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
124 changes: 124 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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" }
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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
Expand Down
111 changes: 111 additions & 0 deletions installer/agent-audit.iss
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions installer/agent_audit_launcher.py
Original file line number Diff line number Diff line change
@@ -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())
Loading