Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Pinner

Docs


Pin floating dependency versions across mise, Node, Python, Docker, GitHub Actions, Terraform, Helm, and Kubernetes. Rewrite manifests to exact pins, commit a unified pinner.lock.json, and fail CI when the graph drifts.

Install

Linux / macOS (curl installer):

curl -fsSL https://raw.githubusercontent.com/zloeber/Pinner/main/scripts/install.sh | bash

Installs to ~/.local/bin/pinner (override with PINNER_INSTALL_DIR). Pin a version with PINNER_VERSION=0.2.0, or dry-run with PINNER_INSTALL_DRY_RUN=1.

Download a multi-platform binary manually from GitHub Releases (Linux/macOS/Windows), or build from source:

cargo install --locked --path crates/pinner

Or via mise backends (availability depends on mise version and published package name):

# GitHub release backend (prebuilt)
mise use -g github:zloeber/Pinner

# Cargo backend (build from crates.io / git as configured)
mise use -g cargo:pinner

Curl installer and cargo install remain first-class.

Quick start

# Resolve floating refs, rewrite sources, write pinner.lock.json
pinner pin
pinner pin --recursive             # include subdirectories
pinner pin --path ./services/api  # target a specific directory scope

# Bump exact pins to latest (rewrite + lock)
pinner upgrade
pinner upgrade --walkthrough   # interactive accept/skip/edit (TTY)
pinner upgrade --script        # emit native package-manager upgrade script
pinner upgrade --continue-on-ecosystem-error  # warn and continue when one ecosystem fails

# Drift gate (no writes) — exit 1 on mismatch, 2 on tool/config errors
pinner check

# Report floating refs; optionally apply pins
pinner audit
pinner audit --fix

# Explain why a pin was chosen
pinner explain <name-or-path>

Agents and CI should use --agent / --format json — never --walkthrough in automation.

Provider support

ProviderDefaultPreferred upgrade meansAlso supportedUpgrade pin styleNotes
miseonmise latest / mise ls-remotePINNER_MISE_RESOLVE_MAPexact tool versionNested mise configs included
nodeonnpm view <pkg> versionnative lock ignored in upgrade; mapexact version in package.jsonWorkspaces supported
pythononuv pip compile / uv resolvepoetry/pdm locks ignored in upgrade; mapexact ==Prefer uv over pip/poetry CLI
dockerondocker buildx imagetools inspectlocal inspect; mapname@sha256:…Upgrades digest for image reference
actionsongh api (latest release → SHA)docker digests for workflow images; map@<sha> + image digestsReusable/composite uses included
terraformonregistry HTTP latest versiongit ls-remote; .terraform.lock.hcl ignored in upgrade; mapexact version / full SHALocal modules skipped
helmopt-inHTTP index / OCI tags latestdocker digests for values images; mapexact chart ver / image digestRequires helm = true
k8sopt-indocker digestsmapimage digestWorkload kinds only
cargooncrates.io HTTP APIcargo CLI if used; mapexact semver in Cargo.tomlPath/git deps skipped
goongo list -m -uproxy.golang.org; mapexact module version
rubyonRubyGems HTTPgem optional; mapexact gem version
gitlabopt-indocker digests + git ls-remotemapdigest / SHAOpt-in in pinner.toml
azureopt-indocker digests; task versions via map/HTTPmapdigest / exact task verOpt-in; tasks map-only until marketplace HTTP

Per-provider Pin / Upgrade / Check / Gaps: docs/guide/ecosystems/.

Config (pinner.toml)

Optional repo-root overrides:

[ecosystems]
mise = true
node = true
python = true
docker = true
actions = true
terraform = true   # default on
helm = true        # opt-in (default off)
k8s = true         # opt-in (default off)

ignore = ["**/node_modules/**", "**/.git/**", "**/vendor/**", "**/tests/fixtures/**"]

[toolchain]
install = true

[pinning]
exact_ranges = true

Global flags: --config, --offline, --dry-run, --ecosystem mise,node, --format text|json.

--ecosystem filters already-enabled kinds from policy; it does not turn on opt-in ecosystems. To pin Helm or Kubernetes, set helm = true / k8s = true in pinner.toml (or rely on defaults for Terraform).

IaC ecosystems (Terraform, Helm, Kubernetes)

EcosystemDefaultPin styleSources
terraformonexact semver for registry modules/providers; git/HTTP module sources → full commit SHA in ?ref=*.tf / *.tofu — remote module blocks and required_providers
helmoff (opt-in)exact chart version strings; floating images in values*.yaml → name@sha256:…Chart.yaml dependencies; Flux HelmRelease; Argo CD Application; values.yaml / values*.yaml images
k8soff (opt-in)container images → name@sha256:…YAML workloads: Deployment, StatefulSet, DaemonSet, Job, CronJob

What is skipped

  • Terraform: local module sources (./, ../, absolute paths); CLI required_version; .terraform/ directory during discovery.
  • Kubernetes: non-workload kinds (ConfigMap, HelmRelease, etc.).

Resolution (lock, native evidence, env maps)

Resolve order matches other ecosystems: existing pinner.lock.json pins first, then ecosystem-specific evidence, then network/tool resolve when online.

  • Terraform providers: may use .terraform.lock.hcl provider selections when present.
  • Terraform git modules: when online, git ls-remote can resolve a floating ref to a full SHA (rewritten as ?ref=<full-sha>).
  • Terraform registry modules/providers: when online (and map/lock miss), HTTP GET against registry.terraform.io version APIs selects the latest matching version. Offline still requires a lock entry or PINNER_TERRAFORM_RESOLVE_MAP.
  • Helm charts: when online (and map miss), HTTP repo index.yaml (and OCI tags list) can resolve chart versions. Prefer PINNER_HELM_RESOLVE_MAP for offline/tests.
  • Helm values images / K8s images: use the shared Docker/buildx digest helper when online; maps preferred in tests.

Test / CI resolve-map env vars

Comma-separated {name}@{requested}=pinned pairs (see pinner_iac_common::parse_resolve_map / resolve_map_lookup). Prefer the name@requested form so shared constraints (e.g. ~> 5.0) do not collide across artifacts. Lookup tries name@requested first, then a legacy bare requested key.

Keys may contain = (e.g. Terraform git ?ref= URLs); use the last = as the separator. Empty requested (missing Helm chart version) uses {name}@= → key {name}@.

export PINNER_TERRAFORM_RESOLVE_MAP='vpc@~> 5.0=5.1.0,hashicorp/aws@~> 5.0=5.100.0,git_mod@git::https://example.com/org/mod.git?ref=main=11bd71901bbe5b1630ceea73d27597364c9af683'
export PINNER_HELM_RESOLVE_MAP='redis@^1.0.0=1.2.3,ingress-nginx@=4.10.0,nginx@nginx:latest=nginx@sha256:abc123…'
export PINNER_K8S_RESOLVE_MAP='nginx@nginx:latest=nginx@sha256:abc123…'

Network integration tests may require PINNER_NETWORK=1. With --offline, resolution fails closed unless the lock or resolve map supplies every pin.

Example opt-in Helm/K8s in pinner.toml:

[ecosystems]
helm = true
k8s = true

Toolchain

Resolver binaries (mise, node/npm, uv, docker, gh) can be detected or installed when policy allows:

pinner toolchain status
pinner toolchain ensure

