# Windows

The whole path on Windows: the zip, PowerShell and cmd, a scheduled task, and what is genuinely different there.

Source: https://jmrplens.github.io/ghchronicle/install/windows/

Windows is a released platform, not an afterthought: every change runs the
whole unit suite and the end-to-end suite on a Windows runner, and the source
carries Windows-only code where the system behaves differently. What follows is
written for PowerShell, with the `cmd` form beside it wherever the two differ.

## What is different here

Read this first. Four of the five lines below are the reason a command copied
from a Linux page does not work.

- **The program** is `ghchronicle.exe`. Typing `ghchronicle` finds it, because
  `PATHEXT` lists `.EXE`.
- **Runtime dependencies**: none. The binary is built `CGO_ENABLED=0`, so there
  is no C runtime to install.
- **Stopping it** is Ctrl+C in its console. There is no signal to send it, and
  `taskkill /F` ends it without closing its sinks.
- **Paths in the configuration**: a backslash inside a double-quoted YAML
  scalar is an escape. Use forward slashes.
- **Running unattended** is a scheduled task. The binary is not a Service
  Control Manager service.

## Pick the archive

Windows archives are `zip` rather than `tar.gz`, and they are named
`ghchronicle_<version>_windows_<arch>.zip`, with `<arch>` either `amd64` or
`arm64`.

| `$env:PROCESSOR_ARCHITECTURE` | The archive to take |
| ----------------------------- | ------------------- |
| `AMD64`                       | `windows_amd64`     |
| `ARM64`                       | `windows_arm64`     |

That variable describes the **process**, not the machine. A 32-bit PowerShell
on a 64-bit machine reports `x86` and leaves the machine's own architecture in
`$env:PROCESSOR_ARCHITEW6432`; an x64 PowerShell running under emulation on an
ARM64 machine reports `AMD64` and sets nothing else, which is the case that
quietly hands you the wrong archive. If either could be you, ask the machine
rather than the shell:

```powershell
(Get-CimInstance Win32_Processor).Architecture   # 9 is x64, 12 is ARM64
```

```powershell
$version = "1.0.0"
$arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "amd64" }
$base = "https://github.com/jmrplens/ghchronicle/releases/download/v$version"
$zip = "ghchronicle_${version}_windows_${arch}.zip"
Invoke-WebRequest -UseBasicParsing -Uri "$base/$zip" -OutFile $zip
```

`-UseBasicParsing` is not decoration. On Windows PowerShell 5.1
`Invoke-WebRequest` builds its result through the Internet Explorer engine
unless told not to, and fails outright on a host where Internet Explorer was
removed or never went through its first-run configuration, which covers Server
Core and most hardened images. On PowerShell 7 the switch is accepted and does
nothing.

