Charlie KrugThe Build Log

← All posts

A broad version pin can agree with two versions that disagree

Pinset audits runtime pins across files, CI, and the local machine. Its pairwise comparison catches a subtle kind of drift that a broad version pin can hide.

A Go pin of 1.24 agrees with both 1.24.1 and 1.24.2. Those two exact patches do not agree with each other. That small wrinkle makes the obvious version-audit algorithm wrong.

Pinset is a read-only CLI that checks Node, Python, Go, and Ruby versions across a repository. It finds language pin files, matching setup steps in GitHub Actions, and the toolchains installed on PATH, then reports whether all those claims tell the same story.

I built it for the dull failure that arrives after a routine upgrade: .nvmrc changed, CI did not, and nobody notices until a contributor gets different behavior from the build. A custom audit script could catch that, but then the team owns another script. Pinset is one static Go binary, has no runtime dependency, and does not ask the repository to adopt a new version manager.

Agreement is not transitive

Pinset uses dotted-prefix compatibility. A broad pin such as 3.12 accepts an installed 3.12.4, while two fully specified versions must match exactly. That is useful because language files and CI configurations often intentionally omit the patch number.

The trap is treating one of those broad pins as the authority. If .python-version says 3.12, a naive checker can compare everything to it and declare both CI at 3.12.1 and the local interpreter at 3.12.4 clean. Every individual comparison passed, but CI and the maintainer are still running different patches.

Pinset keeps every claim and compares every pair within an ecosystem. In the example above, the exact-patch pair exposes the drift. This is a useful general lesson for compatibility checks: when “matches” means prefix-compatible rather than equal, it is not transitive, so a single reference value cannot prove that the whole set agrees.

The scanner also preserves repository-relative paths. A pin found three directories down is reported with its actual location, alongside every matching setup step from the root workflows. That turns “Python drifted” into a list of claims a maintainer can inspect.

The machine gets a vote

An absent executable is not neutral. If a repository pins Ruby but ruby is missing from the current machine, Pinset prints installed=not found and counts it as drift. Otherwise a half-configured laptop could produce a clean report simply because the checker had less evidence available.

Text and JSON are produced from the same result structure, and both follow the same exit contract: zero for agreement, one for drift or a failed scan, and two for invalid arguments. That makes the readable report and the CI gate two views of the same audit instead of two features that can quietly diverge.

The boundary is deliberately narrow. Version 1 knows specific pin files and GitHub setup actions, not every Dockerfile, shell script, or homegrown version declaration a repository might contain. It also will not decide whether CI or the pin file is correct, install a missing runtime, or rewrite anything. It names disagreements and stops. For a tool meant to run against an unfamiliar monorepo, I prefer that limitation to an automatic “fix” with an opinion hidden inside it.

Try it

Open Pinset, grab the static binary, and point it at a polyglot checkout. In the first report, look for one ecosystem whose file, workflow, and installed claims differ, then rerun with --json to see the same evidence in a form CI can consume. The source and release workflow are on GitHub.

Pinset is live. Free, in your browser, no signup.

This post is part of the build log: every app my automated factory ships gets written up here, honestly. Browse everything at apps.charliekrug.com. Comments are open below.

Comments

Loading comments…