Prefer a preinstalled mise (checked on PATH and common locations such as ~/.local/bin/mise). toolchain.install = true (or ensure with install allowed) may install tools through mise, but will not download mise via curl|sh unless you also set:

export PINNER_BOOTSTRAP_MISE=1
pinner toolchain ensure

That bootstrap prints a warning to stderr. Prefer installing mise yourself in CI images and developer machines.

Runtime resolution prefers pinner.lock.json and native locks (package-lock.json / pnpm-lock.yaml / yarn.lock, uv.lock / poetry.lock / pdm.lock), so tools are not required for every check once the lock is complete.

Actions / Compose rewrite notes

GitHub Actions uses: and Compose image: rewrites are line-oriented and preserve indentation for common list forms (- uses: …, nested steps:). Full YAML comment round-trips are not guaranteed for exotic multi-line or flow-style nodes.

CI

See .github/workflows/consumer-example.yml for a documentation-only sample. Typical gate:

- uses: actions/checkout@v4
- run: cargo install --locked --path crates/pinner # or release binary
- run: pinner toolchain ensure
- run: pinner check

Lock schema

pinner.lock.json is validated against schemas/pinner.lock.schema.json.

Development

Tooling for this repo is pinned in .mise.toml and orchestrated via Taskfile.yml (taskfile.dev).

Bootstrap

# Install mise if needed: https://mise.jdx.dev/getting-started.html
mise trust
mise install          # rust (+rustfmt/clippy), node, uv, gh, task
task setup            # verifies binaries (docker is optional/host-provided)

Pinned tools:

ToolPurpose
rust 1.96.0 (+ rustfmt, clippy)Build and test Pinner
node / npmNode ecosystem resolver evidence
uvPython ecosystem resolver evidence
ghGitHub Actions resolve
taskTask runner
dockerHost-installed only (not via mise)

Common tasks

task                  # list tasks
task setup            # mise install + version smoke checks
task build            # cargo build --workspace
task test             # cargo test --workspace
task fmt              # cargo fmt
task fmt:check        # formatting gate
task clippy           # clippy -D warnings
task ci               # fmt:check + clippy + test + schema
task ci:local         # lean CI summary (preferred before push)
task docs             # build mdBook into ./book (requires mdbook)
task run -- pin --dry-run
task pinner:audit

