diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3494469 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,106 @@ +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 + +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, or from the dispatch input + run: | + 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 + # 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: 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. + 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())