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())