# how to pin dependencies reproducibly

## TL;DR

Commit your lockfiles, install from them with the frozen command (`npm ci`, pip with hashes, `go mod` verify), and pin container base images by digest. Version ranges belong in manifests for flexibility; exact pins belong in lockfiles for reproducibility. Automate updates with Dependabot or Renovate so pinned doesnt mean stale.

```text
how to pin dependencies reproducibly
```

## Use this when

- Builds behave differently on different machines or in CI
- You need reproducible builds for security or compliance
- A floating version broke production on a redeploy with no code changes
- You are setting dependency policy for a team

## Not for this skill when

- You need to decide which version to upgrade to (thats a triage decision)
- You want vulnerability scanning (different skill, though pinning enables it)
- You are vendoring all source into the repo (heavier strategy)

## Steps

### 1. Commit every lockfile and stop gitignoring them

The lockfile is the build contract. If it isnt committed, every install resolves versions fresh and reproducibility is fiction.

```bash
git ls-files | grep -E "package-lock.json|poetry.lock|go.sum|Gemfile.lock|Cargo.lock|pnpm-lock.yaml" | head -20
```

Expected: your lockfiles listed as tracked. If a lockfile is gitignored, remove the ignore rule and commit it; application repos always commit lockfiles, libraries may differ, but services are applications.

### 2. Install from the lockfile with the frozen command

The install command must fail if the lockfile is out of sync with the manifest, not silently resolve something new. Thats what makes CI honest.

```bash
npm ci
```

Expected: install completes using exactly the locked versions, and fails loudly if package.json and package-lock.json disagree. Equivalents: `pip install --require-hashes -r requirements.txt`, `go mod verify` plus building with the go.sum present, `bundle install --frozen`, `cargo build --locked`.

### 3. Pin container base images by digest

Tags move; digests dont. A rebuild from `node:20` next month is a different image than today, which silently changes your dependency tree under you.

```bash
grep -i "^FROM" Dockerfile
```

Expected: FROM lines you will convert from tag to digest form: `FROM node:20@sha256:[digest]`. Resolve the digest once (`docker buildx imagetools inspect` or your registry UI), pin it, and let Renovate propose digest updates like any other dependency.

### 4. Lock the CI action and toolchain versions too

Your pipeline's own dependencies float the same way. Pin GitHub Actions to SHAs, pin language versions in CI config, and pin the package manager version.

```bash
grep -rn "uses:" .github/workflows/ | grep -v "[0-9a-f]\{40\}" | head
```

Expected: ideally no output, meaning every action is SHA-pinned. Same idea for `setup-node` version inputs and Docker image tags in CI; every float is a future surprise.

### 5. Automate updates so pinned never means stale

Pinning without updates is how you end up three years behind on a library with known CVEs. Turn on Dependabot or Renovate for lockfiles, digests, and actions, and treat its PRs as first-class work.

```bash
ls .github/dependabot.yml .renovate.json renovate.json; echo "---"; ls .github/workflows/ | head
```

Expected: a bot config present and active. Review and merge its PRs weekly; a pinned dependency with an open update PR is a decision, not neglect.

### Variant: npm ci vs npm install

`npm ci` wipes node_modules and installs exactly the lockfile, failing on mismatch; `npm install` resolves and can update the lockfile. CI and production builds use `npm ci`, always. Local dev can use install, but commit the resulting lockfile change deliberately.

### Variant: pip reproducible installs

Compile with pip-tools (`pip-compile` generating hashes) and install with `--require-hashes`. Plain requirements.txt with `==` pins is better than nothing but still trusts the index at install time; hashes verify the artifact itself.

### Variant: go modules reproducible builds

`go.sum` plus the module cache gives reproducibility by default; keep `GOFLAGS=-mod=readonly` in CI so builds fail rather than silently updating go.mod. Vendor with `go mod vendor` if you need builds that survive an unreachable module proxy.

## Why this happens

Manifests describe what you want ("lodash ^4") and lockfiles describe what you got ("lodash 4.17.21"). Without the lockfile committed and enforced, every install re-runs resolution against a moving index, so the same repo builds different software on different days. Pinning moves version decisions from accident-time to review-time.

## Edge cases and pitfalls

- Lockfile merge conflicts: regenerate rather than hand-merge; most conflicts resolve by taking one side and re-running the lock command.
- Transitive-only updates: a locked transitive dep with a CVE needs a direct override or a bot PR; you cant `npm install` your way out without touching the lockfile deliberately.
- Digest pinning and multi-arch: pin the manifest-list digest, not a single-arch digest, or arm64 builds break.
- Bot PR fatigue: noisy bots get ignored; configure grouping and schedules so updates arrive in digestible batches.
- Private registries: lockfiles referencing private packages need auth at install time; keep the registry config in CI secrets, not in the repo.
- Lockfile drift in monorepos: every package needs its lockfile enforced; one unpinned workspace reintroduces the whole problem.

## Provenance

Resolved from the public thread: https://vectle.com/posts/pst_mkeUATSgnYa6yKqe92SxeA
