From 0232e6448fdb6c292d37dd0465c1ccdcc9bdfcdc Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Mon, 3 Aug 2026 18:18:04 -0400 Subject: [PATCH] feat(installer): tag-triggered Windows installer that carries its prompts Pushing a v* tag freezes agent-audit with PyInstaller on windows-latest, wraps it in Inno Setup, and attaches the result to that tag's release. The installer lands in Program Files, appends that directory to the machine PATH (HKLM, removed again on uninstall) and registers itself in Add/Remove Programs. workflow_dispatch runs the same build and keeps the installer as an artifact instead of publishing it, so the Windows-only steps can be proven without a public release. --collect-data agent_audit is the part that is specific to this repo. The analysis-pass prompts are markdown loaded through importlib.resources, which PyInstaller's import analysis cannot see. A build without the flag succeeds, answers --help, and then dies with FileNotFoundError on the first real audit, so the smoke test runs an actual `skills scan` over an empty directory rather than trusting --help. agent-audit needs the Claude Code CLI on PATH for its model-backed passes and the installer does not bundle it. The finish page and the README say so; the deterministic commands still work without it. The [Registry] PATH line stays one long line on purpose, the .iss stays pure ASCII because Inno reads a BOM-less script as ANSI, and AppVersion has no default so a build that forgets it fails instead of shipping 0.0.0-dev. --- .github/workflows/release.yml | 124 ++++++++++++++++++++++++++++++ .gitignore | 2 + README.md | 25 ++++++ installer/agent-audit.iss | 111 ++++++++++++++++++++++++++ installer/agent_audit_launcher.py | 19 +++++ 5 files changed, 281 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 installer/agent-audit.iss create mode 100644 installer/agent_audit_launcher.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2ddda66 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,124 @@ +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 agent-audit 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/agent_audit_launcher.py. Capital PyInstaller is load-bearing: + # `python -m pyinstaller` is not a module and fails. + # + # --collect-data agent_audit is load-bearing too. The pass prompts are + # markdown read through importlib.resources, which PyInstaller's import + # analysis cannot see; without it the exe builds, answers --help, and + # then dies on the first real audit. Note the command name is hyphenated + # and the module name is not. + run: > + python -m PyInstaller --onefile --console + --name agent-audit --collect-data agent_audit + installer/agent_audit_launcher.py + + - name: Smoke-test the frozen executable + # --help alone would pass on a binary with no prompts inside it, so a + # real audit runs as well: an empty directory, with the CLI pointed at a + # name that does not exist. A missing CLI is recorded as a coverage gap + # rather than raising, so this exercises prompt loading and report + # writing end to end without the CLI, a network call or a cent spent. + run: | + dist\agent-audit.exe --help + if ($LASTEXITCODE -ne 0) { throw "frozen exe failed --help" } + New-Item -ItemType Directory -Force -Path smoke | Out-Null + $env:AGENT_AUDIT_CLAUDE_BIN = "agent-audit-no-such-cli" + dist\agent-audit.exe skills scan smoke + if ($LASTEXITCODE -ne 0) { throw "frozen exe cannot run an audit - packaged prompts likely missing" } + + - 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\agent-audit.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: agent-audit-setup-${{ env.VERSION }} + path: dist/agent-audit-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\agent-audit-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 2ff78ca..53eeb15 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ venv/ dist/ build/ *.egg-info/ +# PyInstaller writes one of these into the repo root on every Windows build. +*.spec .coverage htmlcov/ diff --git a/README.md b/README.md index 73d616f..2fca3c6 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,31 @@ in every report — work without it. Point `AGENT_AUDIT_CLAUDE_BIN` at a different executable if yours is not called `claude`. +### Windows installer + +Each release also carries `agent-audit-setup-.exe` on its +[releases page](https://github.com/Back-Road-Creative/agent-audit/releases) — agent-audit +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. + +**The `claude` CLI is not bundled.** The installer contains agent-audit and nothing else, +so the prerequisite above still applies: install [Claude Code](https://www.anthropic.com/claude-code) +separately and have `claude` on `PATH` before the model-backed passes will run. It is +separately distributed and separately authenticated, and no copy of it belongs inside this +installer. The deterministic commands — `skills manifest`, `skills diff`, `--focus +structural` — work without it. + +**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 agent-audit` needs no +installer. + ## Usage ```bash diff --git a/installer/agent-audit.iss b/installer/agent-audit.iss new file mode 100644 index 0000000..43206e4 --- /dev/null +++ b/installer/agent-audit.iss @@ -0,0 +1,111 @@ +; Inno Setup script for the agent-audit Windows installer. +; +; Expects PyInstaller to have already written dist\agent-audit.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\agent-audit.iss +; +; See .github/workflows/release.yml for the whole sequence. +; +; Keep this file pure ASCII. Inno reads a BOM-less script as ANSI, so an em +; dash or a curly quote pasted in from prose becomes mojibake in the wizard. + +#define AppName "agent-audit" +#define AppURL "https://github.com/Back-Road-Creative/agent-audit" +; agent-audit shells out to the Claude Code CLI for every model-backed pass and +; this installer does not bundle it: it is separately distributed and separately +; authenticated, and no copy of it belongs inside this exe. The deterministic +; commands work without it. Said on the way out, not buried. +#define ClaudeNote "agent-audit runs the Claude Code CLI (claude) for its analysis passes. It is NOT included and must be installed separately, on PATH:%n%n https://www.anthropic.com/claude-code%n%nThe deterministic commands (skills manifest, skills diff, --focus structural) work without it.%n%nOpen a new terminal, then run: agent-audit --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={{FD31A355-943B-4B81-A4BF-AB5A58E9F750} +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}\agent-audit.exe +OutputDir=..\dist +OutputBaseFilename=agent-audit-setup-{#AppVersion} +Compression=lzma +SolidCompression=yes + +[Files] +Source: "..\dist\agent-audit.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/agent_audit_launcher.py b/installer/agent_audit_launcher.py new file mode 100644 index 0000000..8fea637 --- /dev/null +++ b/installer/agent_audit_launcher.py @@ -0,0 +1,19 @@ +"""PyInstaller entry point for the Windows build. + +PyInstaller freezes a *script*. agent-audit's console entry point is a module +function (``agent-audit = "agent_audit.cli: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. + +Note the naming split the rest of the port has to keep straight: the *command* +is ``agent-audit`` (hyphen) and the *module* is ``agent_audit`` (underscore). +""" + +from __future__ import annotations + +import sys + +from agent_audit.cli import main + +if __name__ == "__main__": + sys.exit(main())