Pinner
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
| Provider | Default | Preferred upgrade means | Also supported | Upgrade pin style | Notes |
|---|---|---|---|---|---|
| mise | on | mise latest / mise ls-remote | PINNER_MISE_RESOLVE_MAP | exact tool version | Nested mise configs included |
| node | on | npm view <pkg> version | native lock ignored in upgrade; map | exact version in package.json | Workspaces supported |
| python | on | uv pip compile / uv resolve | poetry/pdm locks ignored in upgrade; map | exact == | Prefer uv over pip/poetry CLI |
| docker | on | docker buildx imagetools inspect | local inspect; map | name@sha256:… | Upgrades digest for image reference |
| actions | on | gh api (latest release → SHA) | docker digests for workflow images; map | @<sha> + image digests | Reusable/composite uses included |
| terraform | on | registry HTTP latest version | git ls-remote; .terraform.lock.hcl ignored in upgrade; map | exact version / full SHA | Local modules skipped |
| helm | opt-in | HTTP index / OCI tags latest | docker digests for values images; map | exact chart ver / image digest | Requires helm = true |
| k8s | opt-in | docker digests | map | image digest | Workload kinds only |
| cargo | on | crates.io HTTP API | cargo CLI if used; map | exact semver in Cargo.toml | Path/git deps skipped |
| go | on | go list -m -u | proxy.golang.org; map | exact module version | |
| ruby | on | RubyGems HTTP | gem optional; map | exact gem version | |
| gitlab | opt-in | docker digests + git ls-remote | map | digest / SHA | Opt-in in pinner.toml |
| azure | opt-in | docker digests; task versions via map/HTTP | map | digest / exact task ver | Opt-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)
| Ecosystem | Default | Pin style | Sources |
|---|---|---|---|
| terraform | on | exact semver for registry modules/providers; git/HTTP module sources → full commit SHA in ?ref= | *.tf / *.tofu — remote module blocks and required_providers |
| helm | off (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 |
| k8s | off (opt-in) | container images → name@sha256:… | YAML workloads: Deployment, StatefulSet, DaemonSet, Job, CronJob |
What is skipped
- Terraform: local module sources (
./,../, absolute paths); CLIrequired_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.hclprovider selections when present. - Terraform git modules: when online,
git ls-remotecan resolve a floatingrefto a full SHA (rewritten as?ref=<full-sha>). - Terraform registry modules/providers: when online (and map/lock miss), HTTP GET against
registry.terraform.ioversion APIs selects the latest matching version. Offline still requires a lock entry orPINNER_TERRAFORM_RESOLVE_MAP. - Helm charts: when online (and map miss), HTTP repo
index.yaml(and OCI tags list) can resolve chart versions. PreferPINNER_HELM_RESOLVE_MAPfor 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:
| Tool | Purpose |
|---|---|
rust 1.96.0 (+ rustfmt, clippy) | Build and test Pinner |
node / npm | Node ecosystem resolver evidence |
uv | Python ecosystem resolver evidence |
gh | GitHub Actions resolve |
task | Task runner |
docker | Host-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
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml | push / PR | lint (fmt + clippy -D warnings) and test (workspace + schema) |
docs.yml | push to main (docs paths) | Build mdBook and deploy GitHub Pages |
semantic-release.yml | push to main | Conventional commits → annotated tag vX.Y.Z (needs PAT_TOKEN) |
release.yml | tag 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 exits2.- 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)
- Merge PRs to
mainusing conventional commit subjects:feat:→ minor bumpfix:/refactor:/perf:→ patch bumpfeat!:or commit body containingBREAKING CHANGE→ major bump- Docs/chore-only commits do not create a release
.github/workflows/semantic-release.ymlruns onmain, computes the next version, commits achore(release): vX.Y.Zbump of[workspace.package].version+Cargo.lock, and pushes an annotated tag on that commit..github/workflows/release.ymlruns on that tag, aligns workspace version from the tag for the build (idempotent when already bumped), runscargo 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.
- Create a classic PAT (
ghp_…) withrepo/ contents write forzloeber/Pinner. Fine-grained tokens work only if they have Contents: Read and Write on this repo. - From the repo root, sync via SecretZero using
Secretfile.yml(mapsrelease_token→ Actions secretPAT_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
- Store
PAT_TOKENas a repository secret (SecretZero does this). The semantic-release job uses theproductionenvironment; 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 examplev1.0.0)- Pre-release suffix:
vMAJOR.MINOR.PATCH-SUFFIX(for examplev1.0.0-rc.1)
What the release workflow builds
| Target | Runner |
|---|---|
x86_64-unknown-linux-gnu | ubuntu-latest |
aarch64-unknown-linux-gnu | ubuntu-24.04-arm |
x86_64-apple-darwin | macos-15-intel |
aarch64-apple-darwin | macos-latest |
x86_64-pc-windows-msvc | windows-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:
| Variable | Default | Purpose |
|---|---|---|
PINNER_VERSION | latest GitHub Release | Pin a semver (without v) |
PINNER_INSTALL_DIR | $HOME/.local/bin | Install destination |
PINNER_INSTALL_DRY_RUN | unset | Set to 1 to print URL and path only |
PINNER_REPO | zloeber/Pinner | GitHub 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 --checkandclippy -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.
| Provider | Default | Preferred upgrade tool |
|---|---|---|
| mise | on | mise latest / mise ls-remote |
| node | on | npm view |
| python | on | uv |
| docker | on | docker buildx imagetools inspect |
| actions | on | gh api |
| terraform | on | registry HTTP |
| helm | opt-in | HTTP index / OCI |
| k8s | opt-in | docker digests |
| cargo | on | crates.io HTTP |
| go | on | go list -m -u |
| ruby | on | RubyGems HTTP |
| gitlab | opt-in | docker digests + git ls-remote |
| azure | opt-in | docker 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
ghauth; 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_versionnot 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 = trueinpinner.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 = trueinpinner.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 = trueinpinner.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.zorName@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_MAPfor CI/offline. - Requires
azure = trueinpinner.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
- Rewrite source manifests so floating refs become exact pins.
- Lock the full cross-ecosystem graph in one committed artifact (
pinner.lock.json). - Check in CI that the working tree and resolved graph match the lock (drift detection).
- Prefer evidence from existing native locks before hitting the network.
- Policy-driven pin style per ecosystem (exact versions, digests, commit SHAs).
- 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 checkis clean across machines/CI. pinner pinis 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:
| Method | Role |
|---|---|
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
ensureor 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
checkwhen the lock is already complete.
CLI
| Command | Behavior |
|---|---|
pinner pin | Discover → resolve → rewrite sources to exact pins → regenerate pinner.lock.json |
pinner check | Non-zero exit on drift vs committed lock; no writes |
pinner audit | Report floating refs (text/JSON); --fix applies pin for reported findings |
pinner toolchain status | Show tool availability |
pinner toolchain ensure | Optionally 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,pinnedsource/path(manifest location)evidence:lock|native_lock|registry|toolmetadata(e.g. digest vs tag, action commit vs moving tag)
Resolution order per finding:
- Existing valid
pinner.lock.jsonentry for that request - Native lock evidence (
package-lock.json,uv.lock, etc.) - Policy + network/tool resolve
- Fail closed if unresolved under
--offlineor 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.tomlfor overrides: enable/disable ecosystems, ignore globs, toolchain install preferences, per-ecosystem pin style, rare allowlisted floating refs.
Default pin styles:
| Ecosystem | Default pin |
|---|---|
| mise | Exact tool version |
| node / python | Exact version (no ranges) |
| docker | Image digest |
| actions | Commit SHA (human tag retained in metadata/comments when useful) |
Ecosystems (v1)
| Ecosystem | Discover | Floating signals | Rewrite | Prefer evidence from |
|---|---|---|---|---|
| mise | .mise.toml, .tool-versions | latest, lts, missing version, ranges | Exact versions in those files | mise lock / installed list; else mise |
| Node | package.json (+ workspaces) | latest, *, ^/~ when policy says pin-exact | Exact versions in package.json | package-lock.json / pnpm-lock.yaml / yarn.lock |
| Python | pyproject.toml, requirements*.txt | unpinned, >=, * | Exact pins (== / uv-compatible) | uv.lock / poetry.lock / pdm.lock |
| Docker | Dockerfile*, compose image lines | :latest, untagged, floating tags | Digest-pinned refs | Local 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
2with 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 ensurethenpin/check; network tests gated (PINNER_NETWORK=1). - Idempotency:
pintwice ⇒ empty second diff;checkclean afterpin. - 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:
- Workspace scaffold,
pinner-ecosystemtrait, lock schema, policy defaults, toolchain status/ensure. pinner-miseend-to-end (pin / check / lock) as the reference ecosystem.- Node, Python, Docker, Actions crates behind the same trait.
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.jsonand 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
- Pin remote Terraform modules and provider version constraints to exact versions (registry) or full commit SHAs (git/HTTP).
- Pin Helm chart dependencies and GitOps chart refs (
HelmRelease,Application) to exact chart versions. - Pin Kubernetes workload container images (core kinds only) to digests.
- Emit/update unified
pinner.lock.jsonentries for the new ecosystems. - Keep
pinner checkoffline-first against the committed lock. - 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 HelmSuperseded (2026-08-04 expansion): Helm must pin floating images invalues.yaml(owned by the Kubernetes ecosystem when those images appear in workload manifests).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)
| Topic | Choice |
|---|---|
| Scope | Terraform + Helm + K8s (phased delivery) |
| Terraform surface | Remote modules + required_providers |
| Pin style | Strongest freeze |
| Helm surface | Chart.yaml deps + HelmRelease / Application |
| K8s surface | Deployment, StatefulSet, DaemonSet, Job, CronJob |
| Defaults | Terraform on; Helm and K8s opt-in |
| Packaging | Three 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:
| Method | Role |
|---|---|
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
| Stage | Behavior |
|---|---|
| Discover | **/*.{tf,tofu} (honor ignore globs) |
| Extract | module blocks with remote source (Terraform Registry, git::, github.com, HTTP/HTTPS). Skip local ./ and ../. Also floating required_providers version constraints. |
| Floating signals | Missing / 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 |
| Resolve | pinner.lock.json → .terraform.lock.hcl (providers) → registry API / git → fail closed under --offline |
| Rewrite | Exact 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 style | Registry: 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
| Stage | Behavior |
|---|---|
| Discover | **/Chart.yaml; Flux HelmRelease; Argo CD Application (YAML matched by apiVersion / kind) |
| Extract | Chart dependencies (dependencies[].name / version / repository); HelmRelease / Application chart name + version + repo or OCI URL |
| Floating signals | Missing version, ranges, *, latest |
| Resolve | Lock → Helm repo index / OCI tags → exact chart version |
| Rewrite | Exact semver in Chart.yaml and CRD chart version fields. No values-file image rewriting. |
Kubernetes (pinner-k8s) — default off
| Stage | Behavior |
|---|---|
| Discover | YAML/YML documents whose kind is Deployment, StatefulSet, DaemonSet, Job, or CronJob |
| Extract | spec.template.spec.containers[].image and initContainers[].image (CronJob via jobTemplate.spec.template.spec) |
| Floating signals | :latest, untagged images, non-digest tags |
| Resolve | Lock → shared image digest helper (same path as Docker) |
| Rewrite | image: 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:
| Ecosystem | Useful metadata |
|---|---|
| terraform module | source_type: registry | git | http; module_source |
| terraform provider | source_type: provider; registry_source (e.g. hashicorp/aws) |
| helm | repository, chart |
| k8s | tag, kind |
Resolution order per finding (unchanged globally):
- Valid
pinner.lock.jsonentry - Native lock evidence (
.terraform.lock.hclfor providers) - Registry / network / tool resolve
- Fail closed if unresolved under
--offlineor strict policy
Toolchain
| Ecosystem | Requirements |
|---|---|
| terraform | No terraform binary required for pin. Registry via HTTP. Git sources need git (or equivalent resolve). |
| helm | Prefer in-crate HTTP/OCI client; optional helm CLI fallback later if needed. |
| k8s | Prefer 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:
0success;1drift/findings;2tool/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_MAPfor terraform, helm, and k8s. - Fixtures:
tests/fixtures/terraform-floating/,helm-floating/,k8s-floating/. - Idempotency:
pintwice ⇒ empty second source/lock diff;checkclean afterpin. - 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
- Foundation:
EcosystemKind+ schema + policy defaults/opt-in; scaffoldpinner-iac-commonand three crates wired into the CLI (helm/k8s no-op until implemented). - Terraform: discover/extract/resolve/rewrite for remote modules + providers; fixtures; idempotency.
- Helm: Chart.yaml + HelmRelease/Application; fixtures; idempotency.
- Kubernetes: core workload image digests via shared helper; fixtures; idempotency.
- 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 pinis idempotent for the new ecosystems.pinner checkfails 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/.tofufiles 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
- Opt-in upgrade CLI:
pinner upgrade— never implied bycheckor CI; humans/agents must invoke it. - 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.
- Same write path as pin: rewrite manifests → regenerate
pinner.lock.json; honor--dry-run,--ecosystem,--offline,--walkthrough,--agent/--format json. - Side-by-side review: walkthrough shows current pin → proposed upgrade (not only requested → proposed).
- 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. - Documented support matrix: root
README.mdplus per-provider pages underdocs/guide/ecosystems/. - Install docs via mise: document
cargo:andgithub: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|majormay 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 checksemantics (still drift vs committed lock).
Decisions
| Topic | Choice |
|---|---|
| Command name | pinner upgrade |
| Default version policy | Latest available (majors allowed) |
| Candidates | All upgradeable declared deps from extract (floating and exact), minus pin’s existing skips (path/git/VCS/local) |
| Lock preference | Bypass pinner.lock.json and native locks for upgrade resolve; test seams via PINNER_*_RESOLVE_MAP still win when set |
| Architecture | ResolveMode::{Pin, Upgrade} on EcosystemCtx + upgrade / upgrade_with_filter in core sharing pin’s rewrite/lock pipeline |
| Offline | Fail closed unless resolve map supplies every candidate (same exit 2 contract) |
| Walkthrough | Same accept/skip/edit/quit; columns show current → proposed |
| Agent mode | Same as pin: JSON, no prompts; --walkthrough + agent/non-TTY → exit 2 |
| Docs | README matrix + docs/guide/ecosystems/<kind>.md for every EcosystemKind |
| mise install of Pinner | Document 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
unchangedmetadata — v1 omits unchanged so walkthrough only shows real bumps).
Pin metadata (upgrade)
When proposing a bump, set:
| Key | Value |
|---|---|
upgrade | true |
previous | prior 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_filterincrates/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
| Surface | Behavior |
|---|---|
pinner upgrade | Run upgrade orchestration |
| Global flags | Same as pin: --dry-run, --offline, --ecosystem, --config, --format, --walkthrough, --agent |
| Exit codes | 0 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 → proposedwhen any pin hasmetadata.upgrade == true, else keeprequested → proposed. - Cell text:
previous → pinnedfor upgrades;requested → pinnedotherwise. - Optional header subtitle:
upgrade reviewwhen in upgrade mode (pass aWalkthroughKindor 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:
| Ecosystem | Preferred upgrade tool | Fallbacks |
|---|---|---|
| python | uv | resolve map |
| node | npm (npm view); detect pnpm/yarn locks for rewrite context only | resolve map |
| mise | mise (mise latest / ls-remote) | resolve map |
| cargo | crates.io HTTP (Rust, no extra PM) or cargo search/cargo info if present | resolve map |
| go | go list -m -u when go present | proxy.golang.org HTTP; map |
| ruby | RubyGems HTTP (no Bundler required); gem optional | map |
| docker / k8s / gitlab images / actions images | docker 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 skip | map |
| actions (uses) | gh api → default branch / latest release SHA per policy: latest release tag’s commit, else default branch HEAD | map |
| terraform | registry HTTP latest matching unconstrained latest version (not ~> floor) | git ls-remote for git modules; map |
| helm | repo/OCI latest chart version | map |
| azure tasks | marketplace/version API or map-only until HTTP exists | map |
| gitlab includes | git ls-remote HEAD/default | map |
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.
| Provider | Default | Preferred upgrade means | Also supported | Upgrade pin style | Notes |
|---|---|---|---|---|---|
| mise | on | mise latest / mise ls-remote | PINNER_MISE_RESOLVE_MAP | exact tool version | Nested mise configs included |
| node | on | npm view <pkg> version | native lock ignored in upgrade; map | exact version in package.json | Workspaces supported |
| python | on | uv pip compile / uv resolve | poetry/pdm locks ignored in upgrade; map | exact == | Prefer uv over pip/poetry CLI |
| docker | on | docker buildx imagetools inspect | local inspect; map | name@sha256:… | Upgrades digest for image reference |
| actions | on | gh api (latest release → SHA) | docker digests for workflow images; map | @<sha> + image digests | Reusable/composite uses included |
| terraform | on | registry HTTP latest version | git ls-remote; .terraform.lock.hcl ignored in upgrade; map | exact version / full SHA | Local modules skipped |
| helm | opt-in | HTTP index / OCI tags latest | docker digests for values images; map | exact chart ver / image digest | Requires helm = true |
| k8s | opt-in | docker digests | map | image digest | Workload kinds only |
| cargo | on | crates.io HTTP API | cargo CLI if used; map | exact semver in Cargo.toml | Path/git deps skipped |
| go | on | go list -m -u | proxy.golang.org; map | exact module version | |
| ruby | on | RubyGems HTTP | gem optional; map | exact gem version | |
| gitlab | opt-in | docker digests + git ls-remote | map | digest / SHA | Opt-in in pinner.toml |
| azure | opt-in | docker digests; task versions via map/HTTP | map | digest / exact task ver | Opt-in |
Support levels (for docs)
Each provider page must state:
- Pin — freeze floating → exact (existing).
- Upgrade — bump to latest (this feature): tools + fallbacks.
- Check — drift vs
pinner.lock.json(unchanged). - 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
2with ecosystem, name, requested, hint (same as pin). --offlinewithout maps → fail closed.- Unit tests:
ResolveMode::Upgradebypasses 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→ exit2. - Idempotency: upgrade when already latest →
upgraded: 0, no diff.
Documentation deliverables
| Artifact | Change |
|---|---|
README.md | Upgrade quick-start; full provider×PM matrix; mise cargo: / github: install |
docs/guide/quick-start.md | Add pinner upgrade (+ walkthrough) |
docs/guide/configuration.md | Note upgrade uses same globals; no new config keys in v1 |
docs/guide/ecosystems/*.md | One page per provider (13) |
docs/SUMMARY.md | Link ecosystem pages + this spec |
skills/pinner/SKILL.md | Agent playbook: upgrade vs pin |
Success criteria
pinner upgradebumps exact pins to newer versions when evidence exists.- Walkthrough shows current → proposed and can skip/edit per row.
--agentupgrade 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/Pinnerandcargo:pinneras mise backends. scripts/ci-localgreen after implementation.
Implementation plan
See docs/superpowers/plans/2026-08-05-upgrade-subcommand.md.