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
Section titled “The one-line install”irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1 | iexIt 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:
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/jmrplens/ghchronicle/main/install.ps1))) -Version 2.6.5 -BinDir C:\tools -NoPathUpdateThe 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:
ghchronicle -setupIt 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
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 = "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.
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 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.
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_VERSION_windows_ARCH.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 -versionIt answers with one line: the release number, then the commit and the build date of that release.
ghchronicle 2.6.5 (commit <commit>, built <date>)The token, and the rest of the environment
Section titled “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.
# 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 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.
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/v2/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 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:
ghchronicle 2.6.5 (module v2.6.5, built with <go version>)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, 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.