Fixture matrix under tests/fixtures/*-floating covers mise, node, python, docker, actions, terraform, helm, and k8s.

CI, docs, and releases

WorkflowTriggerPurpose
ci.ymlpush / PRlint (fmt + clippy -D warnings) and test (workspace + schema)
docs.ymlpush to main (docs paths)Build mdBook and deploy GitHub Pages
semantic-release.ymlpush to mainConventional commits → annotated tag vX.Y.Z (needs PAT_TOKEN)
release.ymltag v*.*.*Multi-platform release binaries + GitHub Release

Cutting a release

Automated (preferred): merge conventional commits to main (feat:, fix:, feat!: / BREAKING CHANGE). Semantic-release tags vX.Y.Z; the release workflow builds binaries. Seed PAT_TOKEN once via Secretfile.yml / SecretZero (see releasing guide).

Manual:

git tag -a v0.2.0 -m "v0.2.0"
git push origin v0.2.0

No Cargo.toml pre-bump required—the release job aligns workspace version from the tag. pinner --version prefers the latest v* git tag. Details: docs/guide/releasing.md.

GitHub Pages setup (one-time)

In the repo Settings → Pages, set Source to GitHub Actions. The first successful docs workflow on main publishes the site.

Local CI before push

Agents and humans should run the lean gate before pushing:

scripts/ci-local          # fmt + clippy + test + schema (short output)

Cursor enforces this via .cursor/rules/pre-push-local-ci.mdc and a beforeShellExecution hook on git push. Optional git hook:

git config core.hooksPath .githooks   # local only

Emergency skip: PINNER_SKIP_LOCAL_CI=1 git push.

Quick start

Install the latest release (Linux / macOS, x86_64 or arm64):

curl -fsSL https://raw.githubusercontent.com/zloeber/Pinner/main/scripts/install.sh | bash

Or via mise backends:

mise use -g github:zloeber/Pinner
# or: mise use -g cargo:pinner

Or build from this repository:

cargo install --locked --path crates/pinner

Common commands:

pinner pin       # resolve floating → exact + rewrite + pinner.lock.json
pinner upgrade   # bump exact pins to latest + rewrite + lock
pinner check     # drift gate (no writes)
pinner audit     # report floating refs (live progress on TTY stderr; pretty panel on stdout)
pinner audit --fix
pinner explain <name-or-path>
pinner pin --recursive            # include subdirectories under current path
pinner pin --path ./services/api  # scan a specific directory

Modes:

pinner pin --agent                 # JSON, no prompts (automation / agents)
pinner upgrade --agent             # same for upgrades
pinner pin --walkthrough           # interactive compact-list gate (TTY only)
pinner upgrade --walkthrough       # interactive accept/skip/edit per row (TTY only)
pinner audit --format json         # machine-readable findings
pinner audit --agent               # same JSON contract for agents

Interactive text audit prints per-ecosystem discover/extract progress on stderr; --agent / --format json stay quiet on stderr for progress.

Agents: prefer --agent / --format json. Never pass --walkthrough in non-interactive or agent loops.

Opt-in ecosystems (helm, k8s, gitlab, azure) need pinner.toml — see Configuration.

Exit codes: 0 success, 1 drift/findings, 2 tool/config/resolution/invalid mode error.

See the repository README for the provider matrix and ecosystems for Pin / Upgrade / Check / Gaps per provider.

Configuration

Optional repo-root pinner.toml:

[ecosystems]
mise = true
node = true
python = true
docker = true
actions = true
terraform = true   # default on
cargo = true       # default on
go = true          # default on
ruby = true        # default on
helm = false       # opt-in
k8s = false        # opt-in
gitlab = false     # opt-in
azure = false      # opt-in

ignore = ["**/node_modules/**", "**/.git/**", "**/vendor/**", "**/tests/fixtures/**"]

[toolchain]
install = true

[pinning]
exact_ranges = true

Global flags: --config, --offline, --dry-run, --ecosystem mise,terraform, --format text|json, --agent, --walkthrough.

--ecosystem only filters ecosystems that are already enabled in policy. Enable Helm/Kubernetes/GitLab/Azure with helm = true / k8s = true / gitlab = true / azure = true in pinner.toml before filtering.

Upgrade (v1)

pinner upgrade uses the same globals and [ecosystems] enable flags as pin / check. There is no [upgrade] table and no new required pinner.toml keys in v1. --ecosystem still filters enabled kinds only.

Upgrade skips native lock evidence (and Terraform .terraform.lock.hcl) so bumps target latest from preferred tools / registries / resolve maps — see ecosystems. Prior pinner.lock.json pins are still passed into resolve context for display-only previous metadata.

Default ignore globs include **/tests/fixtures/** so dogfooding pin/upgrade at a repo root does not walk fixture trees.

Agent vs walkthrough

  • --agent (or --format json / non-TTY): structured JSON, no prompts. Prefer this in automation and AI agent loops.
  • --walkthrough: interactive compact-list gate before writes. Requires a TTY; combining with --agent / --format json / non-TTY exits 2.
  • Agents should never pass --walkthrough.

Releasing

Pinner uses tag-driven semantic versions. A GitHub Release (with multi-platform binaries) is created when a version tag is pushed—usually by the semantic-release workflow after conventional commits land on main.

Automated path (preferred)

  1. Merge PRs to main using conventional commit subjects:
    • feat: → minor bump
    • fix: / refactor: / perf: → patch bump
    • feat!: or commit body containing BREAKING CHANGE → major bump
    • Docs/chore-only commits do not create a release
  2. .github/workflows/semantic-release.yml runs on main, computes the next version, commits a chore(release): vX.Y.Z bump of [workspace.package].version + Cargo.lock, and pushes an annotated tag on that commit.
  3. .github/workflows/release.yml runs on that tag, aligns workspace version from the tag for the build (idempotent when already bumped), runs cargo update -w, compiles binaries with --locked, and publishes the GitHub Release.

pinner --version reports the latest v* git tag (via build.rs / git describe), falling back to Cargo.toml when git history is unavailable. task install / cargo install --path report the Cargo.toml package version—kept in sync by the release commit above.

One-time: seed PAT_TOKEN with SecretZero

Semantic-release checks out with GITHUB_TOKEN (persist-credentials: false), then uses a classic PAT only for the tag push so release.yml can chain (default GITHUB_TOKEN pushes do not trigger other workflows). The push step also clears checkout’s http.*.extraheader so URL-embedded PAT credentials are not overridden by a persisted job token.

  1. Create a classic PAT (ghp_…) with repo / contents write for zloeber/Pinner. Fine-grained tokens work only if they have Contents: Read and Write on this repo.
  2. From the repo root, sync via SecretZero using Secretfile.yml (maps release_token → Actions secret PAT_TOKEN):
# Human / local — do not paste the token into agent chat
export GITHUB_TOKEN=...   # token that can write GitHub Actions secrets
export PAT_TOKEN=...      # the release PAT to seed
secretzero agent sync --web
# or: secretzero web
  1. Store PAT_TOKEN as a repository secret (SecretZero does this). The semantic-release job uses the production environment; repository secrets remain available there. Optionally add the same secret as an environment secret if you prefer environment-scoped credentials.

Never commit PAT values.

If semantic-release fails on “Failed to push tag with PAT_TOKEN”, the secret exists but the token value is expired, revoked, or missing write scope — create a new classic PAT and re-run secretzero agent sync --web.

If a tag is created but release never starts, the tag was almost certainly pushed with GITHUB_TOKEN (checkout’s persisted http.extraheader beating the PAT URL). Confirm with Actions: no release run for that tag. Fix is already in semantic-release.yml (persist-credentials: false + clear extraheader before push). To recover a stuck tag (for example v0.2.0): delete it and re-push with a human PAT, or delete it and re-run Semantic Release after the fix is on main:

git push origin :refs/tags/v0.2.0
# then: Actions → Semantic Release → Run workflow
# or re-push the annotated tag from a machine authenticated as a user/PAT

Manual tag (still supported)

git tag -a v0.2.0 -m "v0.2.0"
git push origin v0.2.0

You do not need to bump Cargo.toml first for a manual tag: the release workflow sets the workspace version from the tag before building. Prefer letting semantic-release bump Cargo.toml on main so local task install matches the published version.

Supported tag patterns:

  • vMAJOR.MINOR.PATCH (for example v1.0.0)
  • Pre-release suffix: vMAJOR.MINOR.PATCH-SUFFIX (for example v1.0.0-rc.1)

What the release workflow builds

TargetRunner
x86_64-unknown-linux-gnuubuntu-latest
aarch64-unknown-linux-gnuubuntu-24.04-arm
x86_64-apple-darwinmacos-15-intel
aarch64-apple-darwinmacos-latest
x86_64-pc-windows-msvcwindows-latest

Artifacts are attached to the GitHub Release as .tar.gz (Unix) or .zip (Windows). Asset names follow pinner-{version}-{target}.tar.gz (for example pinner-0.2.0-x86_64-unknown-linux-gnu.tar.gz).

End-user install

After a release is published, users on Linux or macOS can install with:

curl -fsSL https://raw.githubusercontent.com/zloeber/Pinner/main/scripts/install.sh | bash

Environment variables:

VariableDefaultPurpose
PINNER_VERSIONlatest GitHub ReleasePin a semver (without v)
PINNER_INSTALL_DIR$HOME/.local/binInstall destination
PINNER_INSTALL_DRY_RUNunsetSet to 1 to print URL and path only
PINNER_REPOzloeber/PinnerGitHub owner/repo for releases

Windows users download the .zip asset from the GitHub Release page.

Dry run

Use Actions → release → Run workflow with dry_run=true to build packages without publishing a release. Set dry_run=false only when you intend to publish from workflow_dispatch (normally you publish by pushing a tag or via semantic-release).

CI quality gates

Pull requests and pushes run:

  • lint — cargo fmt --check and clippy -D warnings
  • test — full workspace tests + lock schema fixtures

Documentation is built with mdBook and deployed to GitHub Pages from main.

Ecosystems

Each provider supports Pin (freeze floating → exact), Upgrade (bump exact pins to latest), and Check (drift vs pinner.lock.json). Gaps call out what is skipped.

ProviderDefaultPreferred upgrade tool
miseonmise latest / mise ls-remote
nodeonnpm view
pythononuv
dockerondocker buildx imagetools inspect
actionsongh api
terraformonregistry HTTP
helmopt-inHTTP index / OCI
k8sopt-indocker digests
cargooncrates.io HTTP
goongo list -m -u
rubyonRubyGems HTTP
gitlabopt-indocker digests + git ls-remote
azureopt-indocker digests; tasks via map

Full matrix (also supported / pin style / notes): repository README. Design: upgrade subcommand.

mise

Default: on. Preferred upgrade tool: mise latest / mise ls-remote.

Pin

Discovers floating tool versions in mise configs (including nested), resolves via lock → PINNER_MISE_RESOLVE_MAP → mise CLI when online, rewrites to exact tool versions.

Upgrade

Bypasses lock evidence. Prefers mise latest / mise ls-remote for the newest matching tool version; falls back to PINNER_MISE_RESOLVE_MAP. Pin style: exact tool version string.

Check

Compares discovered mise pins against pinner.lock.json (no writes).

Gaps

  • Offline without lock/map fails closed.
  • Tools not known to mise cannot be resolved via the mise CLI path.

node

Default: on. Preferred upgrade tool: npm view <pkg> version.

Pin

Finds floating ranges in package.json (workspaces supported). Resolve order: lock → native lock (package-lock.json / pnpm-lock.yaml / yarn.lock) → PINNER_NODE_RESOLVE_MAP → npm view when online. Rewrites exact versions in package.json.

Upgrade

Ignores native lockfiles for upgrade evidence. Uses npm view <pkg> version (or resolve map). Detects pnpm/yarn locks for rewrite context only. Pin style: exact version in package.json.

Check

Drift gate against pinner.lock.json.

Gaps

  • Path / link / workspace protocol deps are not upgraded as registry packages.
  • Private registries need credentials available to npm / maps for offline CI.

python

Default: on. Preferred upgrade tool: uv (uv pip compile / uv resolve).

Pin

Finds floating specs in requirements*.txt, pyproject.toml, etc. Evidence: lock → native locks (uv.lock / poetry.lock / pdm.lock) → PINNER_PYTHON_RESOLVE_MAP → uv when online. Rewrites exact == pins.

Upgrade

Ignores poetry/pdm locks for upgrade. Prefers uv resolve / uv pip compile over pip or poetry CLI; map fallback. Pin style: exact ==.

Check

Drift vs pinner.lock.json.

Gaps

  • Prefer uv; other package managers are not the upgrade path.
  • Extras / URL / VCS deps may be skipped or map-only depending on extract support.

docker

Default: on. Preferred upgrade tool: docker buildx imagetools inspect.

Pin

Discovers floating image tags in Dockerfiles / Compose. Resolves digests via lock → map → buildx/local inspect. Rewrites name@sha256:….

Upgrade

Retargets mutable tags to a fresh digest (or upgrades digest for the same floating-tag policy). Preferred: docker buildx imagetools inspect; local inspect and PINNER_DOCKER_RESOLVE_MAP as fallbacks. Pin style: name@sha256:….

Check

Drift vs pinner.lock.json.

Gaps

  • Exact digest pins without tag metadata may skip re-resolve.
  • Buildx / daemon access required for online digest resolution.

actions

Default: on. Preferred upgrade tool: gh api (latest release → commit SHA).

Pin

Finds floating uses: refs and workflow images. Lock → map → gh api / docker digests. Rewrites @<sha> and image digests. Reusable and composite actions included.

Upgrade

Prefers gh api: latest release tag’s commit, else default branch HEAD. Workflow images use docker digests. Map fallback (PINNER_ACTIONS_RESOLVE_MAP). Pin style: @<sha> + image digests.

Check

Drift vs pinner.lock.json.

Gaps

  • Local / path actions are not remote-resolved.
  • Private repos need gh auth; offline needs lock or map.

terraform

Default: on. Preferred upgrade tool: Terraform Registry HTTP (latest unconstrained version).

Pin

Remote module blocks and required_providers in *.tf / *.tofu. Lock → .terraform.lock.hcl (providers) → map → registry HTTP / git ls-remote. Exact semver or full git SHA in ?ref=.

Upgrade

Ignores .terraform.lock.hcl for upgrade. Registry HTTP selects latest version (not ~> floor). Git modules: git ls-remote. Map: PINNER_TERRAFORM_RESOLVE_MAP. Pin style: exact version / full SHA.

Check

Drift vs pinner.lock.json.

Gaps

  • Local module sources (./, ../, absolute paths) skipped.
  • CLI required_version not pinned.
  • Discovery skips .terraform/.

helm

Default: off (opt-in: helm = true). Preferred upgrade tool: HTTP repo index.yaml / OCI tags (latest chart).

Pin

Chart dependencies (Chart.yaml), Flux/Argo sources, and floating images in values*.yaml. Lock → map → HTTP/OCI / docker digests. Exact chart version and name@sha256:… for images.

Upgrade

Latest chart version from index/OCI; values images via docker digests. Map: PINNER_HELM_RESOLVE_MAP. Pin style: exact chart ver / image digest.

Check

Drift vs pinner.lock.json (only when helm enabled).

Gaps

  • Requires helm = true in pinner.toml.
  • Private/OCI repos need network credentials or maps for offline CI.

k8s

Default: off (opt-in: k8s = true). Preferred upgrade tool: docker digests (docker buildx imagetools inspect).

Pin

Container images in workload YAML (Deployment, StatefulSet, DaemonSet, Job, CronJob). Lock → map → digests. Rewrites name@sha256:….

Upgrade

Fresh digests for image references. Map: PINNER_K8S_RESOLVE_MAP. Pin style: image digest.

Check

Drift vs pinner.lock.json when k8s enabled.

Gaps

  • Non-workload kinds (ConfigMap, HelmRelease, etc.) skipped.
  • Requires k8s = true in pinner.toml.

cargo

Default: on. Preferred upgrade tool: crates.io HTTP API.

Pin

Floating deps in Cargo.toml. Lock → map → crates.io (or cargo CLI if used). Exact semver in manifests. Path/git deps skipped for registry resolve.

Upgrade

crates.io HTTP for latest matching version; optional cargo search / cargo info if present; PINNER_CARGO_RESOLVE_MAP. Pin style: exact semver in Cargo.toml.

Check

Drift vs pinner.lock.json.

Gaps

  • Path and git dependencies are not upgraded via crates.io.
  • Workspace members follow crate extract rules (not every path dep).

go

Default: on. Preferred upgrade tool: go list -m -u (when go is present).

Pin

Module requirements in go.mod. Lock → map → go list / proxy.golang.org. Exact module versions.

Upgrade

Prefers go list -m -u; falls back to proxy.golang.org HTTP and PINNER_GO_RESOLVE_MAP. Pin style: exact module version.

Check

Drift vs pinner.lock.json.

Gaps

  • Replace / exclude directives may limit what can be upgraded.
  • Offline without lock/map fails closed.

ruby

Default: on. Preferred upgrade tool: RubyGems HTTP (Bundler not required).

Pin

Floating gems in Gemfile / related manifests. Lock → map → RubyGems HTTP (gem optional). Exact gem versions.

Upgrade

RubyGems HTTP latest; optional gem; PINNER_RUBY_RESOLVE_MAP. Pin style: exact gem version.

Check

Drift vs pinner.lock.json.

Gaps

  • Path / git gems are not registry-upgraded.
  • Bundler lock is not the preferred upgrade evidence path.

gitlab

Default: off (opt-in: gitlab = true). Preferred upgrade tool: docker digests + git ls-remote.

Pin

GitLab CI includes and related image refs when enabled. Lock → map → digests / git ls-remote. Digest / SHA pin style.

Upgrade

Docker digests for images; git ls-remote for include refs (HEAD/default). Map fallback. Pin style: digest / SHA.

Check

Drift vs pinner.lock.json when gitlab enabled.

Gaps

  • Requires gitlab = true in pinner.toml.
  • Private includes need git auth or maps offline.

azure

Default: off (opt-in: azure = true). Preferred upgrade tool: docker digests for images; Azure Pipelines task versions via resolve map (marketplace HTTP not implemented yet).

Pin

Azure Pipelines YAML: floating task refs and container images when enabled. Lock → PINNER_AZURE_RESOLVE_MAP / docker map → digests for images. Exact task version / image digest.

Upgrade

  • Images: docker digests (docker buildx / map), same pattern as docker/k8s.
  • Tasks: map-only via PINNER_AZURE_RESOLVE_MAP (Name@Name@Major=Name@x.y.z or Name@Major=x.y.z). Marketplace/version HTTP upgrade is not implemented yet.

Pin style: digest / exact task version.

Check

Drift vs pinner.lock.json when azure enabled.

Gaps

  • Tasks are map-only — no Azure Marketplace HTTP resolve for pin or upgrade until that API path lands; set PINNER_AZURE_RESOLVE_MAP for CI/offline.
  • Requires azure = true in pinner.toml.
  • Image upgrade still needs docker/buildx or docker/azure maps when online evidence is required.

Pinner Design

Date: 2026-08-03
Status: Approved for implementation planning
Summary: Rust CLI that pins floating tool and dependency versions across a repository, emits a unified lockfile, and gates CI on drift—so builds rest on an idempotent dependency graph.

Problem

Repositories accumulate floating version references: latest in .mise.toml, semver ranges in package.json, unpinned Python deps, FROM python:latest, and GitHub Actions on @v4 or @main. Those references resolve differently over time and across machines, so artifacts are not reproducible.

Pinner makes the dependency graph explicit and checkable: rewrite sources to exact pins, commit a single lock that mirrors the graph, and fail CI when reality drifts.

Goals

  1. Rewrite source manifests so floating refs become exact pins.
  2. Lock the full cross-ecosystem graph in one committed artifact (pinner.lock.json).
  3. Check in CI that the working tree and resolved graph match the lock (drift detection).
  4. Prefer evidence from existing native locks before hitting the network.
  5. Policy-driven pin style per ecosystem (exact versions, digests, commit SHAs).
  6. Toolchain bootstrap to detect, optionally install, and test against resolver binaries (mise, node/npm, uv, docker, gh).

Non-goals (v1)

  • Replacing Renovate/Dependabot (no auto-PR bot).
  • Cryptographic signing or attestations beyond a stable lock document.
  • Guaranteeing bit-identical final binaries by itself (that remains the build system’s job atop a frozen graph).
  • Private registry auth UX beyond passing through env credentials to underlying tools.

Success criteria

  • Same committed lock + same inputs ⇒ pinner check is clean across machines/CI.
  • pinner pin is idempotent: a second run produces no further source or lock diff.
  • Floating refs in supported ecosystems are either pinned or reported as failures under check/audit.
  • Bit-identical release artifacts are a downstream goal enabled by the freeze, not Pinner’s sole responsibility.

Architecture

Approach: shared core + ecosystem plugin crates, plus a toolchain crate for optional install and integration testing.

pinner (CLI)
    ├── pinner-core          # policy, lock I/O, orchestration
    ├── pinner-toolchain     # detect / ensure / status for resolver binaries
    ├── pinner-ecosystem     # shared trait + types
    ├── pinner-mise
    ├── pinner-node
    ├── pinner-python
    ├── pinner-docker
    └── pinner-actions

Ecosystem trait

Each ecosystem implements:

MethodRole
discover(repo)Find manifests
extract(manifest)Floating / unpinned refs
resolve(refs, evidence, policy, tools)Concrete pins (lock → native evidence → network/tool)
rewrite(manifest, pins)Structured patch

Shared types live in pinner-ecosystem (Finding, Pin, Rewrite, ResolveSource).

Toolchain bootstrap

  • pinner toolchain status — required tools vs available, per enabled ecosystem.
  • pinner toolchain ensure — install missing tools (default: via mise) when allowed; respect offline/CI flags.
  • Integration tests call ensure or skip with a clear message when install/network is disabled.
  • Runtime resolution prefers committed lock / native lock evidence so tools are not hard-required for every check when the lock is already complete.

CLI

CommandBehavior
pinner pinDiscover → resolve → rewrite sources to exact pins → regenerate pinner.lock.json
pinner checkNon-zero exit on drift vs committed lock; no writes
pinner auditReport floating refs (text/JSON); --fix applies pin for reported findings
pinner toolchain statusShow tool availability
pinner toolchain ensureOptionally install missing tools
pinner explain <path|pkg>Why a pin was chosen (evidence vs registry, policy)

Common flags: --dry-run, --offline, --ecosystem <list>, --config <path>, --format text|json.

Exit codes: 0 success; 1 drift/findings; 2 tool/config/resolution error.

Lock format

Single repo-root file: pinner.lock.json (committed). Versioned schema, stable key ordering.

Conceptual entry shape:

  • ecosystem, name, requested, pinned
  • source / path (manifest location)
  • evidence: lock | native_lock | registry | tool
  • metadata (e.g. digest vs tag, action commit vs moving tag)

Resolution order per finding:

  1. Existing valid pinner.lock.json entry for that request
  2. Native lock evidence (package-lock.json, uv.lock, etc.)
  3. Policy + network/tool resolve
  4. Fail closed if unresolved under --offline or strict policy

Rewrite mode pins sources; the unified lock mirrors the result (sources are mutated; lock is regenerated from the pinned graph).

Policy and config

  • Defaults in the binary so zero-config works for common cases.
  • Optional pinner.toml for overrides: enable/disable ecosystems, ignore globs, toolchain install preferences, per-ecosystem pin style, rare allowlisted floating refs.

Default pin styles:

EcosystemDefault pin
miseExact tool version
node / pythonExact version (no ranges)
dockerImage digest
actionsCommit SHA (human tag retained in metadata/comments when useful)

Ecosystems (v1)

EcosystemDiscoverFloating signalsRewritePrefer evidence from
mise.mise.toml, .tool-versionslatest, lts, missing version, rangesExact versions in those filesmise lock / installed list; else mise
Nodepackage.json (+ workspaces)latest, *, ^/~ when policy says pin-exactExact versions in package.jsonpackage-lock.json / pnpm-lock.yaml / yarn.lock
Pythonpyproject.toml, requirements*.txtunpinned, >=, *Exact pins (== / uv-compatible)uv.lock / poetry.lock / pdm.lock
DockerDockerfile*, compose image lines:latest, untagged, floating tagsDigest-pinned refsLocal inspect; else registry
Actions.github/workflows/*, action.yml@main / @master / floating @vN@<commitsha>Prior lock; else GitHub API / gh

Shared rules:

  • Honor ignore globs; skip vendored paths.
  • Never invent a pin without evidence or successful resolve.
  • Use structured parsers/patches (TOML/JSON/YAML-aware), not blind regex rewrites.
  • Fixture-based unit tests per ecosystem; integration tests under tests/fixtures/.

Errors

  • Resolution failure → exit 2 with ecosystem, path, requested ref, and actionable hint.
  • No silent partial success for targeted ecosystems unless --continue-on-error (audit-only).
  • Malformed/untrusted lock → reject; do not half-apply rewrites.

Testing

  • Unit: parsers, policy merge, lock round-trip, patch apply (no network).
  • Fixtures: sample repos per ecosystem under tests/fixtures/.
  • Integration: toolchain ensure then pin / check; network tests gated (PINNER_NETWORK=1).
  • Idempotency: pin twice ⇒ empty second diff; check clean after pin.
  • Toolchain: missing-binary scenarios → ensure installs, or skip when install disabled.

CI (consumer repos)

- run: pinner toolchain ensure
- run: pinner check

Optional: pinner audit --format json for annotations. A clean check is the freeze gate; artifact bit-stability remains the build system’s responsibility.

Implementation phasing (for planning)

Phasing does not shrink v1 scope; it orders delivery so the core is testable early:

  1. Workspace scaffold, pinner-ecosystem trait, lock schema, policy defaults, toolchain status/ensure.
  2. pinner-mise end-to-end (pin / check / lock) as the reference ecosystem.
  3. Node, Python, Docker, Actions crates behind the same trait.
  4. audit / explain, CI examples, fixture matrix, idempotency tests.

Concrete packaging defaults

  • Cargo workspace crate names match the architecture diagram above; binary crate is pinner.
  • Lock JSON Schema lives at schemas/pinner.lock.schema.json and is validated in this repo’s CI on lock fixtures.
  • Evidence parsers are best-effort across lockfile format versions; CI integration tests pin and exercise current stable mise, node/npm, and uv via pinner toolchain ensure.

IaC Version Pinning Design

Date: 2026-08-03
Status: Approved for implementation planning
Summary: Extend Pinner with Terraform, Helm, and Kubernetes ecosystems that discover floating infrastructure-as-code version refs, resolve them to strongest pins, rewrite sources, and record them in pinner.lock.json.

Problem

Infrastructure repos accumulate floating refs: Terraform module constraints (~> 3.0), git module ref=main, unpinned providers, Helm chart * / missing versions in Chart.yaml and GitOps CRDs, and Kubernetes workload images on :latest or mutable tags. Those resolve differently over time, so apply/deploy is not reproducible.

Pinner already freezes mise, Node, Python, Docker, and GitHub Actions. This feature brings the same discover → resolve → rewrite → lock → check loop to common IaC surfaces.

Goals

  1. Pin remote Terraform modules and provider version constraints to exact versions (registry) or full commit SHAs (git/HTTP).
  2. Pin Helm chart dependencies and GitOps chart refs (HelmRelease, Application) to exact chart versions.
  3. Pin Kubernetes workload container images (core kinds only) to digests.
  4. Emit/update unified pinner.lock.json entries for the new ecosystems.
  5. Keep pinner check offline-first against the committed lock.
  6. Follow the existing ecosystem plugin trait and CLI (no new top-level commands).

Non-goals

  • Local Terraform module paths (./, ../).
  • Terraform/OpenTofu CLI required_version (mise already covers tool versions).
  • Image pins inside Helm values.yaml (owned by the Kubernetes ecosystem when those images appear in workload manifests). Superseded (2026-08-04 expansion): Helm must pin floating images in values.yaml / values*.yaml (see expansion design/plan Task 10). Workload manifests remain owned by the k8s ecosystem.
  • Scanning arbitrary CRDs for embedded images beyond the five core workload kinds.
  • Replacing Renovate/Dependabot or opening upgrade PRs.
  • Private registry auth UX beyond passing through existing env credentials (same as Docker/Actions today).

Decisions (from design review)

TopicChoice
ScopeTerraform + Helm + K8s (phased delivery)
Terraform surfaceRemote modules + required_providers
Pin styleStrongest freeze
Helm surfaceChart.yaml deps + HelmRelease / Application
K8s surfaceDeployment, StatefulSet, DaemonSet, Job, CronJob
DefaultsTerraform on; Helm and K8s opt-in
PackagingThree ecosystem crates + shared pinner-iac-common

Architecture

pinner (CLI)
    ├── pinner-core              # policy, lock I/O, orchestration (unchanged flow)
    ├── pinner-ecosystem         # + EcosystemKind::{Terraform, Helm, K8s}
    ├── pinner-iac-common        # git SHA resolve, registry/OCI HTTP helpers, image digest seams
    ├── pinner-terraform         # default enabled
    ├── pinner-helm              # opt-in
    ├── pinner-k8s               # opt-in
    └── existing mise/node/python/docker/actions…

Each new crate implements the existing Ecosystem trait:

MethodRole
discover(repo)Find manifests
extract(manifest)Floating / unpinned refs
resolve(findings, ctx)Concrete pins (lock → native evidence → network/tool)
rewrite(manifest, pins)Structured patch

Shared helpers (pinner-iac-common):

  • Git ref → full commit SHA (same idea as Actions / gh)
  • Terraform module/provider registry HTTP
  • Helm repo index / OCI chart version lookup
  • Image tag → digest (reuse Docker resolve patterns; K8s must not duplicate registry logic)

Kind split: three EcosystemKind values so --ecosystem, policy toggles, and lock entries stay independent. Helm does not pin container images; that is exclusively pinner-k8s.

Delivery order: Terraform → Helm → K8s. Each phase ships pin/check/audit + fixtures before the next starts.

Per-ecosystem behavior

Terraform (pinner-terraform) — default on

StageBehavior
Discover**/*.{tf,tofu} (honor ignore globs)
Extractmodule blocks with remote source (Terraform Registry, git::, github.com, HTTP/HTTPS). Skip local ./ and ../. Also floating required_providers version constraints.
Floating signalsMissing / latest module version; constraints that are not a single exact version (~>, >=, ranges); git ref=main / master / branch / non-SHA tag; unpinned or ranged provider versions
Resolvepinner.lock.json → .terraform.lock.hcl (providers) → registry API / git → fail closed under --offline
RewriteExact version = "x.y.z" for registry modules and providers; git/HTTP sources get full commit SHA in ref= (query or equivalent). Structured HCL parse/patch — not blind regex.
Pin styleRegistry: exact version. Git/HTTP: full commit SHA.

