Skip to content

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.

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.

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_ARCHITECTUREThe archive to take
AMD64windows_amd64
ARM64windows_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:

Terminal window
(Get-CimInstance Win32_Processor).Architecture # 9 is x64, 12 is ARM64
Terminal window
$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.

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.

    Terminal window
    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. The command is the same one the release notes print, and the same one a Linux or macOS reader runs.

    Terminal window
    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.

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

  • Directoryghchronicle_1.0.0_windows_amd64.zip
    • ghchronicle.exe the binary
    • LICENSE
    • README.md

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

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

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.

Terminal window
ghchronicle -version
ghchronicle 1.0.0 (commit 4e5dfc2, built 2026-09-14T23:04:02Z)

The token, and the rest of the environment

Section titled “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.

Terminal window
# This window only
$env:GITHUB_TOKEN = "github_pat_..."
# Persisted for this account
[Environment]::SetEnvironmentVariable("GITHUB_TOKEN", "github_pat_...", "User")

The binary needs a configuration file, and the quickstart writes one in six steps. Save it as UTF-8, and mind the two Windows details below it.

Terminal window
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.

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:

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.

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:

Terminal window
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.

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.

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

Terminal window
$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. 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.

Terminal window
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.

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.

Terminal window
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.

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.

FileFor a machine-wide taskFor one account
ConfigurationC:/ProgramData/ghchronicle/${LOCALAPPDATA}/ghchronicle/
State and ledgerC:/ProgramData/ghchronicle/${LOCALAPPDATA}/ghchronicle/
LogC:/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.