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
Section titled “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. Typingghchroniclefinds it, becausePATHEXTlists.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 /Fends 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
Section titled “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:
(Get-CimInstance Win32_Processor).Architecture # 9 is x64, 12 is ARM64$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.
Check what you downloaded
Section titled “Check what you downloaded”checksums.txt holds the SHA-256 of every archive, and
checksums.txt.sigstore.json is a signature over that file.
-
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).Hashif ($actual -eq $expected) { "OK" } else { "MISMATCH" }Get-FileHashreturns the digest in upper case andchecksums.txtholds it in lower case. They still compare equal because PowerShell’s-eqon two strings ignores case, which is the one place here where that default is convenient rather than a trap. -
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.txtThe backtick is PowerShell’s line continuation, where a shell script uses a backslash.
Put it somewhere and on the PATH
Section titled “Put it somewhere and on the PATH”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.
$dir = "C:\Program Files\ghchronicle"Expand-Archive -Path $zip -DestinationPath $dir -Force$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")[Environment]::SetEnvironmentVariable("Path", "$machine;$dir", "Machine")No elevation needed, and nothing outside your profile is touched.
$dir = "$env:LOCALAPPDATA\Programs\ghchronicle"Expand-Archive -Path $zip -DestinationPath $dir -Force$user = [Environment]::GetEnvironmentVariable("Path", "User")[Environment]::SetEnvironmentVariable("Path", "$user;$dir", "User")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.
ghchronicle -versionghchronicle 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.
# This window only$env:GITHUB_TOKEN = "github_pat_..."
# Persisted for this account[Environment]::SetEnvironmentVariable("GITHUB_TOKEN", "github_pat_...", "User")rem This window onlyset GITHUB_TOKEN=github_pat_...
rem Persisted for this account, and NOT visible in this windowsetx GITHUB_TOKEN "github_pat_..."Run it once
Section titled “Run it once”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.
ghchronicle -config config.yaml -list # what would be collectedghchronicle -config config.yaml -once # one sweep, then exitIf 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
Section titled “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:
state_file: "C:\ghchronicle\state.json" # ghchronicle: config.yaml: yaml: line N: found unknown escape characterstate_file: C:\ghchronicle\state.json # correct, plain scalarstate_file: 'C:\ghchronicle\state.json' # correct, single quotedstate_file: C:/ghchronicle/state.json # correct, and the one to preferForward 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
Section titled “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:
Set-Content -Path config.yaml -Value $text -Encoding utf8utf8 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
Section titled “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.
The Windows equivalent of cron, and the one to prefer: nothing has to be stopped, and a missed run costs one sweep.
$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.
A task triggered at logon or at startup, with the collector left in its long-running mode so each family keeps its own cadence.
$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.
Start-ScheduledTask -TaskName ghchronicleGet-ScheduledTaskInfo -TaskName ghchronicle # last run, last resultStop-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.
Build it from source
Section titled “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 github.com/jmrplens/ghchronicle/cmd/ghchronicle@latestLands 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.
git clone https://github.com/jmrplens/ghchroniclecd ghchroniclego build -o ghchronicle.exe .\cmd\ghchroniclego 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
Section titled “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.