Non-goals (terraform crate): local path modules; CLI required_version; rewriting generated .terraform/ trees.

Helm (pinner-helm) — default off

StageBehavior
Discover**/Chart.yaml; Flux HelmRelease; Argo CD Application (YAML matched by apiVersion / kind)
ExtractChart dependencies (dependencies[].name / version / repository); HelmRelease / Application chart name + version + repo or OCI URL
Floating signalsMissing version, ranges, *, latest
ResolveLock → Helm repo index / OCI tags → exact chart version
RewriteExact semver in Chart.yaml and CRD chart version fields. No values-file image rewriting.

Kubernetes (pinner-k8s) — default off

StageBehavior
DiscoverYAML/YML documents whose kind is Deployment, StatefulSet, DaemonSet, Job, or CronJob
Extractspec.template.spec.containers[].image and initContainers[].image (CronJob via jobTemplate.spec.template.spec)
Floating signals:latest, untagged images, non-digest tags
ResolveLock → shared image digest helper (same path as Docker)
Rewriteimage: name@sha256:…; retain original tag in pin metadata when useful

Policy and config

Optional pinner.toml overrides:

[ecosystems]
terraform = true   # default on
helm = false       # default off
k8s = false        # default off

Existing ignore globs and allowlisted floating refs apply; allowlist entries use the new ecosystem names (terraform, helm, k8s).

