From 4c7e2030be7f5888fe1e590b1a4114a595744144 Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Mon, 3 Aug 2026 18:16:58 -0400 Subject: [PATCH 1/2] feat(installer): tag-triggered Windows installer, unsigned and without Claude Code Pushing a v* tag now builds a PyInstaller one-file exe on windows-latest, wraps it in an Inno Setup installer, and attaches it to that tag's GitHub release. The installer lands in Program Files, appends itself to the machine PATH (HKLM), and registers an uninstaller that removes exactly that PATH entry. The console entry point is a module function targeting `__main__`, so PyInstaller is aimed at installer/claude_batch_runner_launcher.py. Importing claude_batch_runner.__main__ binds __name__ to that dotted path rather than "__main__", so the module's own guard does not fire and argparse does not run twice; only the launcher is __main__ in the frozen exe. Two things the build refuses to paper over. The workflow fails a tag whose version disagrees with pyproject.toml, and the .iss declares no default AppVersion, so ISCC errors rather than emitting a 0.0.0-dev installer. Inno Setup is installed via choco rather than assumed present on the runner image. Every unit of work is a `claude -p` subprocess, so the tool cannot run a campaign without the Claude Code CLI on PATH. That is not bundled -- Claude Code is separately versioned and self-updating -- so the installer's finish page and the README both name it, give the install command, and say to sign in before first use. Neither claims anything about how that login is provisioned; the runner inherits whatever the CLI already has. The build is not code-signed and the README says so plainly, SmartScreen box included, with `pip install .` offered as the no-installer path. --- .github/workflows/release.yml | 81 ++++++++++++++++ .gitignore | 1 + CHANGELOG.md | 11 +++ README.md | 36 ++++++++ installer/claude-batch-runner.iss | 108 ++++++++++++++++++++++ installer/claude_batch_runner_launcher.py | 23 +++++ 6 files changed, 260 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 installer/claude-batch-runner.iss create mode 100644 installer/claude_batch_runner_launcher.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6122750 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +# Pushing a v* tag builds the Windows installer and attaches it to that tag's +# GitHub release, creating the release if it does not exist yet. +on: + push: + tags: ["v*"] + +permissions: + contents: write # gh release create / upload + +jobs: + windows-installer: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Take the version from the tag + run: | + "VERSION=$($env:GITHUB_REF_NAME -replace '^v', '')" | + Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Refuse a tag that disagrees with pyproject.toml + # Otherwise the installer would announce a version the packaged code + # does not claim, and Add/Remove Programs would carry that number for + # as long as it stays installed. + run: | + $packaged = python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])" + if ($packaged -ne $env:VERSION) { + throw "tag $env:GITHUB_REF_NAME means version $env:VERSION, pyproject.toml says $packaged" + } + + - name: Install claude-batch-runner and PyInstaller + run: | + python -m pip install --upgrade pip + python -m pip install . pyinstaller + + - name: Build the one-file executable + # The entry point is a module function, so PyInstaller is aimed at + # installer/claude_batch_runner_launcher.py. Capital PyInstaller is + # load-bearing: `python -m pyinstaller` is not a module and fails. + run: python -m PyInstaller --onefile --console --name claude-batch-runner installer/claude_batch_runner_launcher.py + + - name: Smoke-test the frozen executable + # --help only; a real run would need the `claude` CLI, which the runner + # subprocesses and this runner does not have. + run: dist\claude-batch-runner.exe --help + + - name: Install Inno Setup + # Installed, not assumed: a runner image that quietly drops a + # preinstalled tool would break releases with no warning. + run: choco install innosetup -y --no-progress + + - name: Build the installer + run: | + $iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source + if (-not $iscc) { $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" } + if (-not (Test-Path $iscc)) { throw "ISCC.exe not found at $iscc - Inno Setup's install layout changed" } + & $iscc /DAppVersion=$env:VERSION installer\claude-batch-runner.iss + + - name: Attach the installer to the release + # gh ships on the runner, so this needs no third-party action to pin, + # audit and keep current -- softprops/action-gh-release would buy only + # the create-if-missing branch written out below. + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Continue' + $exe = "dist\claude-batch-runner-setup-$env:VERSION.exe" + gh release view $env:GITHUB_REF_NAME --json tagName | Out-Null + if ($LASTEXITCODE -eq 0) { + gh release upload $env:GITHUB_REF_NAME $exe --clobber + } else { + gh release create $env:GITHUB_REF_NAME --verify-tag --generate-notes $exe + } + if ($LASTEXITCODE -ne 0) { throw "could not publish $exe to $env:GITHUB_REF_NAME" } diff --git a/.gitignore b/.gitignore index 62fd498..0c3759f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ venv/ .ruff_cache/ dist/ build/ +*.spec *.egg-info/ .coverage htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b8cd6d..7a4dae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Windows installer, built and attached to a `v*` tag's GitHub release by + `.github/workflows/release.yml`: a PyInstaller one-file executable wrapped in + an Inno Setup installer (`installer/`) that installs into Program Files, adds + itself to `PATH`, and registers an uninstaller. It does not bundle the Claude + Code CLI and it is not code-signed; both are stated in the installer and the + README. + ## 0.1.0 First public release. diff --git a/README.md b/README.md index 1792c3b..693bfec 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,42 @@ claude --version # if this fails, the runner will too Python 3.11 or newer. +### Windows installer + +Each release also carries `claude-batch-runner-setup-.exe` on its +[releases page](https://github.com/Back-Road-Creative/claude-batch-runner/releases) +— the runner frozen into one executable, so it needs no Python. It installs +into Program Files, appends that directory to the system `PATH`, and registers +an uninstaller in Add/Remove Programs. Open a *new* terminal afterwards; one +that was already open still holds the old `PATH`. + +Two things to know before downloading it. + +**Claude Code is not bundled, and the runner cannot do anything without it.** +Every unit of work is a `claude -p` subprocess, so with no `claude` on `PATH` +each unit comes back `FLAGGED` with an error and the campaign accomplishes +nothing. Claude Code is separately versioned and self-updating, so it installs +itself: + +```powershell +winget install Anthropic.ClaudeCode +``` + +The [native installer](https://code.claude.com/docs/en/setup) — +`irm https://claude.ai/install.ps1 | iex` — works too and auto-updates. Confirm +with `claude --version`, then run `claude` once and follow the browser prompts +to sign in. The runner never reads, sets, or forwards credentials of its own; +it inherits whatever that login leaves configured. + +**The build is not code-signed.** There is no code-signing certificate for this +project, so Windows cannot show you a publisher. Expect the blue *"Windows +protected your PC"* box — "Microsoft Defender SmartScreen prevented an +unrecognized app from starting" — which runs the installer only after **More +info** → **Run anyway**, and expect your browser to warn during the download. +That is simply what an unsigned binary looks like; it is not evidence the file +is safe. If you would rather not make that call, `pip install .` from a +checkout needs no installer. + ## Quick start A campaign is one JSON file. This one reviews three documentation files: diff --git a/installer/claude-batch-runner.iss b/installer/claude-batch-runner.iss new file mode 100644 index 0000000..2e5c7a8 --- /dev/null +++ b/installer/claude-batch-runner.iss @@ -0,0 +1,108 @@ +; Inno Setup script for the claude-batch-runner Windows installer. +; +; Expects PyInstaller to have already written dist\claude-batch-runner.exe, and +; takes the version on the command line. There is deliberately no default, so a +; build that forgets it fails loudly rather than shipping a wrong version number: +; +; ISCC.exe /DAppVersion=0.1.0 installer\claude-batch-runner.iss +; +; See .github/workflows/release.yml for the whole sequence. + +#define AppName "claude-batch-runner" +#define AppURL "https://github.com/Back-Road-Creative/claude-batch-runner" +; Every unit of work is a `claude -p` subprocess (claude_batch_runner/driver.py), +; so without the Claude Code CLI on PATH this tool cannot run a campaign at all. +; This installer does not bundle it: it is a separately versioned, self-updating +; product with its own installer. Said on the way out, not buried. +#define ClaudeNote "claude-batch-runner runs the Claude Code CLI (claude). It is NOT included and must be installed separately, on PATH:%n%n winget install Anthropic.ClaudeCode%n%nThen run `claude` once to sign in. Open a new terminal, then run: claude-batch-runner --help" + +[Setup] +; Fixed GUID. This is how Windows tells an upgrade of an existing install from +; a second copy of it, and how the uninstaller finds its own entry. Never change. +AppId={{04F25EFE-CC97-4DCD-B83E-D24A1B070041} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher=Joe Petrucelli +AppPublisherURL={#AppURL} +AppSupportURL={#AppURL}/issues +LicenseFile=..\LICENSE +DefaultDirName={autopf}\{#AppName} +DisableProgramGroupPage=yes +; Program Files and a machine-wide PATH entry both need elevation. +PrivilegesRequired=admin +; Without this, Setup runs in 32-bit mode and {autopf} resolves to +; "Program Files (x86)". x64compatible needs Inno Setup 6.3+ (the older plain +; "x64" is deprecated as of 6.3); choco installs current Inno, and the +; workflow fails loudly if ISCC is missing. +ArchitecturesInstallIn64BitMode=x64compatible +; Broadcast WM_SETTINGCHANGE so a shell started after this picks up the PATH +; edit below without a logoff. +ChangesEnvironment=yes +UninstallDisplayName={#AppName} {#AppVersion} +UninstallDisplayIcon={app}\{#AppName}.exe +OutputDir=..\dist +OutputBaseFilename={#AppName}-setup-{#AppVersion} +Compression=lzma +SolidCompression=yes + +[Files] +Source: "..\dist\{#AppName}.exe"; DestDir: "{app}"; Flags: ignoreversion + +[Registry] +; Append the install directory to the machine PATH. The Check skips the append +; when it is already present, so reinstalling does not grow PATH without bound. +; Deliberately one long line: the `\` continuation is an ISPP line-spanning +; feature that nothing in this repo can compile-test, and a wrapped line that +; silently mis-parses would corrupt the machine PATH. One line cannot. +Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; Check: NeedsAddPath + +[Messages] +; Which of the two the wizard shows depends on whether it created any icons, +; so both carry the note. +FinishedLabel=Setup has installed [name] on your computer.%n%n{#ClaudeNote} +FinishedLabelNoIcons=Setup has installed [name] on your computer.%n%n{#ClaudeNote} + +[Code] +const + EnvKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + +{ Compare against PATH with both sides ';'-padded, so a directory is only a + match as a whole entry, never as the prefix of a longer path. } +function NeedsAddPath(): Boolean; +var + Path: string; +begin + if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', Path) then + Result := True + else + Result := Pos(';' + UpperCase(ExpandConstant('{app}')) + ';', + ';' + UpperCase(Path) + ';') = 0; +end; + +procedure RemoveFromPath(); +var + Path, Dir: string; + P: Integer; +begin + Dir := ExpandConstant('{app}'); + if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', Path) then + exit; + P := Pos(';' + UpperCase(Dir) + ';', ';' + UpperCase(Path) + ';'); + if P = 0 then + exit; + { P indexes the padded copy, so real-string index i sits at P = i+1, and the + ';' introducing the entry is at P-1. The exception is a first entry, which + has no leading ';', so there the trailing one is removed instead. Either way + exactly one separator goes with the directory. } + if P = 1 then + Delete(Path, 1, Length(Dir) + 1) + else + Delete(Path, P - 1, Length(Dir) + 1); + RegWriteExpandStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', Path); +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usPostUninstall then + RemoveFromPath(); +end; diff --git a/installer/claude_batch_runner_launcher.py b/installer/claude_batch_runner_launcher.py new file mode 100644 index 0000000..29f466e --- /dev/null +++ b/installer/claude_batch_runner_launcher.py @@ -0,0 +1,23 @@ +"""PyInstaller entry point for the Windows build. + +PyInstaller freezes a *script*. claude-batch-runner's console entry point is a +module function (``claude-batch-runner = "claude_batch_runner.__main__:main"`` +in pyproject.toml), which pip turns into a generated shim at install time -- +there is no file on disk to point PyInstaller at. This is that file, and it +does nothing else. + +The target module is literally named ``__main__``, so it carries its own +``if __name__ == "__main__"`` block. Importing it as +``claude_batch_runner.__main__`` binds ``__name__`` to that dotted path, not to +``"__main__"``, so the block does not fire and argparse does not run twice. +Only this launcher is ``__main__`` in the frozen exe. +""" + +from __future__ import annotations + +import sys + +from claude_batch_runner.__main__ import main + +if __name__ == "__main__": + sys.exit(main()) From d9c1b013a82c6a75d26396395de39cf6adecee47 Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Mon, 3 Aug 2026 18:19:15 -0400 Subject: [PATCH 2/2] feat(installer): the Windows build can be proven without publishing it --- .github/workflows/release.yml | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6122750..3494469 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,20 @@ name: Release # Pushing a v* tag builds the Windows installer and attaches it to that tag's # GitHub release, creating the release if it does not exist yet. +# +# workflow_dispatch runs the identical build and stops short of publishing, +# leaving the installer as a build artifact instead. Every Windows-only step -- +# the PyInstaller freeze, the ISCC compile, the Inno install -- is unrunnable +# anywhere outside this runner, so without a dry run the first proof that any of +# it works would be a public release that might already be broken. on: push: tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: "Version to build, without the leading v (must match pyproject.toml)" + required: true permissions: contents: write # gh release create / upload @@ -20,10 +31,14 @@ jobs: with: python-version: "3.12" - - name: Take the version from the tag + - name: Take the version from the tag, or from the dispatch input run: | - "VERSION=$($env:GITHUB_REF_NAME -replace '^v', '')" | - Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { + $v = "${{ inputs.version }}" + } else { + $v = $env:GITHUB_REF_NAME -replace '^v', '' + } + "VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: Refuse a tag that disagrees with pyproject.toml # Otherwise the installer would announce a version the packaged code @@ -63,7 +78,17 @@ jobs: if (-not (Test-Path $iscc)) { throw "ISCC.exe not found at $iscc - Inno Setup's install layout changed" } & $iscc /DAppVersion=$env:VERSION installer\claude-batch-runner.iss + - name: Keep the installer as an artifact (dry run only) + # A dry run proves the build; it deliberately publishes nothing. + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: claude-batch-runner-setup-${{ env.VERSION }} + path: dist/claude-batch-runner-setup-${{ env.VERSION }}.exe + if-no-files-found: error + - name: Attach the installer to the release + if: github.event_name == 'push' # gh ships on the runner, so this needs no third-party action to pin, # audit and keep current -- softprops/action-gh-release would buy only # the create-if-missing branch written out below.