Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

pinstall

Go Reference Go version Test coverage Mutation OpenSSF Best Practices OpenSSF Scorecard

Digest-pinned installs of an upstream release: version-addressed, revalidated, with a verdict

A Go library for programs that must install a specific version of some other program and then depend on it. You give it a URL template, the version you pinned, and the SHA-256 of each architecture's archive. It downloads the archive, refuses anything whose digest does not match, installs it into a version-addressed directory, re-probes the binary it is about to activate, re-asserts the settings your install depends on, keeps N predecessors as a fallback, and reports whether the result is usable.

It is the piece you would otherwise write in a shell script, with the failure modes that script usually has: a half-written install directory that looks finished, a binary that silently self-updated out from under the digest you verified, a partial download reported as a checksum mismatch, and no way to tell "still installing" from "gave up".

Nothing here exits your process, reads the environment, or does work at import time. Zero dependencies outside the standard library. Linux only.

Why it is shaped this way

Four decisions drive everything else:

  • A version directory is complete or it does not exist. Artifacts are written into a staging tree, each one is synced, a .complete sentinel naming the version is written and synced last, the directory is synced, and only then is it renamed into place. An interrupted install is detectable by the absence of the sentinel and is never a selection candidate. Atomic visibility is not crash durability, which is why the syncs are separate steps rather than an afterthought.
  • Nothing reaches the installation root before the digest matches. The archive is downloaded and verified in a process-local temp directory. On a mismatch there is not even an empty directory to find later.
  • A sentinel is not proof. Before a version is activated its primary artifact is probed and must answer with the version its own directory claims. An artifact replaced on the volume under an intact sentinel is excluded, which falls through to another complete version and leaves the pin unsatisfied so the next pass reinstalls.
  • A failed install is survivable. Every complete version already on the volume keeps serving. Pruning runs only after a successful publish, because those directories are the fallback set. Retries are bounded, and a repair made in place is picked up by Rescan without restarting your process.

Install

go get github.com/cplieger/pinstall@latest

Usage

A Release is everything true of the package, independent of where it runs — write it once. A Config is one deployment of it: the pin, the digests, the root and your local policy.

package main

import (
	"context"
	"log"
	"os/exec"

	"github.com/cplieger/pinstall"
)

// The pinned digests. Whatever bumps your version literal bumps these with it.
const (
	widgetVersion = "1.4.2"
	amd64SHA      = "9f2b...64 lowercase hex characters..."
	arm64SHA      = "3ce1...64 lowercase hex characters..."
)

// The profile: written once, reused by every deployment.
func widgetRelease() pinstall.Release {
	return pinstall.Release{
		Name:        "widget",
		URLTemplate: "https://widgets.example/dl/{arch}/widget_{version}.zip",
		ArchTokens:  map[string]string{"amd64": "linux-64", "arm64": "linux-arm"},
		ArtifactDir: "dist/bin", // the archive already holds the artifacts
		ProbeArgs:   []string{"--version"},
		Mandatory: []pinstall.Assertion{
			// Must hold after every install; a deployment cannot drop it.
			{Name: "autoupdate", Args: []string{"config", "set", "autoupdate", "off"}},
		},
	}
}

func main() {
	mgr, err := pinstall.New(&pinstall.Config{
		Release: widgetRelease(),
		Version: widgetVersion,
		Digests: map[string]string{"amd64": amd64SHA, "arm64": arm64SHA},
		Root:    "/var/lib/example/tools",
		LinkDir: "bin",                     // optional convenience symlink
		Require: []string{"widget-helper"}, // artifacts you cannot run without
	})
	if err != nil {
		log.Fatal(err)
	}

	// Bounded, retrying, and non-fatal: a failure leaves your program running.
	if err := mgr.EnsureWithRetry(context.Background()); err != nil {
		log.Printf("widget install failed, serving degraded: %v", err)
	}

	if ready, why := mgr.Ready(); !ready {
		log.Printf("widget is not usable yet: %s", why)
		return
	}

	// Always the absolute version-directory path, never the convenience link.
	out, err := exec.Command(mgr.Path(), "run").Output()
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("%s", out)
}

The library writes only under Root:

<Root>/
├── <Name>-versions/
│   ├── 1.4.2/            # the active version: artifacts + .complete
│   └── 1.4.1/            # the retained predecessor
├── <Name>-state.json     # a diagnostic record; never an input to Ready
└── bin/<Binary>          # the optional convenience symlink

Configuration reference

Release — the package profile

Field Description
Name The package identity. Fixes the versions root and the state file. Required
Binary The primary artifact's file name: what gets probed, linked, and always required. Empty defaults to Name
URLTemplate The archive URL, carrying {version} and {arch}. Required
ArchTokens GOARCH to the publisher's token ("amd64" to "x86_64-linux"). An unmapped architecture is ErrUnsupportedArch
ProbeArgs The argv that makes the primary artifact print its version. Required
ParseVersion Parses the probe's output. Nil uses LastFieldOfFirstLine
Unpack Extracts the verified archive. Nil uses UnpackZip
Installer An installer script shipped inside the archive. Nil means the archive already holds the artifacts
ArtifactDir Where the artifacts land: relative to the installer's private home when Installer is set, else to the extracted tree
Mandatory Assertions a deployment cannot configure away. At least one is required — see below
Notice Logged once per install attempt, for a licence acknowledgement the upstream requires

Config — one deployment