CLI filter works as today: --ecosystem terraform,helm,k8s.

Lock format

Extend schemas/pinner.lock.schema.json ecosystem enum with terraform, helm, k8s. Entry shape unchanged (ecosystem, name, requested, pinned, source, path, evidence, metadata).

Example metadata:

EcosystemUseful metadata
terraform modulesource_type: registry | git | http; module_source
terraform providersource_type: provider; registry_source (e.g. hashicorp/aws)
helmrepository, chart
k8stag, kind

Resolution order per finding (unchanged globally):

  1. Valid pinner.lock.json entry
  2. Native lock evidence (.terraform.lock.hcl for providers)
  3. Registry / network / tool resolve
  4. Fail closed if unresolved under --offline or strict policy

Toolchain

EcosystemRequirements
terraformNo terraform binary required for pin. Registry via HTTP. Git sources need git (or equivalent resolve).
helmPrefer in-crate HTTP/OCI client; optional helm CLI fallback later if needed.
k8sPrefer registry HTTP digest resolve; optional docker fallback shared with Docker ecosystem.

pinner toolchain status / ensure gain entries only for tools that become hard requirements. Soft/optional tools stay documented but are not forced installs for check when the lock is complete.

Runtime: pinner check is offline-first. Network resolve runs on pin / audit --fix when lock/native evidence is missing.