> **Name the version rather than asking for the newest**
>
> The repository publishes a moving `v1` tag beside the numbered releases,
> because the Action is listed on the Marketplace and that listing needs one.
> The `v1` release carries **no files at all**, so never build a download URL
> from the major tag: there is nothing behind it to download. Take the version
> from the
> [releases page](https://github.com/jmrplens/ghchronicle/releases) and write
> it down, as above.

## Check what you downloaded

`checksums.txt` holds the SHA-256 of every archive, and
`checksums.txt.sigstore.json` is a signature over that file.

1. Take the checksum file and compare the one line that is yours.

    ```powershell
    Invoke-WebRequest -UseBasicParsing -Uri "$base/checksums.txt" -OutFile checksums.txt
    $expected = (Select-String -Path checksums.txt -Pattern ([regex]::Escape($zip) + '$')).Line.Split(" ")[0]
    $actual = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash
    if ($actual -eq $expected) { "OK" } else { "MISMATCH" }
    ```

    `Get-FileHash` returns the digest in upper case and `checksums.txt` holds
    it in lower case. They still compare equal because PowerShell's `-eq` on
    two strings ignores case, which is the one place here where that default
    is convenient rather than a trap.

2. Check the checksum file itself, if you have
   [cosign](https://docs.sigstore.dev/cosign/system_config/installation/). The
   command is the same one the release notes print, and the same one a Linux
   or macOS reader runs.

    ```powershell
    cosign verify-blob `
      --certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' `
      --certificate-oidc-issuer https://token.actions.githubusercontent.com `
      --bundle checksums.txt.sigstore.json `
      checksums.txt
    ```

    The backtick is PowerShell's line continuation, where a shell script uses a
    backslash.

## Put it somewhere and on the PATH

The archive holds three files and no directory, so unpack it into a directory
you made.

- ghchronicle_1.0.0_windows_amd64.zip
  - ghchronicle.exe the binary
  - LICENSE
  - README.md

- **For everyone**

  An elevated PowerShell, because both the directory and the machine `Path`
  need administrator rights.

  ```powershell
  $dir = "C:\Program Files\ghchronicle"
  Expand-Archive -Path $zip -DestinationPath $dir -Force
  $machine = [Environment]::GetEnvironmentVariable("Path", "Machine")
  [Environment]::SetEnvironmentVariable("Path", "$machine;$dir", "Machine")
  ```

- **For one account**

  No elevation needed, and nothing outside your profile is touched.

  ```powershell
  $dir = "$env:LOCALAPPDATA\Programs\ghchronicle"
  Expand-Archive -Path $zip -DestinationPath $dir -Force
  $user = [Environment]::GetEnvironmentVariable("Path", "User")
  [Environment]::SetEnvironmentVariable("Path", "$user;$dir", "User")
  ```

> **Two traps in writing Path back**
>
> Both snippets read `Path` from the scope they write to, never from
> `$env:Path`. `$env:Path` is the process's own copy, which Windows built by
> joining the machine list and the user list: write that back into either scope
> and you have copied the other one into it, permanently, and it grows again
> every time somebody repeats the command.
>
> The second trap belongs to the machine scope alone.
> `[Environment]::GetEnvironmentVariable` expands `%SystemRoot%` and its
> relatives while it reads, and `SetEnvironmentVariable` writes the result back
> as a plain string, so a machine `Path` that held such entries comes back with
> them baked in and its registry value changes kind from `REG_EXPAND_SZ` to
> `REG_SZ`, for good. You cannot spot it in `$machine`, because the expansion
> has already happened by then. Open System Properties, Environment Variables,
> which shows the value unexpanded: if there is a `%VAR%` anywhere in the
> machine `Path`, add the directory from that dialog rather than from the
> snippet above.

A new `Path` reaches only processes started afterwards, so open a new terminal
before the check below. The current one keeps the environment it was given.

```powershell
ghchronicle -version
```

```text
ghchronicle 1.0.0 (commit 4e5dfc2, built 2026-09-14T23:04:02Z)
```

> **If Windows warns about the file**
>
> The binary carries no Authenticode signature: the release signs the checksum
> file and the SBOMs, and nothing else. SmartScreen and Defender treat an
> unsigned executable downloaded from the internet on their own terms, which
> vary by version and policy and which this page will not guess at. If Windows
> refuses to start it, the attribute to clear is the mark of the web:
>
> ```powershell
> Unblock-File -Path "$dir\ghchronicle.exe"
> ```

## The token, and the rest of the environment

Every `${VAR}` in the configuration file is read from the environment when the
process starts, so the token never has to be in the file.

- **PowerShell**

  ```powershell
  # This window only
  $env:GITHUB_TOKEN = "github_pat_..."

  # Persisted for this account
  [Environment]::SetEnvironmentVariable("GITHUB_TOKEN", "github_pat_...", "User")
  ```

- **cmd**

  ```bat
  rem This window only
  set GITHUB_TOKEN=github_pat_...

  rem Persisted for this account, and NOT visible in this window
  setx GITHUB_TOKEN "github_pat_..."
  ```

> **A persisted variable is a token in the registry**
>
> `SetEnvironmentVariable` with `User` or `Machine`, and `setx`, write to the
> registry in clear text, where anything running as that account can read them.
> That is the same bargain as a systemd environment file, without the file mode
> to lean on. A scheduled task running as `SYSTEM` reads the machine scope, so
> a token put there is readable by every service on the box: prefer the user
> scope and a task that runs as that user.

## Run it once

The binary needs a configuration file, and
[the quickstart](/ghchronicle/start/quickstart/) writes one in six steps. Save
it as UTF-8, and mind the two Windows details below it.

```powershell
ghchronicle -config config.yaml -list   # what would be collected
ghchronicle -config config.yaml -once   # one sweep, then exit
```

If the binary is in the current directory rather than on the `PATH`, PowerShell
needs it named as a path: `.\ghchronicle.exe`. A bare name is a command, and
the current directory is not searched for commands.

### Paths in the configuration file

YAML treats a backslash as an escape inside a **double-quoted** scalar and as
an ordinary character everywhere else. So a Windows path in double quotes is
not the path you wrote, and usually not valid YAML either:

```yaml
state_file: "C:\ghchronicle\state.json" # ghchronicle: config.yaml: yaml: line N: found unknown escape character
state_file: C:\ghchronicle\state.json # correct, plain scalar
state_file: 'C:\ghchronicle\state.json' # correct, single quoted
state_file: C:/ghchronicle/state.json # correct, and the one to prefer
```

Forward slashes are the simplest answer: Windows accepts them in a path, and
they survive being quoted whichever way.

### The file itself has to be UTF-8

Windows PowerShell 5.1, the one that ships in the box, writes neither of the
things a YAML parser wants, and it writes a different wrong thing depending on
how you ask. `>` and `Out-File` produce UTF-16LE, which the parser reads as
binary. `Set-Content` produces the system's active code page, usually ANSI,
which parses while the file is pure ASCII and mangles the first accented
character in it. PowerShell 7 defaults to UTF-8 without a byte order mark and
has neither problem; on 5.1, be explicit:

```powershell
Set-Content -Path config.yaml -Value $text -Encoding utf8
```

`utf8` on 5.1 means UTF-8 **with** a byte order mark, which the value cannot
express and the cmdlet does not warn about. The collector's parser reads past
one, so the file works; a tool that reads the first bytes for itself may not.

## Keep it running

There is no service mode. The collector is a console program: it does not talk
to the Service Control Manager, so registering it with `sc.exe create` produces
a service that Windows starts and then gives up on, reporting that it "did not
respond to the start or control request in a timely fashion". Third-party
service wrappers exist and this project neither ships nor tests one.

The ordinary way to run something unattended on Windows is a scheduled task,
and there are two shapes of it.

- **One sweep on a timer**

  The Windows equivalent of cron, and the one to prefer: nothing has to be
  stopped, and a missed run costs one sweep.

  ```powershell
  $action = New-ScheduledTaskAction `
    -Execute "C:\Program Files\ghchronicle\ghchronicle.exe" `
    -Argument '-config "C:\ProgramData\ghchronicle\config.yaml" -once'
  $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
    -RepetitionInterval (New-TimeSpan -Hours 1) `
    -RepetitionDuration ([TimeSpan]::MaxValue)
  Register-ScheduledTask -TaskName ghchronicle -Action $action -Trigger $trigger
  ```

  `-RepetitionDuration ([TimeSpan]::MaxValue)` is what spells out "for ever".
  Task Scheduler's own rule is that a repetition with no duration repeats
  indefinitely, so leaving it out is not a bug, but the cmdlet has no default
  of its own and the task is then registered carrying no duration at all.

  Two conditions come with this shape, and neither is cron's. An hourly task
  gives every family an hourly cadence at best, so the fifteen-minute rhythm
  of `actions` is lost, which is the same trade
  [cron makes on Linux](/ghchronicle/install/systemd/#cron-instead-of-a-service).
  And `Register-ScheduledTask` here names no `-User` and no `-Principal`, so
  the task is registered under the calling account with the default logon
  type and runs **only while that account is logged on**. A cron job does
  not stop when you log out. For one that behaves the same way, register the
  task with a principal that has "run whether user is logged on or not" set,
  which is `New-ScheduledTaskPrincipal`.

- **Running all the time**

  A task triggered at logon or at startup, with the collector left in its
  long-running mode so each family keeps its own cadence.

  ```powershell
  $action = New-ScheduledTaskAction `
    -Execute "C:\Program Files\ghchronicle\ghchronicle.exe" `
    -Argument '-config "C:\ProgramData\ghchronicle\config.yaml"'
  $trigger = New-ScheduledTaskTrigger -AtLogOn
  $settings = New-ScheduledTaskSettingsSet `
    -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) `
    -ExecutionTimeLimit ([TimeSpan]::Zero)
  Register-ScheduledTask -TaskName ghchronicle -Action $action `
    -Trigger $trigger -Settings $settings
  ```

  `-ExecutionTimeLimit ([TimeSpan]::Zero)` is the one that matters: the
  default stops a task after three days, which for a process meant to run
  for ever is a restart nobody asked for.

```powershell
Start-ScheduledTask -TaskName ghchronicle
Get-ScheduledTaskInfo -TaskName ghchronicle   # last run, last result
```

`Stop-ScheduledTask` ends the process rather than asking it to finish: it is
`taskkill /F` by another name, and it closes no sink on its way out. In the
timer shape there is nothing to stop, which is most of why it is the better
default.

> **Where the log goes**
>
> A task has no console, so the log has to be a file: set `log.file` in the
> configuration. Standard output from a scheduled task is discarded, and
> `Get-ScheduledTaskInfo` reports only the exit code.

## Build it from source

Go 1.27.1 or newer, which is the version `go.mod` declares. `CGO_ENABLED=0`
means no MSVC, no MinGW and no Windows SDK.

- **go install**

  ```powershell
  go install github.com/jmrplens/ghchronicle/cmd/ghchronicle@latest
  ```

  Lands in `$(go env GOPATH)\bin`, which is `%USERPROFILE%\go\bin` unless you
  moved it, and that directory has to be on your `Path`. A binary built this
  way reports its version but not its commit or its build date, because those
  two are stamped by the release build and a module downloaded through the
  proxy carries no checkout to read them from.

- **From a checkout**

  ```powershell
  git clone https://github.com/jmrplens/ghchronicle
  cd ghchronicle
  go build -o ghchronicle.exe .\cmd\ghchronicle
  ```

  `go build` rather than `make`: the Makefile is a GNU Makefile whose recipes
  are POSIX shell, so it wants Git Bash, MSYS2 or WSL. This one line is what
  `make build` does, minus the version stamping.

## Where the files go

The collector looks for a configuration file in no particular place: `-config`
defaults to `config.yaml` **relative to the working directory**, and there is no
search path behind it. A scheduled task's working directory is not something to
rely on, so give every path in the file absolutely.

| File             | For a machine-wide task          | For one account                     |
| ---------------- | -------------------------------- | ----------------------------------- |
| Configuration    | `C:/ProgramData/ghchronicle/`    | `${LOCALAPPDATA}/ghchronicle/`      |
| State and ledger | `C:/ProgramData/ghchronicle/`    | `${LOCALAPPDATA}/ghchronicle/`      |
| Log              | `C:/ProgramData/ghchronicle/`    | `${LOCALAPPDATA}/ghchronicle/`      |

`${LOCALAPPDATA}` is written that way because `${VAR}` is the one form the
collector expands, from the environment, as the process starts. `%VAR%` is a
shell notation and means nothing to the file: a `%LOCALAPPDATA%` copied into
the YAML gives you a directory literally named `%LOCALAPPDATA%`, next to
wherever the task happened to be working. It is `%LOCALAPPDATA%` in `cmd` and
`$env:LOCALAPPDATA` in PowerShell that create the directory in the first place.

A directory under `C:\ProgramData` is writable by its creator and readable by
everyone, so create it elevated and then grant write to the account the task
runs as. The state file and its write ledger are the two the collector rewrites
on every sweep; if one of them is marked read-only the collector clears that
attribute itself, because NTFS refuses to replace a read-only file even when
asked to replace it, and a sweep should not fail over a file property.
