# Windows

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

Source: https://docs.jmrp.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.

## The one-line install

```powershell
irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1 | iex
```

It works out the architecture, takes the newest release, checks the archive
against the checksum that release published, and puts `ghchronicle.exe` under
`%LOCALAPPDATA%\Programs\ghchronicle`. A checksum that does not match stops it
without installing anything, and there is no switch to skip that step. When
`cosign` 2.4.2 or newer is already on your PATH it also verifies that the
checksum file itself came from the release workflow, the same check
`install.sh` makes, and a signature cosign does not confirm stops it, with what
cosign said, so a machine that cannot reach Sigstore is not taken for a forged
file. An older cosign cannot read the signature bundle, so then, as without
`cosign` at all, it says that only the checksum was verified.

It then adds that directory to **your** PATH, the per-user one, so `ghchronicle`
works by name. Programs already open keep the PATH they started with, so open a
new terminal. Windows reads the machine PATH before the per-user one, so
another `ghchronicle` installed for everyone, or an older `go install` build,
can keep answering to the name after this finishes; the script says so when it
finds one. Nothing is written outside your profile and no elevation is asked
for: a machine-wide install belongs to an installer with a UAC prompt, not to a
script read off the network.

To pin a version, choose the directory, or leave the PATH alone, the script
takes parameters, which needs the slightly longer form because `iex` has
nowhere to put them:

```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1))) -Version 2.6.5 -BinDir C:\tools -NoPathUpdate
```

> **Reading it first is the whole point of a short script**
>
> `irm ... | iex` runs whatever that URL answers with today. `irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1 -OutFile install.ps1`,
> read it, then `.\install.ps1`: it is the same script, and it is short enough
> to read.

The rest of this page is that same install done by hand, which is what to
follow when you want to know exactly what landed where, or when you would
rather not run a script you did not write.

Then let it write the configuration:

```powershell
ghchronicle -setup
```

It asks for a token and where the numbers go, checks each answer against the
thing it names, and writes a `config.yaml` under `%APPDATA%\ghchronicle` and,
if you want one, a scheduled task you register with `schtasks`.

## 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 = "2.6.5"
$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 major tag beside the numbered releases,
> because the Action is listed on the Marketplace and that listing needs one.
> That tag's 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/)
   2.4.2 or newer; an older one cannot read the bundle and fails whatever the
   file says. 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_VERSION_windows_ARCH.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
```

It answers with one line: the release number, then the commit and the build
date of that release.

```text
ghchronicle 2.6.5 (commit <commit>, built <date>)
```

> **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

A `${VAR}` in a credential, an address or a file path of 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](https://docs.jmrp.io/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 five that run every
  fifteen minutes, `actions`, `events`, `notifs`, `activity` and
  `ratelimit`, run four times less often, which is the same trade
  [cron makes on Linux](https://docs.jmrp.io/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.2 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/v2/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 has no commit or build date to report: those two are stamped by the
  release build, and a module downloaded through the proxy carries no
  checkout to read them from. `-version` names what the build does record
  instead, the module version the go command fetched and the Go release that
  compiled it:

  ```text
  ghchronicle 2.6.5 (module v2.6.5, built with <go version>)
  ```

- **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, ledger, cache | `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, and it does so
in `state_file`, `sinks.dedupe_file` and `log.file` from 2.6.1 on. An older
release expands no path, and reads `${LOCALAPPDATA}/ghchronicle/state.json` as
a directory literally named `${LOCALAPPDATA}`; with one of those, write the
path out. `%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.

The configuration's own path is the exception in the table: it is given on the
command line, where the collector expands nothing, so a scheduled task's
`-config` names it in full, `C:\Users\you\AppData\Local\ghchronicle\config.yaml`.

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. Leave the state file and the cache file beside it writable. The
collector replaces both by writing a new file and renaming it over the old one,
and NTFS refuses to replace a read-only file even when asked to replace it, so
a read-only state file stops being saved and every sweep warns `state not
saved`. The write ledger and the file sink's rotated files are the two it
clears that attribute on itself, and a sweep does not fail over them.