Errors

  • Same exit codes: 0 success; 1 drift/findings; 2 tool/config/resolution error.
  • Resolution failure includes ecosystem, path, requested ref, and actionable hint.
  • No silent partial success for selected ecosystems: resolve all, then write rewrites + lock (existing orchestration).
  • Malformed/untrusted lock → reject; do not half-apply rewrites.

Testing

  • Unit: HCL/YAML extract and rewrite fixtures (no network).
  • Resolve seams: env maps analogous to PINNER_DOCKER_RESOLVE_MAP / PINNER_ACTIONS_RESOLVE_MAP for terraform, helm, and k8s.
  • Fixtures: tests/fixtures/terraform-floating/, helm-floating/, k8s-floating/.
  • Idempotency: pin twice ⇒ empty second source/lock diff; check clean after pin.
  • Network: gated with PINNER_NETWORK=1.
  • Policy: default-enabled terraform vs opt-in helm/k8s covered in policy merge tests.

CLI / UX impact

No new commands. Existing:

pinner pin
pinner check
pinner audit
pinner audit --fix
pinner explain <name-or-path>
pinner toolchain status

Users enable Helm/K8s via config (required — --ecosystem only filters already-enabled kinds and does not override opt-in defaults):

[ecosystems]
helm = true
k8s = true

