From f2b36545b8c8ab91115f94737d5f7af33b70d69f Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 12:50:38 -0400 Subject: [PATCH 01/51] feat(zenable): migrate from zenable-mcp to compiled zenable CLI Replace the Python-based zenable-mcp package (installed via uvx) with the compiled Zenable CLI binary from cli.zenable.app. The CLI is now auto-installed during cookiecutter generation using the official install script, with curl/wget fallback and non-interactive mode. Also fix trufflehog pre-commit hook to work in git worktrees by resolving the repo root via git-common-dir. Co-Authored-By: Claude Opus 4.6 (1M context) --- .pre-commit-config.yaml | 6 +- .pre-commit-hooks.yaml | 2 +- docs/ai-ide-support.md | 14 +++-- hooks/post_gen_project.py | 112 +++++++++++++++++++++++++++++++------- 4 files changed, 107 insertions(+), 27 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f075b5e..aae3b73 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,8 +43,10 @@ repos: rev: ad6fc8fb446b8fafbf7ea8193d2d6bfd42f45690 # frozen: v3.90.11 hooks: - id: trufflehog - # Check the past 2 commits; it's useful to make this go further back than main when running this where main and HEAD are equal - entry: trufflehog git file://. --since-commit main~1 --no-verification --fail + # Resolve the repo root via git-common-dir so this works in both normal repos and worktrees + # (trufflehog doesn't support .git files used by worktrees) + language: system + entry: bash -c 'trufflehog git "file://$(cd "$(git rev-parse --git-common-dir)/.." && pwd)" --branch "$(git rev-parse --abbrev-ref HEAD)" --since-commit main~1 --no-verification --fail' - repo: https://github.com/python-openapi/openapi-spec-validator rev: a76da2ffdaf698a7fdbd755f89b051fef4c790fd # frozen: 0.8.0b1 hooks: diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index c19581b..652c03c 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -2,5 +2,5 @@ - id: zenable-check name: Run a Zenable check on all changed files language: system - entry: uvx zenable-mcp@latest check + entry: zenable check pass_filenames: true diff --git a/docs/ai-ide-support.md b/docs/ai-ide-support.md index 1a0141f..2d7ab38 100644 --- a/docs/ai-ide-support.md +++ b/docs/ai-ide-support.md @@ -7,11 +7,17 @@ The AI-Native Python template automatically configures AI-powered development to ## Automatic Configuration -When you generate a new project, the post-generation hook automatically detects which IDEs and AI assistants you have installed and creates appropriate configuration files: +When you generate a new project, the post-generation hook automatically installs the [Zenable CLI](https://cli.zenable.app) and configures your IDE integrations: -- Model Context Protocol (MCP) configuration for [Zenable](https://zenable.io) and other MCP servers (if supported tools are detected) -- IDE-specific configuration files based on what's installed (Claude, GitHub Copilot, Cursor, etc.) -- Project-specific context and guidelines tailored to your project +**Installation (if the Zenable CLI is not already installed):** + +```bash +curl -fsSL https://cli.zenable.app/install.sh | bash +``` + +**IDE Configuration:** + +Once installed, `zenable install` detects which IDEs and AI assistants you have installed and creates appropriate configuration files for 15+ supported IDEs including Claude Code, Cursor, Windsurf, VS Code, GitHub Copilot, and more. These configurations are dynamically generated based on your installed IDEs and project settings, and include: diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 14e5615..3a8125a 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -141,30 +141,102 @@ def notify_dockerhub_secrets() -> None: print("=" * 70 + "\n") -def opportunistically_install_zenable_tools() -> None: - """Opportunistically install zenable-mcp if uvx is available.""" - # Check if uvx is not available - if not shutil.which("uvx"): - # uvx is not available, notify the user - print("\n" + "=" * 70) - print("NOTE: Skipped configuring the Zenable AI coding guardrails") - print("=" * 70) - print("\nConfiguring the Zenable AI coding guardrails requires the uv package manager.") - print("To set this up later:") - print("\n1. Install uv via https://docs.astral.sh/uv/getting-started/installation/") - print("2. Run: uvx zenable-mcp@latest install") - print("=" * 70 + "\n") +def _find_zenable_binary() -> str | None: + """Find the zenable binary in PATH or the default install location.""" + zenable_path = shutil.which("zenable") + if zenable_path: + return zenable_path + + # Check the default install location + default_path = Path.home() / ".zenable" / "bin" / "zenable" + if default_path.is_file(): + return str(default_path) + + return None + + +def _download_install_script() -> bytes: + """Download the Zenable install script, trying curl then wget.""" + url = "https://cli.zenable.app/install.sh" - LOG.warning("uvx was not found in PATH, so the Zenable integrations were not installed.") - return + if shutil.which("curl"): + result = subprocess.run( + ["curl", "-fsSL", url], + check=True, + capture_output=True, + timeout=60, + ) + return result.stdout + + if shutil.which("wget"): + result = subprocess.run( + ["wget", "-qO-", url], + check=True, + capture_output=True, + timeout=60, + ) + return result.stdout - # uvx is available, attempt to install zenable-mcp - LOG.debug("uvx is available in PATH, attempting to install the Zenable tools...") + msg = "Neither curl nor wget is available" + raise FileNotFoundError(msg) + + +def _install_zenable_binary() -> bool: + """Install the zenable CLI binary for macOS/Linux. + + Runs non-interactively via ZENABLE_YES=1 so no prompts appear during + cookiecutter project generation. + + Returns True if installation succeeded, False otherwise. + """ + env = {**os.environ, "ZENABLE_YES": "1"} + + try: + install_script = _download_install_script() + subprocess.run( + ["bash"], + input=install_script, + check=True, + capture_output=True, + timeout=120, + env=env, + ) + return True + except Exception: + LOG.warning("Failed to install the Zenable CLI binary") + return False + + +def opportunistically_install_zenable_tools() -> None: + """Opportunistically install the Zenable CLI and configure IDE integrations.""" + zenable_bin = _find_zenable_binary() + + if not zenable_bin: + LOG.debug("Zenable CLI not found, attempting to install...") + if not _install_zenable_binary(): + print("\n" + "=" * 70) + print("NOTE: Skipped configuring the Zenable AI coding guardrails") + print("=" * 70) + print("\nTo set this up later, install the Zenable CLI:") + print("\n curl -fsSL https://cli.zenable.app/install.sh | bash") + print("\nThen run: zenable install") + print("=" * 70 + "\n") + + LOG.warning("Zenable CLI could not be installed.") + return + + zenable_bin = _find_zenable_binary() + if not zenable_bin: + LOG.warning("Zenable CLI was installed but could not be found in PATH or default location.") + return + + # Zenable CLI is available, attempt to configure IDE integrations + LOG.debug("Zenable CLI found at %s, configuring IDE integrations...", zenable_bin) try: - subprocess.run(["uvx", "zenable-mcp@latest", "install"], check=True, timeout=60) + subprocess.run([zenable_bin, "install"], check=True, timeout=60) print("\n" + "=" * 70) print("Successfully configured the Zenable AI coding guardrails 🚀") - print("To start using it, just open the IDE of your choice, login to the MCP server, and you're all set 🤖") + print("To start using it, just open the IDE of your choice, login, and you're all set 🤖") print("Learn more at https://docs.zenable.io") print("=" * 70 + "\n") except Exception: @@ -174,7 +246,7 @@ def opportunistically_install_zenable_tools() -> None: print("WARNING: Failed to configure the Zenable AI coding guardrails") print("=" * 70) print("You can retry it later by running:") - print("\n uvx zenable-mcp@latest install") + print("\n zenable install") print("\nTo report issues, please contact:") print(" • https://zenable.io/feedback") print(" • support@zenable.io") From b9974e1ae8cb3f3cad7eea72bff9c9d4c79828e7 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 13:14:54 -0400 Subject: [PATCH 02/51] fix: handle detached HEAD and install trufflehog in CI Guard against detached HEAD by falling back to commit SHA. Use HEAD~1 instead of main~1 to avoid dependency on main branch existing locally. Install trufflehog binary in CI bootstrap since language: system requires it in PATH. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/actions/bootstrap/action.yml | 5 +++++ .pre-commit-config.yaml | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml index 7b5787f..84a43a6 100644 --- a/.github/actions/bootstrap/action.yml +++ b/.github/actions/bootstrap/action.yml @@ -67,6 +67,11 @@ runs: fi working-directory: ${{ inputs.working-directory }} + - name: Install trufflehog + shell: 'bash --noprofile --norc -Eeuo pipefail {0}' + run: | + curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin + - name: Set Python hash for caching shell: 'bash --noprofile --norc -Eeuo pipefail {0}' run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aae3b73..a3e3cf0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,9 +44,10 @@ repos: hooks: - id: trufflehog # Resolve the repo root via git-common-dir so this works in both normal repos and worktrees - # (trufflehog doesn't support .git files used by worktrees) + # (trufflehog doesn't support .git files used by worktrees). + # Guard against detached HEAD (e.g. CI) by falling back to the commit SHA. language: system - entry: bash -c 'trufflehog git "file://$(cd "$(git rev-parse --git-common-dir)/.." && pwd)" --branch "$(git rev-parse --abbrev-ref HEAD)" --since-commit main~1 --no-verification --fail' + entry: bash -c 'BRANCH=$(git rev-parse --abbrev-ref HEAD); [ "$BRANCH" = "HEAD" ] && BRANCH=$(git rev-parse HEAD); trufflehog git "file://$(cd "$(git rev-parse --git-common-dir)/.." && pwd)" --branch "$BRANCH" --since-commit HEAD~1 --no-verification --fail' - repo: https://github.com/python-openapi/openapi-spec-validator rev: a76da2ffdaf698a7fdbd755f89b051fef4c790fd # frozen: 0.8.0b1 hooks: From 030d31836adaeaf429df7194ab30a5d12e0d0947 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 13:15:01 -0400 Subject: [PATCH 03/51] fix: document cosign signature verification in install script Add docstring note that cli.zenable.app/install.sh performs cosign signature verification and checksum validation of the downloaded binary. Co-Authored-By: Claude Opus 4.6 (1M context) --- hooks/post_gen_project.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 3a8125a..3fc954e 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -187,6 +187,10 @@ def _install_zenable_binary() -> bool: Runs non-interactively via ZENABLE_YES=1 so no prompts appear during cookiecutter project generation. + The install script (cli.zenable.app/install.sh) verifies the downloaded + binary via cosign signature verification and checksum validation before + placing it on disk. + Returns True if installation succeeded, False otherwise. """ env = {**os.environ, "ZENABLE_YES": "1"} From dab3cd98ab231491504c177debe8416af72a3415 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 13:26:10 -0400 Subject: [PATCH 04/51] fix: add missing words to cspell dictionaries Add conftest, docstrings, and taskfile to both the root and template dictionaries to fix cspell lint failures in CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/etc/dictionary.txt | 3 +++ .../.github/etc/dictionary.txt" | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/etc/dictionary.txt b/.github/etc/dictionary.txt index 99b9149..cad5dda 100644 --- a/.github/etc/dictionary.txt +++ b/.github/etc/dictionary.txt @@ -1,9 +1,11 @@ allstar anchore buildx +conftest cookiecutter dependabot digestabot +docstrings dockerhub htmlcov pylance @@ -11,5 +13,6 @@ pythonpath refurb skopeo syft +taskfile zenable zizmor diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/.github/etc/dictionary.txt" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/.github/etc/dictionary.txt" index 4518ebc..22fa581 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/.github/etc/dictionary.txt" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/.github/etc/dictionary.txt" @@ -11,9 +11,12 @@ {{ item }} {% endfor %} allstar +conftest dependabot +docstrings refurb renovatebot skopeo syft +taskfile zenable From 1c341f1fc73582eead2c40d67181e2ae89bee776 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 13:35:22 -0400 Subject: [PATCH 05/51] feat: add Windows smoke test to CI pipeline Add a windows-latest job that generates a project from the template and verifies it renders correctly with all defaults. The bootstrap action is updated to handle Windows by skipping Unix-specific steps (Homebrew, trufflehog, sha256sum) and using PowerShell alternatives. The smoke test verifies: - Project directory is created - Key files exist (pyproject.toml, Taskfile.yml, Dockerfile, etc.) - No unrendered cookiecutter variables remain - Git repo is initialized with at least one commit Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/actions/bootstrap/action.yml | 36 +++++++++++- .github/workflows/ci.yml | 85 +++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml index 84a43a6..bddcaff 100644 --- a/.github/actions/bootstrap/action.yml +++ b/.github/actions/bootstrap/action.yml @@ -16,13 +16,21 @@ inputs: runs: using: 'composite' steps: - - name: Create run_script for running scripts downstream + - name: Create run_script for running scripts downstream (Unix) + if: runner.os != 'Windows' shell: 'bash --noprofile --norc -Eeuo pipefail {0}' working-directory: ${{ inputs.working-directory }} run: | run_script="uv run --frozen" echo "run_script=${run_script}" | tee -a "${GITHUB_ENV}" + - name: Create run_script for running scripts downstream (Windows) + if: runner.os == 'Windows' + shell: pwsh + working-directory: ${{ inputs.working-directory }} + run: | + echo "run_script=uv run --frozen" | Tee-Object -Append $env:GITHUB_ENV + - name: Setup uv uses: astral-sh/setup-uv@v4 with: @@ -37,6 +45,7 @@ runs: repo-token: ${{ inputs.token }} - name: Add Homebrew to the path + if: runner.os != 'Windows' shell: 'bash --noprofile --norc -Eeuo pipefail {0}' # This ensures compatibility with macOS runners and Linux runners with Homebrew run: | @@ -68,23 +77,44 @@ runs: working-directory: ${{ inputs.working-directory }} - name: Install trufflehog + if: runner.os != 'Windows' shell: 'bash --noprofile --norc -Eeuo pipefail {0}' run: | curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin - - name: Set Python hash for caching + - name: Set Python hash for caching (Unix) + if: runner.os != 'Windows' shell: 'bash --noprofile --norc -Eeuo pipefail {0}' run: | # Create a hash of the Python version for better cache keys echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" | tee -a "${GITHUB_ENV}" + - name: Set Python hash for caching (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $pyVersion = python -VV 2>&1 + $hash = [System.BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::Create().ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes($pyVersion) + ) + ).Replace("-", "").ToLower() + echo "PY=$hash" | Tee-Object -Append $env:GITHUB_ENV + - name: Cache pre-commit environments uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit|${{ env.PY }}|${{ hashFiles(format('{0}/.pre-commit-config.yaml', inputs.working-directory)) }} - - name: Initialize the repository + - name: Initialize the repository (Unix) + if: runner.os != 'Windows' working-directory: ${{ inputs.working-directory }} shell: 'bash --noprofile --norc -Eeuo pipefail {0}' run: task -v init + + - name: Initialize the repository (Windows) + if: runner.os == 'Windows' + working-directory: ${{ inputs.working-directory }} + shell: pwsh + run: task -v init diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e514934..ab3edab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,89 @@ jobs: name: vuln-scan-results path: vulns.json if-no-files-found: error + windows-smoke-test: + name: Windows Smoke Test + runs-on: windows-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + with: + persist-credentials: 'false' + - name: Setup uv + uses: astral-sh/setup-uv@v4 + with: + python-version: ${{ env.python_version }} + - name: Install Task + uses: go-task/setup-task@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Generate project from template + shell: pwsh + env: + RUN_POST_HOOK: 'true' + SKIP_GIT_PUSH: 'true' + run: | + # Configure git identity for the post-gen commit + git config --global user.name "CI Automation" + git config --global user.email "ci@zenable.io" + + # Generate a project with all defaults using cookiecutter + uvx --with gitpython cookiecutter . --no-input --output-dir "$env:RUNNER_TEMP" + - name: Verify generated project + shell: pwsh + run: | + $project = Join-Path $env:RUNNER_TEMP "replace-me" + + # Verify the project directory was created + if (-not (Test-Path $project)) { + Write-Error "Project directory not found at $project" + exit 1 + } + + # Verify key files exist + $requiredFiles = @( + "pyproject.toml", + "Taskfile.yml", + "Dockerfile", + "CLAUDE.md", + ".github/project.yml", + ".github/workflows/ci.yml" + ) + foreach ($file in $requiredFiles) { + $filePath = Join-Path $project $file + if (-not (Test-Path $filePath)) { + Write-Error "Required file missing: $file" + exit 1 + } + } + + # Verify no unrendered cookiecutter variables remain + $pattern = '\{\{\s*cookiecutter\.' + $matches = Get-ChildItem -Path $project -Recurse -File -Exclude '.git' | + Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' } | + Select-String -Pattern $pattern + if ($matches) { + Write-Error "Unrendered cookiecutter variables found:" + $matches | ForEach-Object { Write-Error $_.ToString() } + exit 1 + } + + # Verify git repo was initialized and has a commit + $gitDir = Join-Path $project ".git" + if (-not (Test-Path $gitDir)) { + Write-Error "Git repository not initialized" + exit 1 + } + + Push-Location $project + $commitCount = git rev-list --count HEAD 2>$null + Pop-Location + if ($commitCount -lt 1) { + Write-Error "No commits found in generated project" + exit 1 + } + + Write-Host "Windows smoke test passed: project generated and verified successfully" finalizer: # This gives us something to set as required in the repo settings. Some projects use dynamic fan-outs using matrix strategies and the fromJSON function, so # you can't hard-code what _should_ run vs not. Having a finalizer simplifies that so you can just check that the finalizer succeeded, and if so, your @@ -110,7 +193,7 @@ jobs: name: Finalize the pipeline runs-on: ubuntu-24.04 # Keep this aligned with the above jobs - needs: [lint, test] + needs: [lint, test, windows-smoke-test] if: always() # Ensure it runs even if "needs" fails or is cancelled steps: - name: Check for failed or cancelled jobs From 640f4245561633dd569731f3e52ace03dbea8d7e Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 13:50:15 -0400 Subject: [PATCH 06/51] fix: allow cookiecutter template paths on Windows checkout The template directory name contains pipe and quote characters ({{cookiecutter.project_name|replace(" ", "")}}) which are invalid on NTFS. Set core.protectNTFS=false before checkout to allow these paths in the Windows smoke test. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab3edab..51993ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,8 @@ jobs: name: Windows Smoke Test runs-on: windows-latest steps: + - name: Allow cookiecutter template paths on Windows + run: git config --global core.protectNTFS false - name: Checkout the repository uses: actions/checkout@v6 with: From 9de528c8e00e38da0d343c49b4d3057a90032339 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 13:52:05 -0400 Subject: [PATCH 07/51] fix: use system git config for core.protectNTFS on Windows actions/checkout overrides HOME with a temp directory, so global git config set in a prior step is lost. Use system-level config instead which persists across HOME changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51993ac..9775fa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,7 @@ jobs: runs-on: windows-latest steps: - name: Allow cookiecutter template paths on Windows - run: git config --global core.protectNTFS false + run: git config --system core.protectNTFS false - name: Checkout the repository uses: actions/checkout@v6 with: From d514c52de058d4f00526ca5f2adb207f34a0d488 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 14:08:04 -0400 Subject: [PATCH 08/51] fix: use GIT_CONFIG_GLOBAL env var for NTFS protection override actions/checkout overrides both HOME and system config. Use the GIT_CONFIG_GLOBAL environment variable to point at a custom gitconfig file that disables core.protectNTFS, which persists through all of checkout's internal git operations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9775fa7..662f73c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,8 +106,16 @@ jobs: name: Windows Smoke Test runs-on: windows-latest steps: - - name: Allow cookiecutter template paths on Windows - run: git config --system core.protectNTFS false + - name: Configure git for cookiecutter template paths + shell: pwsh + run: | + # The cookiecutter template directory contains pipe/quote characters + # that are invalid on NTFS. Write a gitconfig file that disables + # NTFS protection and point GIT_CONFIG_GLOBAL at it so the setting + # persists through actions/checkout's HOME override. + $configPath = Join-Path $env:RUNNER_TEMP "gitconfig-ntfs" + "[core]`n protectNTFS = false" | Out-File -FilePath $configPath -Encoding utf8 + echo "GIT_CONFIG_GLOBAL=$configPath" | Tee-Object -Append $env:GITHUB_ENV - name: Checkout the repository uses: actions/checkout@v6 with: From 05bd4043570e95ea54624e34657d4846e17ecaee Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 14:24:22 -0400 Subject: [PATCH 09/51] fix: pass branch ref through env var to avoid script injection Actionlint flags github.head_ref as potentially untrusted in inline scripts. Pass it through an environment variable instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 662f73c..ad06995 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,20 +106,10 @@ jobs: name: Windows Smoke Test runs-on: windows-latest steps: - - name: Configure git for cookiecutter template paths - shell: pwsh - run: | - # The cookiecutter template directory contains pipe/quote characters - # that are invalid on NTFS. Write a gitconfig file that disables - # NTFS protection and point GIT_CONFIG_GLOBAL at it so the setting - # persists through actions/checkout's HOME override. - $configPath = Join-Path $env:RUNNER_TEMP "gitconfig-ntfs" - "[core]`n protectNTFS = false" | Out-File -FilePath $configPath -Encoding utf8 - echo "GIT_CONFIG_GLOBAL=$configPath" | Tee-Object -Append $env:GITHUB_ENV - - name: Checkout the repository - uses: actions/checkout@v6 - with: - persist-credentials: 'false' + # Note: no checkout step. The cookiecutter template directory contains + # characters (pipe, quotes) that are illegal on NTFS, so we cannot check + # out the repo on Windows. Instead, cookiecutter fetches the template + # directly from the remote branch. - name: Setup uv uses: astral-sh/setup-uv@v4 with: @@ -133,13 +123,17 @@ jobs: env: RUN_POST_HOOK: 'true' SKIP_GIT_PUSH: 'true' + TEMPLATE_BRANCH: ${{ github.head_ref || github.ref_name }} + TEMPLATE_REPO: ${{ github.repository }} run: | # Configure git identity for the post-gen commit git config --global user.name "CI Automation" git config --global user.email "ci@zenable.io" - # Generate a project with all defaults using cookiecutter - uvx --with gitpython cookiecutter . --no-input --output-dir "$env:RUNNER_TEMP" + # Cookiecutter fetches the template from the remote branch. + # This avoids checking out the repo locally, which is impossible on + # Windows due to illegal NTFS characters in the template directory name. + uvx --with gitpython cookiecutter "gh:$env:TEMPLATE_REPO" --checkout "$env:TEMPLATE_BRANCH" --no-input --output-dir "$env:RUNNER_TEMP" - name: Verify generated project shell: pwsh run: | From 955fd5fe402964f99fb9f9d59e7744a5a37de041 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 14:39:02 -0400 Subject: [PATCH 10/51] fix: inject core.protectNTFS via GIT_CONFIG env vars Cookiecutter internally runs git clone, which also fails on NTFS due to the template directory name containing quote characters. Use GIT_CONFIG_COUNT/KEY/VALUE environment variables to inject core.protectNTFS=false into all git invocations without needing a config file or HOME directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad06995..5889d65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,14 +125,18 @@ jobs: SKIP_GIT_PUSH: 'true' TEMPLATE_BRANCH: ${{ github.head_ref || github.ref_name }} TEMPLATE_REPO: ${{ github.repository }} + # The template directory name contains quotes which are illegal on NTFS. + # GIT_CONFIG_COUNT/KEY/VALUE injects core.protectNTFS=false into every + # git invocation (including cookiecutter's internal git clone) without + # needing a config file. + GIT_CONFIG_COUNT: '1' + GIT_CONFIG_KEY_0: core.protectNTFS + GIT_CONFIG_VALUE_0: 'false' run: | - # Configure git identity for the post-gen commit git config --global user.name "CI Automation" git config --global user.email "ci@zenable.io" # Cookiecutter fetches the template from the remote branch. - # This avoids checking out the repo locally, which is impossible on - # Windows due to illegal NTFS characters in the template directory name. uvx --with gitpython cookiecutter "gh:$env:TEMPLATE_REPO" --checkout "$env:TEMPLATE_BRANCH" --no-input --output-dir "$env:RUNNER_TEMP" - name: Verify generated project shell: pwsh From e070fb205feb3e11ea8de716f777bf8287fe35e6 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 14:54:12 -0400 Subject: [PATCH 11/51] fix: use cookiecutter zip download to bypass NTFS path restrictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-quote characters in the template directory name are fundamentally illegal on NTFS — no git config can work around this. Use cookiecutter's zip URL support instead, which extracts via Python's zipfile module and avoids NTFS filesystem restrictions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5889d65..9ab93c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,21 +123,17 @@ jobs: env: RUN_POST_HOOK: 'true' SKIP_GIT_PUSH: 'true' - TEMPLATE_BRANCH: ${{ github.head_ref || github.ref_name }} - TEMPLATE_REPO: ${{ github.repository }} - # The template directory name contains quotes which are illegal on NTFS. - # GIT_CONFIG_COUNT/KEY/VALUE injects core.protectNTFS=false into every - # git invocation (including cookiecutter's internal git clone) without - # needing a config file. - GIT_CONFIG_COUNT: '1' - GIT_CONFIG_KEY_0: core.protectNTFS - GIT_CONFIG_VALUE_0: 'false' + TEMPLATE_REF: ${{ github.event.pull_request.head.sha || github.sha }} run: | git config --global user.name "CI Automation" git config --global user.email "ci@zenable.io" - # Cookiecutter fetches the template from the remote branch. - uvx --with gitpython cookiecutter "gh:$env:TEMPLATE_REPO" --checkout "$env:TEMPLATE_BRANCH" --no-input --output-dir "$env:RUNNER_TEMP" + # The template directory name contains double-quote characters which + # are fundamentally illegal on NTFS — git clone cannot work. Use + # cookiecutter's zip download support instead, which extracts via + # Python's zipfile module and avoids NTFS path restrictions. + $zipUrl = "https://github.com/${{ github.repository }}/archive/$env:TEMPLATE_REF.zip" + uvx --with gitpython cookiecutter "$zipUrl" --no-input --output-dir "$env:RUNNER_TEMP" - name: Verify generated project shell: pwsh run: | From f0fb118fdc6625150e7cb50e2f571a40b7c95345 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 15:10:29 -0400 Subject: [PATCH 12/51] fix: manually extract zip with Python zipfile to handle NTFS chars Cookiecutter's zip URL support still fails because Python's zipfile extraction on Windows cannot create paths with double-quote characters. Download the zip manually, extract with Python (which can handle the entries internally), then point cookiecutter at the extracted directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab93c2..26b9fa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,11 +129,18 @@ jobs: git config --global user.email "ci@zenable.io" # The template directory name contains double-quote characters which - # are fundamentally illegal on NTFS — git clone cannot work. Use - # cookiecutter's zip download support instead, which extracts via - # Python's zipfile module and avoids NTFS path restrictions. + # are fundamentally illegal on NTFS — git clone cannot work. Download + # the archive and extract it with Python's zipfile (which can write + # entries with NTFS-illegal chars because it bypasses Win32 APIs for + # internal temp paths). Then run cookiecutter on the extracted tree. $zipUrl = "https://github.com/${{ github.repository }}/archive/$env:TEMPLATE_REF.zip" - uvx --with gitpython cookiecutter "$zipUrl" --no-input --output-dir "$env:RUNNER_TEMP" + $zipPath = Join-Path $env:RUNNER_TEMP "template.zip" + $extractDir = Join-Path $env:RUNNER_TEMP "template-src" + Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath + + python -c "import zipfile,os,sys; z=zipfile.ZipFile(sys.argv[1]); z.extractall(sys.argv[2]); print(os.path.join(sys.argv[2],os.listdir(sys.argv[2])[0]))" $zipPath $extractDir | Set-Variable templateDir + + uvx --with gitpython cookiecutter $templateDir.Trim() --no-input --output-dir "$env:RUNNER_TEMP" - name: Verify generated project shell: pwsh run: | From c985fcebcdacfdcee03d78e9e8e287cc11acbfb3 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 15:26:31 -0400 Subject: [PATCH 13/51] fix: use extended-length path prefix to bypass NTFS char restrictions Python's zipfile.extractall() strips double-quote characters from paths on Windows since NTFS rejects them. Use the \\?\ extended-length path prefix when extracting, which bypasses NTFS filename validation and allows the template directory with quotes to be created. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26b9fa9..2b65e2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,16 +129,33 @@ jobs: git config --global user.email "ci@zenable.io" # The template directory name contains double-quote characters which - # are fundamentally illegal on NTFS — git clone cannot work. Download - # the archive and extract it with Python's zipfile (which can write - # entries with NTFS-illegal chars because it bypasses Win32 APIs for - # internal temp paths). Then run cookiecutter on the extracted tree. + # are fundamentally illegal on NTFS — git clone and normal extraction + # both fail. Extract using Python with the \\?\ extended-length path + # prefix, which bypasses NTFS name validation on Windows. $zipUrl = "https://github.com/${{ github.repository }}/archive/$env:TEMPLATE_REF.zip" $zipPath = Join-Path $env:RUNNER_TEMP "template.zip" $extractDir = Join-Path $env:RUNNER_TEMP "template-src" Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath - python -c "import zipfile,os,sys; z=zipfile.ZipFile(sys.argv[1]); z.extractall(sys.argv[2]); print(os.path.join(sys.argv[2],os.listdir(sys.argv[2])[0]))" $zipPath $extractDir | Set-Variable templateDir + python -c @" + import zipfile, os, sys + zf = zipfile.ZipFile(sys.argv[1]) + dest = sys.argv[2] + # Use extended-length path prefix to bypass NTFS char restrictions + if os.name == 'nt': + dest = '\\\\?\\' + os.path.abspath(dest) + for info in zf.infolist(): + target = os.path.join(dest, info.filename) + if info.is_dir(): + os.makedirs(target, exist_ok=True) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + with zf.open(info) as src, open(target, 'wb') as dst: + dst.write(src.read()) + # Print the top-level directory (without the \\?\ prefix) + raw_dest = sys.argv[2] + print(os.path.join(raw_dest, os.listdir(dest)[0])) + "@ $zipPath $extractDir | Set-Variable templateDir uvx --with gitpython cookiecutter $templateDir.Trim() --no-input --output-dir "$env:RUNNER_TEMP" - name: Verify generated project From c75e86f6e4f9761098a31143312bbdff506b65f1 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 15:30:51 -0400 Subject: [PATCH 14/51] fix: normalize zip paths to backslashes for \\?\ prefix on Windows The \\?\ extended-length path prefix requires backslashes and no trailing slashes. Zip entries use forward slashes which must be converted to OS-native separators before joining with the prefix. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b65e2d..eb13951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,21 +140,23 @@ jobs: python -c @" import zipfile, os, sys zf = zipfile.ZipFile(sys.argv[1]) - dest = sys.argv[2] - # Use extended-length path prefix to bypass NTFS char restrictions - if os.name == 'nt': - dest = '\\\\?\\' + os.path.abspath(dest) + raw_dest = sys.argv[2] + # Use extended-length path prefix to bypass NTFS char restrictions. + # The prefix requires backslashes and no trailing slashes. + prefix = '\\\\?\\' + os.path.abspath(raw_dest) if os.name == 'nt' else raw_dest for info in zf.infolist(): - target = os.path.join(dest, info.filename) + # Normalize zip forward slashes to OS path separators + clean = info.filename.replace('/', os.sep).rstrip(os.sep) + target = os.path.join(prefix, clean) if info.is_dir(): os.makedirs(target, exist_ok=True) else: os.makedirs(os.path.dirname(target), exist_ok=True) with zf.open(info) as src, open(target, 'wb') as dst: dst.write(src.read()) - # Print the top-level directory (without the \\?\ prefix) - raw_dest = sys.argv[2] - print(os.path.join(raw_dest, os.listdir(dest)[0])) + # Print the top-level directory (without the \\?\ prefix for downstream use) + top = os.listdir(prefix)[0] + print(os.path.join(raw_dest, top)) "@ $zipPath $extractDir | Set-Variable templateDir uvx --with gitpython cookiecutter $templateDir.Trim() --no-input --output-dir "$env:RUNNER_TEMP" From f78df89fcdaeb5957e829a11d18b01a8e842462b Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 15:42:52 -0400 Subject: [PATCH 15/51] fix: replace double quotes with single quotes in NTFS paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double quotes are fundamentally illegal in NTFS paths — even the \\?\ extended-length prefix cannot work around this. Replace " with ' during zip extraction, which is safe because Jinja2 treats both quote types identically: replace(" ", "") and replace(' ', '') produce the same output. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb13951..8895fc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,9 +129,10 @@ jobs: git config --global user.email "ci@zenable.io" # The template directory name contains double-quote characters which - # are fundamentally illegal on NTFS — git clone and normal extraction - # both fail. Extract using Python with the \\?\ extended-length path - # prefix, which bypasses NTFS name validation on Windows. + # are fundamentally illegal on NTFS. Download the zip and extract with + # Python, replacing " with ' in paths during extraction. This is safe + # because Jinja2 treats single and double quotes identically, so + # replace(" ", "") and replace(' ', '') produce the same output. $zipUrl = "https://github.com/${{ github.repository }}/archive/$env:TEMPLATE_REF.zip" $zipPath = Join-Path $env:RUNNER_TEMP "template.zip" $extractDir = Join-Path $env:RUNNER_TEMP "template-src" @@ -140,23 +141,19 @@ jobs: python -c @" import zipfile, os, sys zf = zipfile.ZipFile(sys.argv[1]) - raw_dest = sys.argv[2] - # Use extended-length path prefix to bypass NTFS char restrictions. - # The prefix requires backslashes and no trailing slashes. - prefix = '\\\\?\\' + os.path.abspath(raw_dest) if os.name == 'nt' else raw_dest + dest = sys.argv[2] for info in zf.infolist(): - # Normalize zip forward slashes to OS path separators - clean = info.filename.replace('/', os.sep).rstrip(os.sep) - target = os.path.join(prefix, clean) + name = info.filename + if os.name == 'nt': + name = name.replace(chr(34), chr(39)) + target = os.path.join(dest, name.replace('/', os.sep).rstrip(os.sep)) if info.is_dir(): os.makedirs(target, exist_ok=True) else: os.makedirs(os.path.dirname(target), exist_ok=True) with zf.open(info) as src, open(target, 'wb') as dst: dst.write(src.read()) - # Print the top-level directory (without the \\?\ prefix for downstream use) - top = os.listdir(prefix)[0] - print(os.path.join(raw_dest, top)) + print(os.path.join(dest, os.listdir(dest)[0])) "@ $zipPath $extractDir | Set-Variable templateDir uvx --with gitpython cookiecutter $templateDir.Trim() --no-input --output-dir "$env:RUNNER_TEMP" From d8c66ff6ef036117e7bec3dbd20be90660b352bd Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 15:55:39 -0400 Subject: [PATCH 16/51] fix: use bash shell for Windows template extraction Use Git Bash (available on all Windows runners) for the zip download and extraction. Git Bash's unzip may handle NTFS-illegal characters differently than Python's os.makedirs through MSYS2's path translation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 40 +++++++++++----------------------------- 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8895fc2..40ac3b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,7 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Generate project from template - shell: pwsh + shell: bash env: RUN_POST_HOOK: 'true' SKIP_GIT_PUSH: 'true' @@ -128,35 +128,17 @@ jobs: git config --global user.name "CI Automation" git config --global user.email "ci@zenable.io" - # The template directory name contains double-quote characters which - # are fundamentally illegal on NTFS. Download the zip and extract with - # Python, replacing " with ' in paths during extraction. This is safe - # because Jinja2 treats single and double quotes identically, so - # replace(" ", "") and replace(' ', '') produce the same output. - $zipUrl = "https://github.com/${{ github.repository }}/archive/$env:TEMPLATE_REF.zip" - $zipPath = Join-Path $env:RUNNER_TEMP "template.zip" - $extractDir = Join-Path $env:RUNNER_TEMP "template-src" - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath - - python -c @" - import zipfile, os, sys - zf = zipfile.ZipFile(sys.argv[1]) - dest = sys.argv[2] - for info in zf.infolist(): - name = info.filename - if os.name == 'nt': - name = name.replace(chr(34), chr(39)) - target = os.path.join(dest, name.replace('/', os.sep).rstrip(os.sep)) - if info.is_dir(): - os.makedirs(target, exist_ok=True) - else: - os.makedirs(os.path.dirname(target), exist_ok=True) - with zf.open(info) as src, open(target, 'wb') as dst: - dst.write(src.read()) - print(os.path.join(dest, os.listdir(dest)[0])) - "@ $zipPath $extractDir | Set-Variable templateDir + # The cookiecutter template directory name contains characters that + # are illegal on NTFS (double quotes, pipe). Use Git Bash (available + # on all Windows runners) which operates on a POSIX compatibility + # layer where these characters are valid. + zipUrl="https://github.com/${{ github.repository }}/archive/${TEMPLATE_REF}.zip" + tmpdir=$(mktemp -d) + curl -fsSL "$zipUrl" -o "$tmpdir/template.zip" + unzip -q "$tmpdir/template.zip" -d "$tmpdir/src" + templateDir="$tmpdir/src/$(ls "$tmpdir/src")" - uvx --with gitpython cookiecutter $templateDir.Trim() --no-input --output-dir "$env:RUNNER_TEMP" + uvx --with gitpython cookiecutter "$templateDir" --no-input --output-dir "$RUNNER_TEMP" - name: Verify generated project shell: pwsh run: | From d2d3bd3e54ae981c63ae482cf16490356760f041 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 15:58:18 -0400 Subject: [PATCH 17/51] fix: rename template dir to remove NTFS-illegal chars before cookiecutter The template directory contains | and " which are illegal on NTFS. Extract the zip with unzip (which handles these in MSYS2), then rename the directory to {{cookiecutter.project_name}} which is NTFS-safe and produces identical output for the default project name (no spaces). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40ac3b2..99faf62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,17 +128,32 @@ jobs: git config --global user.name "CI Automation" git config --global user.email "ci@zenable.io" - # The cookiecutter template directory name contains characters that - # are illegal on NTFS (double quotes, pipe). Use Git Bash (available - # on all Windows runners) which operates on a POSIX compatibility - # layer where these characters are valid. + # The template directory name contains NTFS-illegal characters + # (double quotes, pipe). Download and extract the zip, then rename + # the template directory to be NTFS-safe. Cookiecutter identifies + # the template dir by matching {{cookiecutter.* so the renamed dir + # must still start with that prefix. zipUrl="https://github.com/${{ github.repository }}/archive/${TEMPLATE_REF}.zip" tmpdir=$(mktemp -d) curl -fsSL "$zipUrl" -o "$tmpdir/template.zip" unzip -q "$tmpdir/template.zip" -d "$tmpdir/src" - templateDir="$tmpdir/src/$(ls "$tmpdir/src")" + repoDir="$tmpdir/src/$(ls "$tmpdir/src")" - uvx --with gitpython cookiecutter "$templateDir" --no-input --output-dir "$RUNNER_TEMP" + # Rename the template dir to remove NTFS-illegal chars. + # Cookiecutter matches dirs starting with "{{cookiecutter." and + # renders the name as a Jinja2 expression for the output dir. + # {{cookiecutter.project_name}} renders identically to the original + # with replace(" ", "") since the default has no spaces. + cd "$repoDir" + for d in *; do + if [[ "$d" == *'cookiecutter.'* ]]; then + mv "$d" '{{cookiecutter.project_name}}' + break + fi + done + cd - + + uvx --with gitpython cookiecutter "$repoDir" --no-input --output-dir "$RUNNER_TEMP" - name: Verify generated project shell: pwsh run: | From 297cf016e5e1aee4fcb5eef832d19c0842af61d4 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 16:00:10 -0400 Subject: [PATCH 18/51] debug: add logging to Windows template dir rename Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99faf62..6620bcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,12 +145,20 @@ jobs: # {{cookiecutter.project_name}} renders identically to the original # with replace(" ", "") since the default has no spaces. cd "$repoDir" + echo "=== Template dir contents ===" + ls -la + echo "=== Looking for cookiecutter template dir ===" for d in *; do + echo " checking: $d" if [[ "$d" == *'cookiecutter.'* ]]; then + echo " FOUND template dir: $d" mv "$d" '{{cookiecutter.project_name}}' + echo " renamed to: {{cookiecutter.project_name}}" break fi done + echo "=== After rename ===" + ls -la cd - uvx --with gitpython cookiecutter "$repoDir" --no-input --output-dir "$RUNNER_TEMP" From 64f0b8d92246d72732151fcc3105c2eec9781f44 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 16:02:04 -0400 Subject: [PATCH 19/51] fix: extract zip with Python, renaming template dir for NTFS safety unzip silently skips entries with NTFS-illegal chars. Use Python's zipfile to extract all entries, renaming any directory containing {{cookiecutter. to {{cookiecutter.project_name}} which is NTFS-safe and renders identically for the default project name. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 45 ++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6620bcd..70985cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,37 +129,28 @@ jobs: git config --global user.email "ci@zenable.io" # The template directory name contains NTFS-illegal characters - # (double quotes, pipe). Download and extract the zip, then rename - # the template directory to be NTFS-safe. Cookiecutter identifies - # the template dir by matching {{cookiecutter.* so the renamed dir - # must still start with that prefix. + # (double quotes, pipe). Use Python to extract the zip, renaming + # the template dir to {{cookiecutter.project_name}} which is + # NTFS-safe and renders identically for defaults (no spaces). zipUrl="https://github.com/${{ github.repository }}/archive/${TEMPLATE_REF}.zip" tmpdir=$(mktemp -d) curl -fsSL "$zipUrl" -o "$tmpdir/template.zip" - unzip -q "$tmpdir/template.zip" -d "$tmpdir/src" - repoDir="$tmpdir/src/$(ls "$tmpdir/src")" - # Rename the template dir to remove NTFS-illegal chars. - # Cookiecutter matches dirs starting with "{{cookiecutter." and - # renders the name as a Jinja2 expression for the output dir. - # {{cookiecutter.project_name}} renders identically to the original - # with replace(" ", "") since the default has no spaces. - cd "$repoDir" - echo "=== Template dir contents ===" - ls -la - echo "=== Looking for cookiecutter template dir ===" - for d in *; do - echo " checking: $d" - if [[ "$d" == *'cookiecutter.'* ]]; then - echo " FOUND template dir: $d" - mv "$d" '{{cookiecutter.project_name}}' - echo " renamed to: {{cookiecutter.project_name}}" - break - fi - done - echo "=== After rename ===" - ls -la - cd - + repoDir=$(python3 -c " + import zipfile, os, sys + zf, dest = zipfile.ZipFile(sys.argv[1]), sys.argv[2] + for info in zf.infolist(): + parts = info.filename.split('/') + safe = [('{{cookiecutter.project_name}}' if '{{cookiecutter.' in p else p) for p in parts] + target = os.path.join(dest, *[s for s in safe if s]) + if info.is_dir(): + os.makedirs(target, exist_ok=True) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + with zf.open(info) as s, open(target, 'wb') as d: + d.write(s.read()) + print(os.path.join(dest, os.listdir(dest)[0])) + " "$tmpdir/template.zip" "$tmpdir/src") uvx --with gitpython cookiecutter "$repoDir" --no-input --output-dir "$RUNNER_TEMP" - name: Verify generated project From 9b05e30e9fb7d42c1c43598b1aecc001e16ba281 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Fri, 3 Apr 2026 20:40:25 -0400 Subject: [PATCH 20/51] feat: verify install script checksum before execution Fetch release metadata from cli.zenable.app/zenable/latest and verify the install.sh SHA-256 checksum before piping it to bash. Uses Python stdlib (urllib.request, hashlib) instead of curl/wget for downloading. The install script itself also performs cosign signature verification of the binary it downloads, providing defense in depth. Co-Authored-By: Claude Opus 4.6 (1M context) --- hooks/post_gen_project.py | 66 ++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 3fc954e..764df2e 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -4,6 +4,7 @@ """ import datetime +import hashlib import json import os import pprint @@ -13,6 +14,7 @@ from collections import OrderedDict from logging import basicConfig, getLogger from pathlib import Path +from urllib.request import urlopen import yaml @@ -155,48 +157,53 @@ def _find_zenable_binary() -> str | None: return None -def _download_install_script() -> bytes: - """Download the Zenable install script, trying curl then wget.""" - url = "https://cli.zenable.app/install.sh" +ZENABLE_RELEASE_URL = "https://cli.zenable.app/zenable/latest" - if shutil.which("curl"): - result = subprocess.run( - ["curl", "-fsSL", url], - check=True, - capture_output=True, - timeout=60, - ) - return result.stdout - if shutil.which("wget"): - result = subprocess.run( - ["wget", "-qO-", url], - check=True, - capture_output=True, - timeout=60, - ) - return result.stdout +def _fetch_release_metadata() -> dict: + """Fetch the Zenable CLI release metadata from cli.zenable.app.""" + with urlopen(ZENABLE_RELEASE_URL, timeout=30) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def _download_url(url: str) -> bytes: + """Download a URL and return the raw bytes.""" + with urlopen(url, timeout=60) as resp: # noqa: S310 + return resp.read() - msg = "Neither curl nor wget is available" - raise FileNotFoundError(msg) + +def _verify_checksum(data: bytes, expected_sha256: str) -> None: + """Verify SHA-256 checksum of data against the expected value. + + Raises ValueError if the checksum does not match. + """ + actual = hashlib.sha256(data).hexdigest() + if actual != expected_sha256: + msg = f"Checksum mismatch: expected {expected_sha256}, got {actual}" + raise ValueError(msg) def _install_zenable_binary() -> bool: """Install the zenable CLI binary for macOS/Linux. - Runs non-interactively via ZENABLE_YES=1 so no prompts appear during - cookiecutter project generation. - - The install script (cli.zenable.app/install.sh) verifies the downloaded - binary via cosign signature verification and checksum validation before - placing it on disk. + Fetches the release metadata from cli.zenable.app/zenable/latest, + downloads install.sh, verifies its SHA-256 checksum, then executes + it non-interactively. The install script itself also performs cosign + signature verification of the downloaded binary. Returns True if installation succeeded, False otherwise. """ env = {**os.environ, "ZENABLE_YES": "1"} try: - install_script = _download_install_script() + metadata = _fetch_release_metadata() + + install_url = metadata["installers"]["install.sh"] + expected_checksum = metadata["installer_checksums"]["install.sh"] + + install_script = _download_url(install_url) + _verify_checksum(install_script, expected_checksum) + subprocess.run( ["bash"], input=install_script, @@ -206,6 +213,9 @@ def _install_zenable_binary() -> bool: env=env, ) return True + except ValueError: + LOG.warning("Zenable install script checksum verification failed") + return False except Exception: LOG.warning("Failed to install the Zenable CLI binary") return False From b2080373366835724feb01636eba2108fc232b1c Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 17:13:24 -0400 Subject: [PATCH 21/51] fix: validate URL scheme instead of blanket noqa for S310 Introduce _https_urlopen() that validates the URL uses HTTPS before calling urlopen, centralizing the SSRF mitigation. The noqa: S310 is now confined to a single validated call site instead of scattered across multiple callers. Co-Authored-By: Claude Opus 4.6 (1M context) --- hooks/post_gen_project.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 764df2e..97f456c 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -14,6 +14,7 @@ from collections import OrderedDict from logging import basicConfig, getLogger from pathlib import Path +from urllib.parse import urlparse from urllib.request import urlopen import yaml @@ -160,15 +161,24 @@ def _find_zenable_binary() -> str | None: ZENABLE_RELEASE_URL = "https://cli.zenable.app/zenable/latest" +def _https_urlopen(url: str, *, timeout: int) -> object: + """Open a URL after validating it uses HTTPS to mitigate SSRF risks.""" + parsed = urlparse(url) + if parsed.scheme != "https": + msg = f"Only HTTPS URLs are allowed, got scheme: {parsed.scheme!r}" + raise ValueError(msg) + return urlopen(url, timeout=timeout) # noqa: S310 + + def _fetch_release_metadata() -> dict: """Fetch the Zenable CLI release metadata from cli.zenable.app.""" - with urlopen(ZENABLE_RELEASE_URL, timeout=30) as resp: # noqa: S310 + with _https_urlopen(ZENABLE_RELEASE_URL, timeout=30) as resp: return json.loads(resp.read()) def _download_url(url: str) -> bytes: """Download a URL and return the raw bytes.""" - with urlopen(url, timeout=60) as resp: # noqa: S310 + with _https_urlopen(url, timeout=60) as resp: return resp.read() From d3c4b21cefe549cb98ce438ec4ae05da48159d0e Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 17:13:49 -0400 Subject: [PATCH 22/51] fix: add Windows support to zenable CLI install and discovery Use sys.platform to select the appropriate installer (install.ps1 on Windows, install.sh elsewhere) and binary name (zenable.exe on Windows). Co-Authored-By: Claude Opus 4.6 (1M context) --- hooks/post_gen_project.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 97f456c..8464bfa 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -151,7 +151,8 @@ def _find_zenable_binary() -> str | None: return zenable_path # Check the default install location - default_path = Path.home() / ".zenable" / "bin" / "zenable" + binary_name = "zenable.exe" if sys.platform == "win32" else "zenable" + default_path = Path.home() / ".zenable" / "bin" / binary_name if default_path.is_file(): return str(default_path) @@ -194,12 +195,13 @@ def _verify_checksum(data: bytes, expected_sha256: str) -> None: def _install_zenable_binary() -> bool: - """Install the zenable CLI binary for macOS/Linux. + """Install the zenable CLI binary. Fetches the release metadata from cli.zenable.app/zenable/latest, - downloads install.sh, verifies its SHA-256 checksum, then executes - it non-interactively. The install script itself also performs cosign - signature verification of the downloaded binary. + downloads the appropriate installer for the current platform, verifies + its SHA-256 checksum, then executes it non-interactively. The install + script itself also performs cosign signature verification of the + downloaded binary. Returns True if installation succeeded, False otherwise. """ @@ -208,14 +210,21 @@ def _install_zenable_binary() -> bool: try: metadata = _fetch_release_metadata() - install_url = metadata["installers"]["install.sh"] - expected_checksum = metadata["installer_checksums"]["install.sh"] + if sys.platform == "win32": + installer_key = "install.ps1" + cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-Command", "-"] + else: + installer_key = "install.sh" + cmd = ["bash"] + + install_url = metadata["installers"][installer_key] + expected_checksum = metadata["installer_checksums"][installer_key] install_script = _download_url(install_url) _verify_checksum(install_script, expected_checksum) subprocess.run( - ["bash"], + cmd, input=install_script, check=True, capture_output=True, From 49745f6cc231938c86ed4ddd805c92d9957be1a3 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 17:41:48 -0400 Subject: [PATCH 23/51] fix: add zenable install dir to PATH after install The installer updates PATH for future shells (via GITHUB_PATH in CI or shell profile locally) but not the current Python process. After a successful install, prepend ~/.zenable/bin to the process PATH so _find_zenable_binary() can locate the binary immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --- hooks/post_gen_project.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 8464bfa..8b52636 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -258,6 +258,13 @@ def opportunistically_install_zenable_tools() -> None: LOG.warning("Zenable CLI could not be installed.") return + # The installer updates PATH for future shells/steps (e.g. via + # GITHUB_PATH or the user's shell profile) but not the current + # process. Add the default install directory so we can find the + # binary immediately. + zenable_bin_dir = str(Path.home() / ".zenable" / "bin") + os.environ["PATH"] = zenable_bin_dir + os.pathsep + os.environ.get("PATH", "") + zenable_bin = _find_zenable_binary() if not zenable_bin: LOG.warning("Zenable CLI was installed but could not be found in PATH or default location.") From dc2abc27e5f8391358a6c7f7c8676b80a4586482 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 17:43:57 -0400 Subject: [PATCH 24/51] fix: use HTTPS-only opener instead of urlopen with noqa Replace urlopen (which accepts arbitrary URL schemes) with an opener built from HTTPSHandler only. This makes HTTPS-only enforcement structural rather than relying on runtime validation + linter suppression. Co-Authored-By: Claude Opus 4.6 (1M context) --- hooks/post_gen_project.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 8b52636..f7864a7 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -14,8 +14,7 @@ from collections import OrderedDict from logging import basicConfig, getLogger from pathlib import Path -from urllib.parse import urlparse -from urllib.request import urlopen +from urllib.request import HTTPSHandler, build_opener import yaml @@ -162,24 +161,18 @@ def _find_zenable_binary() -> str | None: ZENABLE_RELEASE_URL = "https://cli.zenable.app/zenable/latest" -def _https_urlopen(url: str, *, timeout: int) -> object: - """Open a URL after validating it uses HTTPS to mitigate SSRF risks.""" - parsed = urlparse(url) - if parsed.scheme != "https": - msg = f"Only HTTPS URLs are allowed, got scheme: {parsed.scheme!r}" - raise ValueError(msg) - return urlopen(url, timeout=timeout) # noqa: S310 +_https_opener = build_opener(HTTPSHandler()) def _fetch_release_metadata() -> dict: """Fetch the Zenable CLI release metadata from cli.zenable.app.""" - with _https_urlopen(ZENABLE_RELEASE_URL, timeout=30) as resp: + with _https_opener.open(ZENABLE_RELEASE_URL, timeout=30) as resp: return json.loads(resp.read()) def _download_url(url: str) -> bytes: """Download a URL and return the raw bytes.""" - with _https_urlopen(url, timeout=60) as resp: + with _https_opener.open(url, timeout=60) as resp: return resp.read() From ead2dd9bfdf52465bb8951632d969701ac7b744f Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 18:01:24 -0400 Subject: [PATCH 25/51] feat: expand Windows smoke test with init, test, build, and docker run Add end-to-end verification steps to the Windows CI job: initialize the generated project, run tests, build the Docker image, verify the image runs, and confirm the zenable CLI is functional. Also add diagnostic logging to the post-gen hook when the zenable binary cannot be found after installation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ hooks/post_gen_project.py | 14 +++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70985cb..b1cc94f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,31 @@ jobs: } Write-Host "Windows smoke test passed: project generated and verified successfully" + - name: Initialize generated project + shell: bash + run: | + cd "$RUNNER_TEMP/replace-me" + task -v init + - name: Run tests + shell: bash + run: | + cd "$RUNNER_TEMP/replace-me" + task -v test + - name: Build Docker image + shell: bash + run: | + cd "$RUNNER_TEMP/replace-me" + task -v build + - name: Verify Docker image + shell: bash + run: | + docker run --rm --entrypoint python3 zenable-io/replace-me:latest \ + -c "from replace_me import __version__; print(__version__)" + - name: Verify zenable CLI + shell: bash + run: | + export PATH="$HOME/.zenable/bin:$PATH" + zenable version finalizer: # This gives us something to set as required in the repo settings. Some projects use dynamic fan-outs using matrix strategies and the fromJSON function, so # you can't hard-code what _should_ run vs not. Having a finalizer simplifies that so you can just check that the finalizer succeeded, and if so, your diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index f7864a7..e8e643b 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -216,7 +216,7 @@ def _install_zenable_binary() -> bool: install_script = _download_url(install_url) _verify_checksum(install_script, expected_checksum) - subprocess.run( + result = subprocess.run( cmd, input=install_script, check=True, @@ -224,6 +224,10 @@ def _install_zenable_binary() -> bool: timeout=120, env=env, ) + if result.stdout: + LOG.info("Zenable installer stdout: %s", result.stdout.decode("utf-8", errors="replace").strip()) + if result.stderr: + LOG.info("Zenable installer stderr: %s", result.stderr.decode("utf-8", errors="replace").strip()) return True except ValueError: LOG.warning("Zenable install script checksum verification failed") @@ -260,6 +264,14 @@ def opportunistically_install_zenable_tools() -> None: zenable_bin = _find_zenable_binary() if not zenable_bin: + # Diagnostic: log what the installer actually created + zenable_dir = Path.home() / ".zenable" + if zenable_dir.exists(): + contents = [str(p.relative_to(zenable_dir)) for p in zenable_dir.rglob("*")] + LOG.warning("Install dir %s contents: %s", zenable_dir, contents) + else: + LOG.warning("Install directory does not exist: %s", zenable_dir) + LOG.warning("Current PATH: %s", os.environ.get("PATH", "")) LOG.warning("Zenable CLI was installed but could not be found in PATH or default location.") return From 57c59f3f491fab8235f27668879de5fc5ea8dd4b Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 18:21:34 -0400 Subject: [PATCH 26/51] fix: bootstrap venv before task commands in Windows smoke test Task eagerly evaluates top-level vars including VERSION which runs `uv run python -c` to import the project. Bootstrap the venv with `uv sync` and set PYTHONPATH so the namespace package import works before any task commands. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1cc94f..776dca9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,16 +212,22 @@ jobs: shell: bash run: | cd "$RUNNER_TEMP/replace-me" + # Bootstrap the venv first so Task can evaluate top-level vars + # (VERSION uses `uv run python -c` which needs the project importable) + uv sync --frozen --all-extras + export PYTHONPATH="$PWD" task -v init - name: Run tests shell: bash run: | cd "$RUNNER_TEMP/replace-me" + export PYTHONPATH="$PWD" task -v test - name: Build Docker image shell: bash run: | cd "$RUNNER_TEMP/replace-me" + export PYTHONPATH="$PWD" task -v build - name: Verify Docker image shell: bash From c21c994a50dd6bbeb0f426c72000bf3fbcddeee8 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 18:38:44 -0400 Subject: [PATCH 27/51] fix: override VERSION and LOCAL_PLATFORM env vars for Windows task commands Task eagerly evaluates top-level sh: vars and the namespace-package import for VERSION fails on Windows. Supply both VERSION and LOCAL_PLATFORM as env-var overrides so Task skips the sh: evaluation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 776dca9..3a3f74d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,24 +210,31 @@ jobs: Write-Host "Windows smoke test passed: project generated and verified successfully" - name: Initialize generated project shell: bash + # VERSION and LOCAL_PLATFORM are top-level Taskfile vars evaluated via + # `uv run` shell commands. On Windows the namespace-package import + # that resolves VERSION fails, so we supply both as env-var overrides. + env: + VERSION: '0.0.0' + LOCAL_PLATFORM: 'linux/amd64' run: | cd "$RUNNER_TEMP/replace-me" - # Bootstrap the venv first so Task can evaluate top-level vars - # (VERSION uses `uv run python -c` which needs the project importable) uv sync --frozen --all-extras - export PYTHONPATH="$PWD" task -v init - name: Run tests shell: bash + env: + VERSION: '0.0.0' + LOCAL_PLATFORM: 'linux/amd64' run: | cd "$RUNNER_TEMP/replace-me" - export PYTHONPATH="$PWD" task -v test - name: Build Docker image shell: bash + env: + VERSION: '0.0.0' + LOCAL_PLATFORM: 'linux/amd64' run: | cd "$RUNNER_TEMP/replace-me" - export PYTHONPATH="$PWD" task -v build - name: Verify Docker image shell: bash From dba422fa631cb78eea13ca6bc3bcbd5f423253d3 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 18:54:50 -0400 Subject: [PATCH 28/51] fix: bypass Task for Windows CI steps to avoid VERSION var evaluation Task eagerly evaluates all top-level sh: vars including VERSION which uses a namespace-package import that fails on Windows. Run init, test, and build commands directly instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a3f74d..7591245 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,32 +210,32 @@ jobs: Write-Host "Windows smoke test passed: project generated and verified successfully" - name: Initialize generated project shell: bash - # VERSION and LOCAL_PLATFORM are top-level Taskfile vars evaluated via - # `uv run` shell commands. On Windows the namespace-package import - # that resolves VERSION fails, so we supply both as env-var overrides. - env: - VERSION: '0.0.0' - LOCAL_PLATFORM: 'linux/amd64' + # Task's top-level VERSION var uses a namespace-package import that + # fails on Windows, so we run the init commands directly. run: | cd "$RUNNER_TEMP/replace-me" uv sync --frozen --all-extras - task -v init - name: Run tests shell: bash - env: - VERSION: '0.0.0' - LOCAL_PLATFORM: 'linux/amd64' run: | cd "$RUNNER_TEMP/replace-me" - task -v test + uv run --frozen pytest -m "unit" tests/ + uv run --frozen pytest -m "integration" tests/ - name: Build Docker image shell: bash - env: - VERSION: '0.0.0' - LOCAL_PLATFORM: 'linux/amd64' run: | cd "$RUNNER_TEMP/replace-me" - task -v build + docker buildx create --name multiplatform --driver docker-container --use 2>/dev/null || true + docker buildx build \ + --platform linux/amd64 \ + --pull \ + --load \ + --build-arg NAME="replace_me" \ + --build-arg DESCRIPTION="A brief description of the project" \ + --build-arg TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --build-arg COMMIT_HASH="$(git rev-parse HEAD)" \ + -t zenable-io/replace-me:latest \ + . - name: Verify Docker image shell: bash run: | From 85fc50d68e443ebeffda034a7081ed0a7f7b1b79 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 19:07:16 -0400 Subject: [PATCH 29/51] fix: add Windows support to Taskfile VERSION and LOCAL_PLATFORM vars The VERSION var used a namespace-package import (from src.X) that fails on Windows. Switch to explicit sys.path.insert so the import works cross-platform. Also use bash directly for LOCAL_PLATFORM instead of uv run, since shell scripts need bash on Windows. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 19 +++---------------- .../Taskfile.yml" | 4 ++-- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7591245..b1cc94f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,32 +210,19 @@ jobs: Write-Host "Windows smoke test passed: project generated and verified successfully" - name: Initialize generated project shell: bash - # Task's top-level VERSION var uses a namespace-package import that - # fails on Windows, so we run the init commands directly. run: | cd "$RUNNER_TEMP/replace-me" - uv sync --frozen --all-extras + task -v init - name: Run tests shell: bash run: | cd "$RUNNER_TEMP/replace-me" - uv run --frozen pytest -m "unit" tests/ - uv run --frozen pytest -m "integration" tests/ + task -v test - name: Build Docker image shell: bash run: | cd "$RUNNER_TEMP/replace-me" - docker buildx create --name multiplatform --driver docker-container --use 2>/dev/null || true - docker buildx build \ - --platform linux/amd64 \ - --pull \ - --load \ - --build-arg NAME="replace_me" \ - --build-arg DESCRIPTION="A brief description of the project" \ - --build-arg TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --build-arg COMMIT_HASH="$(git rev-parse HEAD)" \ - -t zenable-io/replace-me:latest \ - . + task -v build - name: Verify Docker image shell: bash run: | diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index 4f0955e..bbaf883 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -14,11 +14,11 @@ vars: PYTHON_VERSION: {{ cookiecutter.python_version }} SUPPORTED_PLATFORMS: 'linux/amd64,linux/arm64' VERSION: - sh: uv run python -c 'from src.{{ '{{.PROJECT_SLUG}}' }} import __version__; print(__version__)' + sh: uv run python -c "import sys; sys.path.insert(0, 'src'); from {{ '{{.PROJECT_SLUG}}' }} import __version__; print(__version__)" RUN_SCRIPT: 'uv run --frozen' SCRIPTS_DIR: 'scripts' LOCAL_PLATFORM: - sh: "{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" + sh: "bash {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" # Use PLATFORM if specified, otherwise use LOCAL_PLATFORM PLATFORM: '{{ '{{if .PLATFORM}}' }}{{ '{{.PLATFORM}}' }}{{ '{{else}}' }}{{ '{{.LOCAL_PLATFORM}}' }}{{ '{{end}}' }}' # Output redirect based on CI environment From 8c3ec2ca823dd8e196d4cde740abba4d5b45001a Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 19:28:19 -0400 Subject: [PATCH 30/51] fix: extract VERSION via file parsing instead of Python import Replace the namespace-package import (`from src.X import __version__`) with a helper script that reads __init__.py directly. The script resolves the file path relative to its own location, avoiding CWD and import-mechanism issues on Windows. Includes ZENABLE_LOGLEVEL=DEBUG support for diagnostics. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 + .../Taskfile.yml" | 2 +- .../scripts/get_version.py" | 54 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100755 "{{cookiecutter.project_name|replace(\" \", \"\")}}/scripts/get_version.py" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1cc94f..e4e82a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,8 @@ jobs: windows-smoke-test: name: Windows Smoke Test runs-on: windows-latest + env: + ZENABLE_LOGLEVEL: DEBUG steps: # Note: no checkout step. The cookiecutter template directory contains # characters (pipe, quotes) that are illegal on NTFS, so we cannot check diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index bbaf883..b07b9a0 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -14,7 +14,7 @@ vars: PYTHON_VERSION: {{ cookiecutter.python_version }} SUPPORTED_PLATFORMS: 'linux/amd64,linux/arm64' VERSION: - sh: uv run python -c "import sys; sys.path.insert(0, 'src'); from {{ '{{.PROJECT_SLUG}}' }} import __version__; print(__version__)" + sh: "{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_version.py {{ '{{.PROJECT_SLUG}}' }}" RUN_SCRIPT: 'uv run --frozen' SCRIPTS_DIR: 'scripts' LOCAL_PLATFORM: diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/scripts/get_version.py" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/scripts/get_version.py" new file mode 100755 index 0000000..2c2052d --- /dev/null +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/scripts/get_version.py" @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Extract __version__ from the project's __init__.py without importing it. + +This avoids import-mechanism issues (namespace packages, venv state, etc.) +that can occur cross-platform. The script locates the file relative to its +own position in the repo so it works regardless of the caller's CWD. +""" + +import os +import pathlib +import re +import sys + +FALLBACK = "0.0.0" + + +def _debug(msg: str) -> None: + if os.environ.get("ZENABLE_LOGLEVEL", "").upper() == "DEBUG": + print(f"get_version: {msg}", file=sys.stderr) + + +def main() -> None: + if len(sys.argv) < 2: + print(FALLBACK) + sys.exit(0) + + project_slug = sys.argv[1] + + # Resolve relative to this script's location (scripts/ -> project root) + script_dir = pathlib.Path(__file__).resolve().parent + project_root = script_dir.parent + init_file = project_root / "src" / project_slug / "__init__.py" + + _debug(f"cwd={os.getcwd()}") + _debug(f"script_dir={script_dir}") + _debug(f"project_root={project_root}") + _debug(f"init_file={init_file} exists={init_file.exists()}") + + if not init_file.exists(): + _debug(f"init file not found, falling back to {FALLBACK}") + print(FALLBACK) + sys.exit(0) + + text = init_file.read_text(encoding="utf-8") + match = re.search(r'__version__\s*=\s*"(.+?)"', text) + if match: + print(match.group(1)) + else: + _debug(f"no __version__ found in {init_file}, falling back to {FALLBACK}") + print(FALLBACK) + + +if __name__ == "__main__": + main() From d25b55e67bbedcd217feae2d7519f98ece34146e Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 19:34:45 -0400 Subject: [PATCH 31/51] fix: move VERSION var below RUN_SCRIPT and SCRIPTS_DIR in Taskfile Task resolves vars in definition order, so VERSION must be defined after the variables it references (RUN_SCRIPT, SCRIPTS_DIR). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Taskfile.yml" | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index b07b9a0..ea48245 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -13,10 +13,10 @@ vars: PROJECT_SLUG: {{ cookiecutter.project_slug }} PYTHON_VERSION: {{ cookiecutter.python_version }} SUPPORTED_PLATFORMS: 'linux/amd64,linux/arm64' - VERSION: - sh: "{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_version.py {{ '{{.PROJECT_SLUG}}' }}" RUN_SCRIPT: 'uv run --frozen' SCRIPTS_DIR: 'scripts' + VERSION: + sh: "{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_version.py {{ '{{.PROJECT_SLUG}}' }}" LOCAL_PLATFORM: sh: "bash {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" # Use PLATFORM if specified, otherwise use LOCAL_PLATFORM From d95362bd25ca485581913e2f9453cc40cc7a6be6 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 19:48:43 -0400 Subject: [PATCH 32/51] fix: use inline Python for VERSION var and disable Linux CI temporarily Remove get_version.py in favor of inline sys.path.insert approach (which should now work since var ordering is fixed). Disable lint/test jobs temporarily to iterate faster on Windows support. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 4 +- .../Taskfile.yml" | 2 +- .../scripts/get_version.py" | 54 ------------------- 3 files changed, 4 insertions(+), 56 deletions(-) delete mode 100755 "{{cookiecutter.project_name|replace(\" \", \"\")}}/scripts/get_version.py" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4e82a8..c1dfbce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ defaults: jobs: lint: name: Lint + if: false # Temporarily disabled while iterating on Windows support runs-on: ubuntu-24.04 steps: - name: Checkout the repository @@ -39,6 +40,7 @@ jobs: run: task -v lint test: name: Test + if: false # Temporarily disabled while iterating on Windows support runs-on: ubuntu-24.04 permissions: contents: write @@ -243,7 +245,7 @@ jobs: name: Finalize the pipeline runs-on: ubuntu-24.04 # Keep this aligned with the above jobs - needs: [lint, test, windows-smoke-test] + needs: [lint, test, windows-smoke-test] # lint and test temporarily disabled if: always() # Ensure it runs even if "needs" fails or is cancelled steps: - name: Check for failed or cancelled jobs diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index ea48245..8186ac1 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -16,7 +16,7 @@ vars: RUN_SCRIPT: 'uv run --frozen' SCRIPTS_DIR: 'scripts' VERSION: - sh: "{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_version.py {{ '{{.PROJECT_SLUG}}' }}" + sh: "{{ '{{.RUN_SCRIPT}}' }} python -c \"import sys; sys.path.insert(0, 'src'); from {{ '{{.PROJECT_SLUG}}' }} import __version__; print(__version__)\"" LOCAL_PLATFORM: sh: "bash {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" # Use PLATFORM if specified, otherwise use LOCAL_PLATFORM diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/scripts/get_version.py" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/scripts/get_version.py" deleted file mode 100755 index 2c2052d..0000000 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/scripts/get_version.py" +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -"""Extract __version__ from the project's __init__.py without importing it. - -This avoids import-mechanism issues (namespace packages, venv state, etc.) -that can occur cross-platform. The script locates the file relative to its -own position in the repo so it works regardless of the caller's CWD. -""" - -import os -import pathlib -import re -import sys - -FALLBACK = "0.0.0" - - -def _debug(msg: str) -> None: - if os.environ.get("ZENABLE_LOGLEVEL", "").upper() == "DEBUG": - print(f"get_version: {msg}", file=sys.stderr) - - -def main() -> None: - if len(sys.argv) < 2: - print(FALLBACK) - sys.exit(0) - - project_slug = sys.argv[1] - - # Resolve relative to this script's location (scripts/ -> project root) - script_dir = pathlib.Path(__file__).resolve().parent - project_root = script_dir.parent - init_file = project_root / "src" / project_slug / "__init__.py" - - _debug(f"cwd={os.getcwd()}") - _debug(f"script_dir={script_dir}") - _debug(f"project_root={project_root}") - _debug(f"init_file={init_file} exists={init_file.exists()}") - - if not init_file.exists(): - _debug(f"init file not found, falling back to {FALLBACK}") - print(FALLBACK) - sys.exit(0) - - text = init_file.read_text(encoding="utf-8") - match = re.search(r'__version__\s*=\s*"(.+?)"', text) - if match: - print(match.group(1)) - else: - _debug(f"no __version__ found in {init_file}, falling back to {FALLBACK}") - print(FALLBACK) - - -if __name__ == "__main__": - main() From 206f9b4efb1e21db7b7891117e53a5ad5d9e20c1 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 19:55:03 -0400 Subject: [PATCH 33/51] fix: add debug output to VERSION var and use absolute path Print CWD and src directory existence to stderr to diagnose why the import fails on Windows. Use os.path.join for absolute path. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Taskfile.yml" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index 8186ac1..6941fca 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -16,7 +16,7 @@ vars: RUN_SCRIPT: 'uv run --frozen' SCRIPTS_DIR: 'scripts' VERSION: - sh: "{{ '{{.RUN_SCRIPT}}' }} python -c \"import sys; sys.path.insert(0, 'src'); from {{ '{{.PROJECT_SLUG}}' }} import __version__; print(__version__)\"" + sh: "{{ '{{.RUN_SCRIPT}}' }} python -c \"import sys,os;d=os.path.join(os.getcwd(),'src');print(f'DEBUG cwd={os.getcwd()} src_exists={os.path.isdir(d)}',file=sys.stderr);sys.path.insert(0,d);from {{ '{{.PROJECT_SLUG}}' }} import __version__;print(__version__)\"" LOCAL_PLATFORM: sh: "bash {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" # Use PLATFORM if specified, otherwise use LOCAL_PLATFORM From c3c3bfb55b8813005ca4a760b4833d962705e88e Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 20:00:18 -0400 Subject: [PATCH 34/51] fix: add src contents and syspath to VERSION debug output Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Taskfile.yml" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index 6941fca..18ed0db 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -16,7 +16,7 @@ vars: RUN_SCRIPT: 'uv run --frozen' SCRIPTS_DIR: 'scripts' VERSION: - sh: "{{ '{{.RUN_SCRIPT}}' }} python -c \"import sys,os;d=os.path.join(os.getcwd(),'src');print(f'DEBUG cwd={os.getcwd()} src_exists={os.path.isdir(d)}',file=sys.stderr);sys.path.insert(0,d);from {{ '{{.PROJECT_SLUG}}' }} import __version__;print(__version__)\"" + sh: "{{ '{{.RUN_SCRIPT}}' }} python -c \"import sys,os;d=os.path.join(os.getcwd(),'src');print(f'DEBUG cwd={os.getcwd()} src_contents={os.listdir(d)} syspath0={sys.path[:3]}',file=sys.stderr);sys.path.insert(0,d);from {{ '{{.PROJECT_SLUG}}' }} import __version__;print(__version__)\"" LOCAL_PLATFORM: sh: "bash {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" # Use PLATFORM if specified, otherwise use LOCAL_PLATFORM From ba9e2fe56fdcc6d776617e791142906a539caa7d Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 20:05:41 -0400 Subject: [PATCH 35/51] fix: only rename top-level template dir in Windows zip extraction The zip extraction was replacing ALL {{cookiecutter.*}} directory names with {{cookiecutter.project_name}}, including nested ones like {{cookiecutter.project_slug}}. This caused src/replace_me/ to become src/replace-me/ on Windows, breaking Python imports. Only rename the top-level template dir (which has NTFS-illegal pipe and quote chars). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 11 ++++++++++- .../Taskfile.yml" | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1dfbce..6a75350 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,7 +145,16 @@ jobs: zf, dest = zipfile.ZipFile(sys.argv[1]), sys.argv[2] for info in zf.infolist(): parts = info.filename.split('/') - safe = [('{{cookiecutter.project_name}}' if '{{cookiecutter.' in p else p) for p in parts] + # Only rename the top-level template dir (index 1) which has + # NTFS-illegal chars (pipe, quotes). Nested cookiecutter dirs + # like {{cookiecutter.project_slug}} are NTFS-safe and must be + # preserved so cookiecutter renders them correctly. + safe = [] + for i, p in enumerate(parts): + if i == 1 and '{{cookiecutter.' in p: + safe.append('{{cookiecutter.project_name}}') + else: + safe.append(p) target = os.path.join(dest, *[s for s in safe if s]) if info.is_dir(): os.makedirs(target, exist_ok=True) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index 18ed0db..8186ac1 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -16,7 +16,7 @@ vars: RUN_SCRIPT: 'uv run --frozen' SCRIPTS_DIR: 'scripts' VERSION: - sh: "{{ '{{.RUN_SCRIPT}}' }} python -c \"import sys,os;d=os.path.join(os.getcwd(),'src');print(f'DEBUG cwd={os.getcwd()} src_contents={os.listdir(d)} syspath0={sys.path[:3]}',file=sys.stderr);sys.path.insert(0,d);from {{ '{{.PROJECT_SLUG}}' }} import __version__;print(__version__)\"" + sh: "{{ '{{.RUN_SCRIPT}}' }} python -c \"import sys; sys.path.insert(0, 'src'); from {{ '{{.PROJECT_SLUG}}' }} import __version__; print(__version__)\"" LOCAL_PLATFORM: sh: "bash {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" # Use PLATFORM if specified, otherwise use LOCAL_PLATFORM From 04b3437c2937a787f232c1f99cd8d8df84c45459 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 20:12:28 -0400 Subject: [PATCH 36/51] fix: add Docker buildx setup and handle missing buildx gracefully Add docker/setup-buildx-action to the Windows CI job so docker buildx is available. Also make init-docker-multiplatform skip gracefully if buildx is not available rather than failing the init task. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 ++ .../Taskfile.yml" | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a75350..f7028a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,6 +221,8 @@ jobs: } Write-Host "Windows smoke test passed: project generated and verified successfully" + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Initialize generated project shell: bash run: | diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index 8186ac1..146b69c 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -60,7 +60,13 @@ tasks: # This fixes an "ERROR: Multiple platforms feature is currently not supported for docker driver" pipeline error # Only create our multiplatform builder if it doesn't already exist; otherwise list information about the one that exists # It suppresses the inspect output when it's not running in a GitHub Action - - docker buildx inspect multiplatform {{ '{{if ne .GITHUB_ACTIONS "true"}}' }}>/dev/null{{ '{{end}}' }} || docker buildx create --name multiplatform --driver docker-container --use + # Skip gracefully if docker buildx is not available (e.g. Windows without buildx plugin) + - | + if ! docker buildx version > /dev/null 2>&1; then + echo "docker buildx not available, skipping multiplatform setup" + exit 0 + fi + docker buildx inspect multiplatform {{ '{{if ne .GITHUB_ACTIONS "true"}}' }}>/dev/null{{ '{{end}}' }} || docker buildx create --name multiplatform --driver docker-container --use init: desc: Initialize the repo for local use; intended to be run after git clone From fb2f5e9feb49dc9a75cff435fb02d19461a67148 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 20:19:34 -0400 Subject: [PATCH 37/51] fix: remove Docker build/run from Windows CI smoke test BuildKit has no Windows image, so Linux Docker builds cannot work on Windows runners. Remove Docker buildx setup, build, and image verification steps. Docker builds are already tested by the Linux CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7028a8..e76d5a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,8 +221,6 @@ jobs: } Write-Host "Windows smoke test passed: project generated and verified successfully" - - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v3 - name: Initialize generated project shell: bash run: | @@ -233,16 +231,6 @@ jobs: run: | cd "$RUNNER_TEMP/replace-me" task -v test - - name: Build Docker image - shell: bash - run: | - cd "$RUNNER_TEMP/replace-me" - task -v build - - name: Verify Docker image - shell: bash - run: | - docker run --rm --entrypoint python3 zenable-io/replace-me:latest \ - -c "from replace_me import __version__; print(__version__)" - name: Verify zenable CLI shell: bash run: | From 21da23614b501b269f9c4b60da1e815904d7cad9 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 20:25:48 -0400 Subject: [PATCH 38/51] fix: run only unit tests on Windows (integration tests need Docker) Integration tests build and run Docker images which require Linux containers, unavailable on Windows runners. Run only unit tests on Windows; integration tests are covered by the Linux CI job. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e76d5a3..48cc03a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -226,15 +226,20 @@ jobs: run: | cd "$RUNNER_TEMP/replace-me" task -v init - - name: Run tests + - name: Run unit tests shell: bash + # Integration tests require Docker (Linux images) which is not + # available on Windows runners; those are covered by the Linux CI job. run: | cd "$RUNNER_TEMP/replace-me" - task -v test + task -v unit-test - name: Verify zenable CLI shell: bash run: | export PATH="$HOME/.zenable/bin:$PATH" + echo "Checking $HOME/.zenable/:" + ls -laR "$HOME/.zenable/" 2>/dev/null || echo "$HOME/.zenable/ does not exist" + echo "---" zenable version finalizer: # This gives us something to set as required in the repo settings. Some projects use dynamic fan-outs using matrix strategies and the fromJSON function, so From 98f821c475096b83088159461d2a404999560054 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 20:46:33 -0400 Subject: [PATCH 39/51] fix: write PowerShell installer to temp file instead of piping stdin PowerShell's -Command - does not reliably read scripts from stdin, causing the zenable CLI installer to exit 0 without actually installing. Write the install.ps1 to a temp file and use -File instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- hooks/post_gen_project.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index e8e643b..1a93f37 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -11,6 +11,7 @@ import shutil import subprocess import sys +import tempfile from collections import OrderedDict from logging import basicConfig, getLogger from pathlib import Path @@ -205,10 +206,8 @@ def _install_zenable_binary() -> bool: if sys.platform == "win32": installer_key = "install.ps1" - cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-Command", "-"] else: installer_key = "install.sh" - cmd = ["bash"] install_url = metadata["installers"][installer_key] expected_checksum = metadata["installer_checksums"][installer_key] @@ -216,14 +215,30 @@ def _install_zenable_binary() -> bool: install_script = _download_url(install_url) _verify_checksum(install_script, expected_checksum) + if sys.platform == "win32": + # Write to a temp file because PowerShell's -Command - does not + # reliably read scripts from stdin. + tmp = tempfile.NamedTemporaryFile(suffix=".ps1", delete=False, mode="wb") + tmp.write(install_script) + tmp.close() + cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-File", tmp.name] + input_data = None + else: + cmd = ["bash"] + input_data = install_script + tmp = None + result = subprocess.run( cmd, - input=install_script, + input=input_data, check=True, capture_output=True, timeout=120, env=env, ) + + if tmp is not None: + Path(tmp.name).unlink(missing_ok=True) if result.stdout: LOG.info("Zenable installer stdout: %s", result.stdout.decode("utf-8", errors="replace").strip()) if result.stderr: From d7e9a870928b1ceecc15225e647ed99a439d3857 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 20:53:35 -0400 Subject: [PATCH 40/51] feat: re-enable Linux CI jobs and clean up debug output Windows smoke test is passing. Re-enable lint and test jobs, remove ZENABLE_LOGLEVEL=DEBUG and diagnostic ls output. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48cc03a..064399a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,6 @@ defaults: jobs: lint: name: Lint - if: false # Temporarily disabled while iterating on Windows support runs-on: ubuntu-24.04 steps: - name: Checkout the repository @@ -40,7 +39,6 @@ jobs: run: task -v lint test: name: Test - if: false # Temporarily disabled while iterating on Windows support runs-on: ubuntu-24.04 permissions: contents: write @@ -107,8 +105,6 @@ jobs: windows-smoke-test: name: Windows Smoke Test runs-on: windows-latest - env: - ZENABLE_LOGLEVEL: DEBUG steps: # Note: no checkout step. The cookiecutter template directory contains # characters (pipe, quotes) that are illegal on NTFS, so we cannot check @@ -237,9 +233,6 @@ jobs: shell: bash run: | export PATH="$HOME/.zenable/bin:$PATH" - echo "Checking $HOME/.zenable/:" - ls -laR "$HOME/.zenable/" 2>/dev/null || echo "$HOME/.zenable/ does not exist" - echo "---" zenable version finalizer: # This gives us something to set as required in the repo settings. Some projects use dynamic fan-outs using matrix strategies and the fromJSON function, so @@ -249,7 +242,7 @@ jobs: name: Finalize the pipeline runs-on: ubuntu-24.04 # Keep this aligned with the above jobs - needs: [lint, test, windows-smoke-test] # lint and test temporarily disabled + needs: [lint, test, windows-smoke-test] if: always() # Ensure it runs even if "needs" fails or is cancelled steps: - name: Check for failed or cancelled jobs From de8b5fe3fbb1a2581baf23aec2e16b2648559fb1 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 21:09:46 -0400 Subject: [PATCH 41/51] feat: add WSL 2 + Docker for Linux container builds on Windows CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install Ubuntu 24.04 in WSL, set up Docker Engine inside it, and use it to build and verify the Linux Docker image on the Windows runner. No third-party actions required — uses native wsl commands. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 064399a..f128b92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,6 +229,35 @@ jobs: run: | cd "$RUNNER_TEMP/replace-me" task -v unit-test + - name: Setup WSL with Docker + shell: bash + run: | + wsl --install -d Ubuntu-24.04 --no-launch + wsl -d Ubuntu-24.04 -u root -- bash -ec " + apt-get update -qq + apt-get install -y -qq curl ca-certificates >/dev/null + curl -fsSL https://get.docker.com | sh -s -- --quiet + service docker start + docker buildx create --use + " + - name: Build Docker image + shell: bash + run: | + wsl_project="$(wsl -d Ubuntu-24.04 -- wslpath "$RUNNER_TEMP")/replace-me" + wsl -d Ubuntu-24.04 -u root --cd "$wsl_project" -- docker buildx build \ + --platform linux/amd64 \ + --pull --load \ + --build-arg NAME=replace_me \ + --build-arg "DESCRIPTION=A brief description of the project" \ + --build-arg "TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --build-arg COMMIT_HASH=smoke-test \ + -t zenable-io/replace-me:latest . + - name: Verify Docker image + shell: bash + run: | + wsl -d Ubuntu-24.04 -u root -- docker run --rm --entrypoint python3 \ + zenable-io/replace-me:latest \ + -c "from replace_me import __version__; print(__version__)" - name: Verify zenable CLI shell: bash run: | From afd5b02c9299c543becf6f8e72ead53484062b74 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sat, 4 Apr 2026 21:29:44 -0400 Subject: [PATCH 42/51] fix: use PowerShell for WSL Docker steps to handle Windows paths Git Bash strips backslashes when passing paths to wsl commands. Use PowerShell to convert RUNNER_TEMP to WSL /mnt/ path format. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f128b92..0a98870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -241,22 +241,25 @@ jobs: docker buildx create --use " - name: Build Docker image - shell: bash + shell: pwsh run: | - wsl_project="$(wsl -d Ubuntu-24.04 -- wslpath "$RUNNER_TEMP")/replace-me" - wsl -d Ubuntu-24.04 -u root --cd "$wsl_project" -- docker buildx build \ - --platform linux/amd64 \ - --pull --load \ - --build-arg NAME=replace_me \ - --build-arg "DESCRIPTION=A brief description of the project" \ - --build-arg "TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --build-arg COMMIT_HASH=smoke-test \ + # Convert Windows path to WSL path (e.g. D:\a\_temp → /mnt/d/a/_temp) + $drive = "$env:RUNNER_TEMP"[0].ToString().ToLower() + $rest = "$env:RUNNER_TEMP".Substring(3).Replace('\', '/') + $wslProject = "/mnt/$drive/$rest/replace-me" + Write-Host "Building from WSL path: $wslProject" + wsl -d Ubuntu-24.04 -u root --cd $wslProject -- docker buildx build ` + --platform linux/amd64 ` + --pull --load ` + --build-arg NAME=replace_me ` + --build-arg "DESCRIPTION=A brief description of the project" ` + --build-arg COMMIT_HASH=smoke-test ` -t zenable-io/replace-me:latest . - name: Verify Docker image - shell: bash + shell: pwsh run: | - wsl -d Ubuntu-24.04 -u root -- docker run --rm --entrypoint python3 \ - zenable-io/replace-me:latest \ + wsl -d Ubuntu-24.04 -u root -- docker run --rm --entrypoint python3 ` + zenable-io/replace-me:latest ` -c "from replace_me import __version__; print(__version__)" - name: Verify zenable CLI shell: bash From 2f9b748d401b25e587d0e8d0dbfd14c28b8d5b45 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 04:40:54 -0400 Subject: [PATCH 43/51] fix: fail on missing buildx, use docker wrapper, and fix get_epoch.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the graceful buildx skip — missing buildx is a real failure. Move WSL+Docker setup before task init and create a docker wrapper script that routes all docker commands through WSL, so task init and task build work transparently. Also use task build instead of manual docker commands. Fix get_epoch.sh to use bash directly (uv run can't execute .sh files on Windows). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 58 ++++++++++--------- .../Taskfile.yml" | 10 +--- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a98870..08c3173 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,6 +217,31 @@ jobs: } Write-Host "Windows smoke test passed: project generated and verified successfully" + - name: Setup WSL with Docker + shell: bash + run: | + wsl --install -d Ubuntu-24.04 --no-launch + wsl -d Ubuntu-24.04 -u root -- bash -ec " + apt-get update -qq + apt-get install -y -qq curl ca-certificates >/dev/null + curl -fsSL https://get.docker.com | sh -s -- --quiet + service docker start + " + + # Create a docker wrapper so Task commands use WSL's Docker. + # Pass --cd with the converted CWD so docker build contexts resolve. + mkdir -p "$HOME/bin" + cat > "$HOME/bin/docker" << 'WRAPPER' + #!/bin/bash + # Convert Windows CWD to WSL /mnt/ path + win_cwd="$(pwd -W 2>/dev/null || pwd)" + drive="$(echo "${win_cwd:0:1}" | tr '[:upper:]' '[:lower:]')" + rest="${win_cwd:3}" + wsl_cwd="/mnt/$drive/$(echo "$rest" | tr '\\' '/')" + exec wsl -d Ubuntu-24.04 -u root --cd "$wsl_cwd" -- docker "$@" + WRAPPER + chmod +x "$HOME/bin/docker" + echo "$HOME/bin" >> "$GITHUB_PATH" - name: Initialize generated project shell: bash run: | @@ -229,37 +254,16 @@ jobs: run: | cd "$RUNNER_TEMP/replace-me" task -v unit-test - - name: Setup WSL with Docker - shell: bash - run: | - wsl --install -d Ubuntu-24.04 --no-launch - wsl -d Ubuntu-24.04 -u root -- bash -ec " - apt-get update -qq - apt-get install -y -qq curl ca-certificates >/dev/null - curl -fsSL https://get.docker.com | sh -s -- --quiet - service docker start - docker buildx create --use - " - name: Build Docker image - shell: pwsh + shell: bash run: | - # Convert Windows path to WSL path (e.g. D:\a\_temp → /mnt/d/a/_temp) - $drive = "$env:RUNNER_TEMP"[0].ToString().ToLower() - $rest = "$env:RUNNER_TEMP".Substring(3).Replace('\', '/') - $wslProject = "/mnt/$drive/$rest/replace-me" - Write-Host "Building from WSL path: $wslProject" - wsl -d Ubuntu-24.04 -u root --cd $wslProject -- docker buildx build ` - --platform linux/amd64 ` - --pull --load ` - --build-arg NAME=replace_me ` - --build-arg "DESCRIPTION=A brief description of the project" ` - --build-arg COMMIT_HASH=smoke-test ` - -t zenable-io/replace-me:latest . + cd "$RUNNER_TEMP/replace-me" + task -v build - name: Verify Docker image - shell: pwsh + shell: bash run: | - wsl -d Ubuntu-24.04 -u root -- docker run --rm --entrypoint python3 ` - zenable-io/replace-me:latest ` + docker run --rm --entrypoint python3 \ + zenable-io/replace-me:latest \ -c "from replace_me import __version__; print(__version__)" - name: Verify zenable CLI shell: bash diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index 146b69c..4673cd6 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -60,13 +60,7 @@ tasks: # This fixes an "ERROR: Multiple platforms feature is currently not supported for docker driver" pipeline error # Only create our multiplatform builder if it doesn't already exist; otherwise list information about the one that exists # It suppresses the inspect output when it's not running in a GitHub Action - # Skip gracefully if docker buildx is not available (e.g. Windows without buildx plugin) - - | - if ! docker buildx version > /dev/null 2>&1; then - echo "docker buildx not available, skipping multiplatform setup" - exit 0 - fi - docker buildx inspect multiplatform {{ '{{if ne .GITHUB_ACTIONS "true"}}' }}>/dev/null{{ '{{end}}' }} || docker buildx create --name multiplatform --driver docker-container --use + - docker buildx inspect multiplatform {{ '{{if ne .GITHUB_ACTIONS "true"}}' }}>/dev/null{{ '{{end}}' }} || docker buildx create --name multiplatform --driver docker-container --use init: desc: Initialize the repo for local use; intended to be run after git clone @@ -94,7 +88,7 @@ tasks: TIMESTAMP: sh: '{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_rfc3339_timestamp.py' EPOCH: - sh: '{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_epoch.sh' + sh: 'bash {{ '{{.SCRIPTS_DIR}}' }}/get_epoch.sh' COMMIT_HASH: sh: git rev-parse HEAD BUILD_PLATFORM: '{{ '{{if eq .PLATFORM "all"}}' }}{{ '{{.SUPPORTED_PLATFORMS}}' }}{{ '{{else if .PLATFORM}}' }}{{ '{{.PLATFORM}}' }}{{ '{{else}}' }}{{ '{{.LOCAL_PLATFORM}}' }}{{ '{{end}}' }}' From 93ef040f0b3f0c6845dc71901e0f4d86906c0b47 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 04:57:52 -0400 Subject: [PATCH 44/51] fix: use .bat wrapper for docker so Task's mvdan/sh finds it on Windows Task's Go-based shell (mvdan/sh) uses os/exec.LookPath which only finds files with Windows-recognized extensions (.exe, .bat, .cmd). Replace the bash wrapper with a .bat file that routes docker commands through WSL. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c3173..1c19739 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,19 +228,10 @@ jobs: service docker start " - # Create a docker wrapper so Task commands use WSL's Docker. - # Pass --cd with the converted CWD so docker build contexts resolve. + # Create a .bat wrapper so Task's shell (mvdan/sh) finds it on Windows. + # WSL auto-translates the inherited CWD from Windows to /mnt/ paths. mkdir -p "$HOME/bin" - cat > "$HOME/bin/docker" << 'WRAPPER' - #!/bin/bash - # Convert Windows CWD to WSL /mnt/ path - win_cwd="$(pwd -W 2>/dev/null || pwd)" - drive="$(echo "${win_cwd:0:1}" | tr '[:upper:]' '[:lower:]')" - rest="${win_cwd:3}" - wsl_cwd="/mnt/$drive/$(echo "$rest" | tr '\\' '/')" - exec wsl -d Ubuntu-24.04 -u root --cd "$wsl_cwd" -- docker "$@" - WRAPPER - chmod +x "$HOME/bin/docker" + printf '@wsl -d Ubuntu-24.04 -u root -- docker %%*\r\n' > "$HOME/bin/docker.bat" echo "$HOME/bin" >> "$GITHUB_PATH" - name: Initialize generated project shell: bash From 562be6014fae943767d2ff94db64929856bb1d27 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 05:14:05 -0400 Subject: [PATCH 45/51] fix: add bash docker wrapper alongside .bat for Git Bash steps The .bat wrapper is found by Task's mvdan/sh but not by Git Bash. Add a bash script wrapper too so both Task and direct bash steps route docker commands through WSL. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c19739..938fb7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,10 +228,16 @@ jobs: service docker start " - # Create a .bat wrapper so Task's shell (mvdan/sh) finds it on Windows. - # WSL auto-translates the inherited CWD from Windows to /mnt/ paths. + # Create docker wrappers that route to WSL's Docker. + # - .bat for Task's mvdan/sh (Go's exec.LookPath needs a Windows extension) + # - bash script for Git Bash steps mkdir -p "$HOME/bin" printf '@wsl -d Ubuntu-24.04 -u root -- docker %%*\r\n' > "$HOME/bin/docker.bat" + cat > "$HOME/bin/docker" << 'WRAPPER' + #!/bin/bash + exec wsl -d Ubuntu-24.04 -u root -- docker "$@" + WRAPPER + chmod +x "$HOME/bin/docker" echo "$HOME/bin" >> "$GITHUB_PATH" - name: Initialize generated project shell: bash From 196a9b76ac062fff789e6a00237422aa41f1ef8b Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 06:01:30 -0400 Subject: [PATCH 46/51] refactor: extract zip template extraction to scripts/extract_template_zip.py Move the inline Python zip extraction into a proper script file that can be tested and maintained independently. The CI downloads it from the raw GitHub URL since the repo cannot be checked out on Windows. Also add python3 --version check to Docker image verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 39 ++++++------------------ scripts/extract_template_zip.py | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 30 deletions(-) create mode 100755 scripts/extract_template_zip.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 938fb7c..61e7994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,37 +129,14 @@ jobs: git config --global user.email "ci@zenable.io" # The template directory name contains NTFS-illegal characters - # (double quotes, pipe). Use Python to extract the zip, renaming - # the template dir to {{cookiecutter.project_name}} which is - # NTFS-safe and renders identically for defaults (no spaces). + # (double quotes, pipe). Use extract_template_zip.py to safely + # extract and rename only the top-level template dir. zipUrl="https://github.com/${{ github.repository }}/archive/${TEMPLATE_REF}.zip" + scriptUrl="https://raw.githubusercontent.com/${{ github.repository }}/${TEMPLATE_REF}/scripts/extract_template_zip.py" tmpdir=$(mktemp -d) curl -fsSL "$zipUrl" -o "$tmpdir/template.zip" - - repoDir=$(python3 -c " - import zipfile, os, sys - zf, dest = zipfile.ZipFile(sys.argv[1]), sys.argv[2] - for info in zf.infolist(): - parts = info.filename.split('/') - # Only rename the top-level template dir (index 1) which has - # NTFS-illegal chars (pipe, quotes). Nested cookiecutter dirs - # like {{cookiecutter.project_slug}} are NTFS-safe and must be - # preserved so cookiecutter renders them correctly. - safe = [] - for i, p in enumerate(parts): - if i == 1 and '{{cookiecutter.' in p: - safe.append('{{cookiecutter.project_name}}') - else: - safe.append(p) - target = os.path.join(dest, *[s for s in safe if s]) - if info.is_dir(): - os.makedirs(target, exist_ok=True) - else: - os.makedirs(os.path.dirname(target), exist_ok=True) - with zf.open(info) as s, open(target, 'wb') as d: - d.write(s.read()) - print(os.path.join(dest, os.listdir(dest)[0])) - " "$tmpdir/template.zip" "$tmpdir/src") + curl -fsSL "$scriptUrl" -o "$tmpdir/extract_template_zip.py" + repoDir=$(python3 "$tmpdir/extract_template_zip.py" "$tmpdir/template.zip" "$tmpdir/src") uvx --with gitpython cookiecutter "$repoDir" --no-input --output-dir "$RUNNER_TEMP" - name: Verify generated project @@ -259,8 +236,10 @@ jobs: - name: Verify Docker image shell: bash run: | - docker run --rm --entrypoint python3 \ - zenable-io/replace-me:latest \ + # Verify the Python runtime is functional + docker run --rm --entrypoint python3 zenable-io/replace-me:latest --version + # Verify the project module is importable and reports its version + docker run --rm --entrypoint python3 zenable-io/replace-me:latest \ -c "from replace_me import __version__; print(__version__)" - name: Verify zenable CLI shell: bash diff --git a/scripts/extract_template_zip.py b/scripts/extract_template_zip.py new file mode 100755 index 0000000..88ec0fb --- /dev/null +++ b/scripts/extract_template_zip.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Extract a cookiecutter template from a GitHub archive zip. + +The top-level template directory in the zip contains NTFS-illegal characters +(pipe, double-quotes) from the Jinja2 expression in +``{{cookiecutter.project_name|replace(" ", "")}}``. This script renames +**only** that top-level directory to the safe ``{{cookiecutter.project_name}}`` +form while preserving nested cookiecutter directories like +``{{cookiecutter.project_slug}}`` so cookiecutter renders them correctly. + +Usage: + python extract_template_zip.py + +Prints the path to the extracted template root on stdout. +""" + +import os +import sys +import zipfile + + +def extract(zip_path: str, dest: str) -> str: + """Extract the zip, renaming only the top-level template directory.""" + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + parts = info.filename.split("/") + + safe = [] + for i, part in enumerate(parts): + # Index 0 is the repo root (e.g. ai-native-python-). + # Index 1 is the template directory with NTFS-illegal chars. + if i == 1 and "{{cookiecutter." in part: + safe.append("{{cookiecutter.project_name}}") + else: + safe.append(part) + + target = os.path.join(dest, *[s for s in safe if s]) + + if info.is_dir(): + os.makedirs(target, exist_ok=True) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + with zf.open(info) as src, open(target, "wb") as dst: + dst.write(src.read()) + + return os.path.join(dest, os.listdir(dest)[0]) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + print(extract(sys.argv[1], sys.argv[2])) From 6cde0d9b59bf306ce4cbfd8dc61b5e889dc6d43d Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 06:31:40 -0400 Subject: [PATCH 47/51] feat: add --version and --help to template entrypoint Add argparse to main.py so the generated project supports --version (from __init__.__version__) and --help out of the box. Update Docker verify to test both flags directly via the container entrypoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 7 ++----- .../src/main.py" | 8 +++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e7994..49da940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,11 +236,8 @@ jobs: - name: Verify Docker image shell: bash run: | - # Verify the Python runtime is functional - docker run --rm --entrypoint python3 zenable-io/replace-me:latest --version - # Verify the project module is importable and reports its version - docker run --rm --entrypoint python3 zenable-io/replace-me:latest \ - -c "from replace_me import __version__; print(__version__)" + docker run --rm zenable-io/replace-me:latest --version + docker run --rm zenable-io/replace-me:latest --help - name: Verify zenable CLI shell: bash run: | diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" index 6aa3cd6..e6f2e86 100755 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" @@ -3,11 +3,17 @@ {{ cookiecutter.project_name }} script entrypoint """ -from {{ cookiecutter.project_slug }} import config +import argparse + +from {{ cookiecutter.project_slug }} import __version__, config def main(): """Main entry point for the application.""" + parser = argparse.ArgumentParser(description="{{ cookiecutter.project_short_description | replace('"', '\\"') | replace("'", "\\\\'") }}") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + parser.parse_args() + log = config.setup_logging() log.debug("Logging initialized with level: %s", log.level) From 0375dba9ceac39d69ecc83e9982920314abf7f23 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 06:43:15 -0400 Subject: [PATCH 48/51] fix: update tests for argparse in main.py Mock sys.argv in test_main_function to avoid pytest arg conflicts. Add test_main_version and test_main_as_script_version to verify --version works both in-process and as a subprocess. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/test_main.py" | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" index 18bae2b..aa9a264 100755 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" @@ -22,14 +22,20 @@ def test_main_function(): """Test that main() raises NotImplementedError""" from main import main - # Mock the argument parsing to avoid conflicts with pytest args - with patch("{{ cookiecutter.project_slug }}.config.get_args_config") as mock_args: - import logging + with patch("sys.argv", ["main"]): + with pytest.raises(NotImplementedError): + main() - mock_args.return_value = {"loglevel": logging.WARNING} - with pytest.raises(NotImplementedError): +@pytest.mark.unit +def test_main_version(): + """Test that --version prints the version and exits""" + from main import main + + with patch("sys.argv", ["main", "--version"]): + with pytest.raises(SystemExit) as exc_info: main() + assert exc_info.value.code == 0 @pytest.mark.unit @@ -46,3 +52,18 @@ def test_main_as_script(): # Should exit with code 1 due to NotImplementedError assert result.returncode == 1 assert "NotImplementedError" in result.stderr + + +@pytest.mark.unit +def test_main_as_script_version(): + """Test that --version works when run as a script""" + main_path = Path(__file__).parent.parent / "src" / "main.py" + + result = subprocess.run( + [sys.executable, str(main_path), "--version"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "0.0.0" in result.stdout From 4569df1ea7a6d9a54ba56c27a64da55a78c21c47 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 06:56:04 -0400 Subject: [PATCH 49/51] fix: wrap add_argument call to satisfy ruff format line length Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main.py" | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" index e6f2e86..8ae9470 100755 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" @@ -11,7 +11,9 @@ def main(): """Main entry point for the application.""" parser = argparse.ArgumentParser(description="{{ cookiecutter.project_short_description | replace('"', '\\"') | replace("'", "\\\\'") }}") - parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) parser.parse_args() log = config.setup_logging() From dee16726148af5e657cda3f569e99d8b4f6bfdfc Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 07:22:06 -0400 Subject: [PATCH 50/51] docs: add Windows install command for Zenable CLI Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/ai-ide-support.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ai-ide-support.md b/docs/ai-ide-support.md index 2d7ab38..b6acf6f 100644 --- a/docs/ai-ide-support.md +++ b/docs/ai-ide-support.md @@ -11,10 +11,16 @@ When you generate a new project, the post-generation hook automatically installs **Installation (if the Zenable CLI is not already installed):** +macOS/Linux: ```bash curl -fsSL https://cli.zenable.app/install.sh | bash ``` +Windows: +```powershell +powershell -ExecutionPolicy Bypass -Command "irm https://cli.zenable.app/install.ps1 | iex" +``` + **IDE Configuration:** Once installed, `zenable install` detects which IDEs and AI assistants you have installed and creates appropriate configuration files for 15+ supported IDEs including Claude Code, Cursor, Windsurf, VS Code, GitHub Copilot, and more. From 2cbb1f96102366c5d6eb90577c6d13abddbf61b2 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Sun, 5 Apr 2026 07:25:24 -0400 Subject: [PATCH 51/51] fix: use dynamic version in test_main_as_script_version Import __version__ from the package instead of hardcoding "0.0.0" so the test doesn't break after a release increments the version. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/test_main.py" | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" index aa9a264..9414b00 100755 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" @@ -65,5 +65,7 @@ def test_main_as_script_version(): text=True, ) + from {{ cookiecutter.project_slug }} import __version__ + assert result.returncode == 0 - assert "0.0.0" in result.stdout + assert __version__ in result.stdout