Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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" }
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ venv/
.ruff_cache/
dist/
build/
*.spec
*.egg-info/
.coverage
htmlcov/
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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:
Expand Down
108 changes: 108 additions & 0 deletions installer/claude-batch-runner.iss
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 23 additions & 0 deletions installer/claude_batch_runner_launcher.py
Original file line number Diff line number Diff line change
@@ -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())
Loading