After opt-in, narrowing is allowed: pinner pin --ecosystem helm.

Implementation phasing

  1. Foundation: EcosystemKind + schema + policy defaults/opt-in; scaffold pinner-iac-common and three crates wired into the CLI (helm/k8s no-op until implemented).
  2. Terraform: discover/extract/resolve/rewrite for remote modules + providers; fixtures; idempotency.
  3. Helm: Chart.yaml + HelmRelease/Application; fixtures; idempotency.
  4. Kubernetes: core workload image digests via shared helper; fixtures; idempotency.
  5. Docs / README / consumer CI examples for the new ecosystems.

Success criteria

  • Remote Terraform modules and floating providers are pinned to exact versions or commit SHAs; local modules are ignored.
  • With helm/k8s enabled, floating chart versions and core-workload images are pinned or reported.
  • pinner pin is idempotent for the new ecosystems.
  • pinner check fails on drift without network when the lock is present.
  • Helm/K8s remain off unless opted in; Terraform runs by default and no-ops when no .tf/.tofu files exist.

Upgrade Subcommand Design

Date: 2026-08-05
Status: Approved for implementation planning
Summary: Add an opt-in pinner upgrade command that re-resolves declared dependencies to the newest available versions per ecosystem (via package managers, registries, or other means), then reuses pin’s rewrite → lock → walkthrough/agent path with a side-by-side upgrade TUI.

Problem

pinner pin freezes floating refs and then prefers pinner.lock.json / native locks, so a second pin is idempotent and will not bump already-exact pins. Teams still need a deliberate, reviewable way to move libraries, binaries, images, and modules to the most recent versions across ecosystems — without learning each package manager’s upgrade UX.

Pinner should eat that complexity: one optional subcommand, per-provider upgrade strategies (prefer Rust-based tools like uv where applicable), the same walkthrough/agent contract as pin, and clear documentation of what each provider supports.

Goals

  1. Opt-in upgrade CLI: pinner upgrade — never implied by check or CI; humans/agents must invoke it.
  2. Latest-by-default policy: resolve to the newest available version/digest/SHA appropriate for each ecosystem (including major bumps). Walkthrough accept/skip/edit is the safety valve.
  3. Same write path as pin: rewrite manifests → regenerate pinner.lock.json; honor --dry-run, --ecosystem, --offline, --walkthrough, --agent / --format json.
  4. Side-by-side review: walkthrough shows current pin → proposed upgrade (not only requested → proposed).
  5. Per-provider upgrade means: each ecosystem declares preferred tool(s) and fallback channels (HTTP/registry/git); prefer Rust-based PMs (uv) when choosing among peers.
  6. Documented support matrix: root README.md plus per-provider pages under docs/guide/ecosystems/.
  7. Install docs via mise: document cargo: and github: backends as the supported mise install targets for Pinner itself.

Non-goals

  • Replacing Renovate/Dependabot or opening PRs.
  • Constrained upgrades by default (--semver patch|minor|major may be a later flag; v1 is latest-only).
  • Guaranteeing post-upgrade build/test success.
  • Upgrading path/git/VCS/local module sources (same skips as pin).
  • Changing pinner check semantics (still drift vs committed lock).

Decisions

TopicChoice
Command namepinner upgrade
Default version policyLatest available (majors allowed)
CandidatesAll upgradeable declared deps from extract (floating and exact), minus pin’s existing skips (path/git/VCS/local)
Lock preferenceBypass pinner.lock.json and native locks for upgrade resolve; test seams via PINNER_*_RESOLVE_MAP still win when set
ArchitectureResolveMode::{Pin, Upgrade} on EcosystemCtx + upgrade / upgrade_with_filter in core sharing pin’s rewrite/lock pipeline
OfflineFail closed unless resolve map supplies every candidate (same exit 2 contract)
WalkthroughSame accept/skip/edit/quit; columns show current → proposed
Agent modeSame as pin: JSON, no prompts; --walkthrough + agent/non-TTY → exit 2
DocsREADME matrix + docs/guide/ecosystems/<kind>.md for every EcosystemKind
mise install of PinnerDocument cargo:pinner and github:zloeber/Pinner backends (not a vague mise install pinner)

Approaches considered

A — Separate Upgrade trait method per ecosystem (rejected)

Add fn upgrade_resolve(...) beside resolve. Clear intent, but doubles every crate’s surface and duplicates rewrite/lock orchestration.

B — Fork orchestration only; hack resolve with empty lock (rejected)

Call existing resolve with lock_pins: &[] and somehow ignore native locks. Brittle: many crates still short-circuit on native locks / treat exact requested as already resolved.

C — ResolveMode on context + shared upgrade orchestration (chosen)

Extend EcosystemCtx with resolve_mode. Ecosystems branch at the top of resolve: Upgrade skips freeze evidence and runs “newest” strategies. Core adds upgrade / upgrade_with_filter that select all upgradeable findings and reuse pin’s staging/rewrite/lock/walkthrough hooks.

Why C: Minimal CLI/core duplication, one evidence model, testable mode flag, incremental per-ecosystem enablement.

Architecture

pinner upgrade [--walkthrough|--agent|…]
    → core::upgrade_with_filter
         discover / extract (all findings)
         filter upgradeable (!skipped kinds; respect allow_floating? no — upgrades still apply)
         ecosystem.resolve(findings, ctx with ResolveMode::Upgrade)
         walkthrough filter (optional)
         rewrite + write pinner.lock.json   # same as pin
flowchart TD
  cli[pinner upgrade] --> orch[core upgrade_with_filter]
  orch --> extract[discover + extract all]
  extract --> mode{ResolveMode Upgrade}
  mode --> skipFreeze[Skip pinner.lock and native lock]
  skipFreeze --> maps[PINNER resolve maps if set]
  maps --> tools[PM tool / registry / HTTP / git]
  tools --> pins[Proposed Pins with metadata]
  pins --> wt{walkthrough?}
  wt -->|yes| tui[TUI current to proposed]
  wt -->|no agent| apply[rewrite + lock]
  tui --> apply

EcosystemCtx

#![allow(unused)]
fn main() {
pub enum ResolveMode {
    Pin,
    Upgrade,
}

pub struct EcosystemCtx<'a> {
    pub repo: &'a Path,
    pub lock_pins: &'a [Pin],
    pub offline: bool,
    pub pin_exact_ranges: bool,
    pub resolve_mode: ResolveMode,
}
}
  • Pin mode: unchanged order — lock → native lock → map → tool/registry → fail.
  • Upgrade mode: map (test/offline seam) → tool/registry/HTTP/git “latest” → fail. Do not return existing lock/native versions unless they are already newest (optional short-circuit after fetching latest: if equal, omit from proposed set or include with unchanged metadata — v1 omits unchanged so walkthrough only shows real bumps).

Pin metadata (upgrade)

When proposing a bump, set:

KeyValue
upgradetrue
previousprior exact pin / declared exact / prior digest-or-SHA string shown as “current”
upgrade_channel"tool" | "registry" | "http" | "git" | "map"

requested remains the manifest’s declared constraint/version. pinned is the new target. Walkthrough displays previous → pinned when upgrade is true, else requested → pinned.

Core orchestration

Mirror pin / pin_with_filter:

  • upgrade / upgrade_with_filter in crates/pinner-core/src/orchestrate.rs
  • Candidate set: all extracted findings that are upgradeable (not path/git/VCS/local — ecosystems already omit those in extract; allowlisted floating refs are still upgradeable unless we later add upgrade.ignore)
  • Empty proposed set (everything already latest) → success, no writes (or rewrite lock timestamps only — v1: no writes, report upgraded: 0)
  • Walkthrough abort → exit 0, nothing written

CLI

SurfaceBehavior
pinner upgradeRun upgrade orchestration
Global flagsSame as pin: --dry-run, --offline, --ecosystem, --config, --format, --walkthrough, --agent
Exit codes0 ok/abort-clean/no-op; 1 unused for upgrade success path (no “drift”); 2 tool/config/resolve/invalid mode

check / pin / audit unchanged.

TUI

Extend crates/pinner-ui/src/walkthrough.rs:

  • Column title: current → proposed when any pin has metadata.upgrade == true, else keep requested → proposed.
  • Cell text: previous → pinned for upgrades; requested → pinned otherwise.
  • Optional header subtitle: upgrade review when in upgrade mode (pass a WalkthroughKind or detect via metadata).
  • Keys unchanged: accept / skip / edit / quit.

Toolchain

Extend required_tools for upgrade-capable ecosystems that need CLIs (cargo online resolve may need cargo or HTTP; go may need go; ruby may use HTTP or gem). Prefer:

EcosystemPreferred upgrade toolFallbacks
pythonuvresolve map
nodenpm (npm view); detect pnpm/yarn locks for rewrite context onlyresolve map
misemise (mise latest / ls-remote)resolve map
cargocrates.io HTTP (Rust, no extra PM) or cargo search/cargo info if presentresolve map
gogo list -m -u when go presentproxy.golang.org HTTP; map
rubyRubyGems HTTP (no Bundler required); gem optionalmap
docker / k8s / gitlab images / actions imagesdocker buildx digests for latest tag or retarget digest for same floating tag policy: upgrade retags mutable tags to fresh digest; exact digest pins re-resolve via tag in metadata if present else skipmap
actions (uses)gh api → default branch / latest release SHA per policy: latest release tag’s commit, else default branch HEADmap
terraformregistry HTTP latest matching unconstrained latest version (not ~> floor)git ls-remote for git modules; map
helmrepo/OCI latest chart versionmap
azure tasksmarketplace/version API or map-only until HTTP existsmap
gitlab includesgit ls-remote HEAD/defaultmap

Exact command strings are owned by each crate and listed in per-provider docs.

Per-provider support matrix (normative)

Document this table on the root README and expand each row in docs/guide/ecosystems/<kind>.md.

ProviderDefaultPreferred upgrade meansAlso supportedUpgrade pin styleNotes
miseonmise latest / mise ls-remotePINNER_MISE_RESOLVE_MAPexact tool versionNested mise configs included
nodeonnpm view <pkg> versionnative lock ignored in upgrade; mapexact version in package.jsonWorkspaces supported
pythononuv pip compile / uv resolvepoetry/pdm locks ignored in upgrade; mapexact ==Prefer uv over pip/poetry CLI
dockerondocker buildx imagetools inspectlocal inspect; mapname@sha256:…Upgrades digest for image reference
actionsongh api (latest release → SHA)docker digests for workflow images; map@<sha> + image digestsReusable/composite uses included
terraformonregistry HTTP latest versiongit ls-remote; .terraform.lock.hcl ignored in upgrade; mapexact version / full SHALocal modules skipped
helmopt-inHTTP index / OCI tags latestdocker digests for values images; mapexact chart ver / image digestRequires helm = true
k8sopt-indocker digestsmapimage digestWorkload kinds only
cargooncrates.io HTTP APIcargo CLI if used; mapexact semver in Cargo.tomlPath/git deps skipped
goongo list -m -uproxy.golang.org; mapexact module version
rubyonRubyGems HTTPgem optional; mapexact gem version
gitlabopt-indocker digests + git ls-remotemapdigest / SHAOpt-in in pinner.toml
azureopt-indocker digests; task versions via map/HTTPmapdigest / exact task verOpt-in

Support levels (for docs)

Each provider page must state:

  1. Pin — freeze floating → exact (existing).
  2. Upgrade — bump to latest (this feature): tools + fallbacks.
  3. Check — drift vs pinner.lock.json (unchanged).
  4. Gaps — what is not upgraded (path deps, etc.).

Install via mise (Pinner itself)

README Install section must replace vague mise install pinner with explicit backends:

# GitHub release backend (prebuilt)
mise use -g github:zloeber/Pinner

# Cargo backend (build from crates.io / git as configured)
mise use -g cargo:pinner

Document that backend availability depends on mise version and published package name; curl installer and cargo install remain first-class.

Config

Optional future [upgrade] table is out of scope for v1. No new required pinner.toml keys. Ecosystems still gated by existing [ecosystems] enable flags; --ecosystem filters enabled kinds only.

Errors and testing

  • Resolution failure → exit 2 with ecosystem, name, requested, hint (same as pin).
  • --offline without maps → fail closed.
  • Unit tests: ResolveMode::Upgrade bypasses lock fixtures; use resolve maps.
  • Integration: fixture with exact pins → upgrade proposes newer map/network versions → walkthrough skip leaves file unchanged; accept rewrites.
  • CLI smoke: upgrade --agent, upgrade --walkthrough --agent → exit 2.
  • Idempotency: upgrade when already latest → upgraded: 0, no diff.

Documentation deliverables

ArtifactChange
README.mdUpgrade quick-start; full provider×PM matrix; mise cargo: / github: install
docs/guide/quick-start.mdAdd pinner upgrade (+ walkthrough)
docs/guide/configuration.mdNote upgrade uses same globals; no new config keys in v1
docs/guide/ecosystems/*.mdOne page per provider (13)
docs/SUMMARY.mdLink ecosystem pages + this spec
skills/pinner/SKILL.mdAgent playbook: upgrade vs pin

Success criteria

  • pinner upgrade bumps exact pins to newer versions when evidence exists.
  • Walkthrough shows current → proposed and can skip/edit per row.
  • --agent upgrade emits JSON and writes only when not dry-run.
  • README matrix and all 13 provider pages list preferred PM/channel.
  • mise install docs mention only github:zloeber/Pinner and cargo:pinner as mise backends.
  • scripts/ci-local green after implementation.

Implementation plan

See docs/superpowers/plans/2026-08-05-upgrade-subcommand.md.