Field Description
Release The profile above
Version The pin, constrained to a path- and URL-safe character set
Digests GOARCH to the lowercase hex SHA-256 of that architecture's archive. The running architecture needs one
Root The absolute installation root; the only tree touched
GOARCH Overrides the architecture. Empty resolves from runtime.GOARCH
URLTemplate Overrides the release's template, for a mirror
Require Artifacts a version directory must hold to count as complete. The primary artifact is always added
Optional Artifacts installed when the archive provides them, warned about when it does not
Assert Assertions re-asserted on every pass. Release.Mandatory is merged in
Purge A one-shot sweep of a layout a previous installer left behind. Nil skips it
LinkDir A directory under Root for the convenience symlink. Empty publishes none
Retain Predecessors kept besides the active version. Zero uses 1
RetryBackoff The first EnsureWithRetry backoff, doubling to a ten minute cap. Zero uses 30s
MaxAttempts Bounds EnsureWithRetry. Zero uses 4
Untrusted Root was writable by others: activate only what this process installed

Manager

Method Description
Ensure(ctx) error One idempotent pass: purge, prune partials, select, install if needed, assert
EnsureWithRetry(ctx) error Ensure with bounded exponential backoff. Never exits the process
Rescan(ctx) (bool, error) Re-derive from disk, download nothing. Makes an in-place repair observable
Ready() (bool, Reason) The verdict, and why it is withheld
Active() (State, bool) The diagnostic record, and whether a version is active
PathEntry() string The directory to lead PATH with, or ""
Path() string The absolute primary artifact, or ""

Reason is an enum, not a message: ReasonReady, ReasonInstalling, ReasonRetrying, ReasonUnavailable, ReasonAssertion. Your program owns the wording it shows its own users; the library owns only the distinction. Typed errors for classification: ErrDigestMismatch, ErrUnsupportedArch, ErrNoVersion, ErrVersionMismatch.

Assertions, and why one is mandatory

An Assertion is a bounded command run against the installed artifact — the full argv after the artifact's path, so the library needs to know nothing about how your package is configured. Required assertions run twice: against the staged artifact before publication, so a candidate they cannot hold on never becomes a version directory, and against the active artifact on every pass, because an assertion's effect usually lives in the package's own mutable configuration and cannot be remembered. A required failure withholds readiness; anything else only warns.

Release.Mandatory is the set a deployment cannot weaken, reword or drop: whatever a caller passes, each one is forced Required and substituted with the profile's own argv. A profile that declares none is refused at construction. The most common thing to assert is that the package will not update itself — a self-replacing binary invalidates the digest you verified — and "this package needs no post-install guarantee" is indistinguishable from "the profile author forgot". A package that genuinely needs no gate declares its cheapest positive check instead, which makes it a claim someone had to write on purpose.

Included profiles

pinstall names no vendor. A ready-made profile for one release ships alongside it:

  • pinstall/kirocli — the kiro-cli release: URL shape, architecture tokens, in-archive installer, probe argv, licence notice, and the auto-update assertion. Also kirocli.Setting(key, bool) / kirocli.SettingRaw(key, value) for building assertions in that package's own settings grammar.
mgr, err := pinstall.New(&pinstall.Config{
	Release: kirocli.Release(),
	Version: version,
	Digests: map[string]string{"amd64": amd64SHA, "arm64": armSHA},
	Root:    toolsDir,
	LinkDir: "bin",
	Assert:  []pinstall.Assertion{kirocli.Setting("telemetry.enabled", false)},
})

The pin stays with the consumer, so whatever bumps your version and digests keeps working unchanged.

Sweeping a previous installer's layout

If your program used to install the same package a different way, Config.Purge removes what that installer left behind — once per volume, recorded by a marker file. It is injected rather than built in, because the residue is a fact about one deployment's history, not about the package.

Every target is removed only when what is on disk has the shape the old installer left there: a declared artifact must be a regular file, a staging tree must be a directory. A directory holding a convenience link is often co-owned, and another installer publishes symlinks there; a symlink at a swept path is somebody else's live pointer and is refused rather than deleted. Deletes are confined by os.Root, so a redirected entry cannot reach outside Root. A target that could not be removed withholds the marker, so the next pass retries instead of recording a job it did not finish.

Unsupported by Design

Deliberate non-goals, not TODOs:

Not included Rationale
Signature or attestation verification The trust anchor is the digest you pinned, which you obtained out of band. Verify a signature where you produce the pin, not where you consume it
Resolving "latest" A resolved version is not a pin. Whatever bumps your version and digest literals owns that decision; this library installs exactly what it was told
Rollback, journals, backups Nothing is ever overwritten in place, so there is no partial-promotion window to recover from. The retained predecessor is the recovery mechanism
Archive formats beyond zip One Unpacker is shipped and tested. A tar.gz consumer supplies a function; an enum with one implemented value would be a partially built public surface
Windows and macOS The publish protocol relies on same-filesystem rename plus fsync of a directory, and the confined deletes use os.Root. Linux only, like the rest of this account's libraries
Live in-process version upgrades Retention assumes a new pin arrives by restarting the consumer. Enabling live upgrades would require per-version leases before a directory could be pruned

Contributing

Issues and PRs are welcome. See CONTRIBUTING.md for the conventions and how to run the checks locally.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

GPL-3.0. See LICENSE.

About

Install and update a pinned, digest-verified package into versioned directories

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages