Lockfile Strategies for Generated Clients
This page belongs to SDK Version Pinning in the API Versioning & Deprecation reference. A semver range states an intent; a lockfile records what you actually got. For a generated API client, where a minor release can add methods and change types, the difference between the two determines whether a build is reproducible six months from now.
The decision trigger
| Symptom | Cause |
|---|---|
| CI installs a different SDK version than a developer’s machine | lockfile not committed, or --no-frozen-lockfile in CI |
| A build that worked yesterday fails today with no code change | transitive dependency floated |
| Production ships an SDK nobody reviewed | dependency bot auto-merging majors |
| Two services in a monorepo use different SDK versions | no workspace-level resolution policy |
| A “patch” SDK update broke a call site | upstream version derivation not tied to a spec diff |
| Rolling back the app did not roll back the SDK | lockfile excluded from the release artefact |
Spec snippet: range in the manifest, exact version in the lock
// package.json — declares the intent
{
"name": "billing-dashboard",
"dependencies": {
"@example/billing": "^3.8.0"
}
}
// package-lock.json (abridged) — records the reality, including integrity
{
"packages": {
"node_modules/@example/billing": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@example/billing/-/billing-3.8.2.tgz",
"integrity": "sha512-9Qm…",
"engines": { "node": ">=20" }
}
}
}
The integrity hash is the part that matters most and gets the least attention: it means a republished or tampered tarball fails installation rather than silently entering your build. Combined with the provenance attestation from Publishing Generated SDKs from GitHub Actions, it closes the loop from the workflow that built the package to the build that consumes it.
Step-by-step
Step 1 — Apply the application-versus-library rule
| Project type | Manifest | Lockfile | Rationale |
|---|---|---|---|
| Application / service | range (^3.8.0) |
commit it | reproducible deploys |
| Published library | range, as wide as tested | do not commit | consumers resolve |
| Monorepo workspace | ranges per package | one lock at the root | single resolution |
| Container image build | irrelevant | commit + --frozen install |
image is the artefact |
The library row is the one people get backwards. A library that commits a lockfile still does not impose it on consumers — lockfiles are ignored for transitive dependencies — so committing one gives a false sense of control while hiding the range incompatibilities that consumers will actually hit.
Step 2 — Make CI installs fail rather than resolve
# Every ecosystem has a "use the lock, do not update it" mode. Use it in CI.
npm ci # fails if package.json and the lock disagree
pnpm install --frozen-lockfile
yarn install --immutable
pip install --require-hashes -r requirements.txt # from pip-compile output
poetry install --sync # errors when poetry.lock is stale
uv sync --frozen
go mod download # go.sum is verified automatically
go build -mod=readonly ./... # refuses to modify go.mod
A plain npm install in CI silently updates the lockfile inside the container and then discards it, so the build you tested is not the build you shipped. Using the frozen variants turns “someone edited the manifest and forgot to regenerate” into a fast, obvious failure.
Step 3 — Know what each ecosystem actually pins
| Ecosystem | Lock artefact | Pins transitives | Verifies integrity | Notes |
|---|---|---|---|---|
| npm / pnpm / yarn | package-lock.json, pnpm-lock.yaml, yarn.lock |
yes | yes (integrity) |
npm ci requires the lock |
pip + pip-compile |
requirements.txt with hashes |
yes | yes (--require-hashes) |
plain requirements.txt does not |
| Poetry / uv | poetry.lock, uv.lock |
yes | yes | --sync/--frozen in CI |
| Go modules | go.mod + go.sum |
yes | yes (go.sum) |
MVS: no floating ranges at all |
| Cargo | Cargo.lock |
yes | yes | commit for binaries, not libraries |
Go is the odd one out and the most reliable: minimal version selection means the manifest itself names exact versions, so there is no range to float and go.sum verifies every module hash. If you are choosing a language for a service whose reproducibility matters, that property is worth noticing.
Step 4 — Configure the dependency bot deliberately
# .github/dependabot.yml — group SDK updates, gate majors
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule: { interval: weekly }
groups:
example-sdks:
patterns: ["@example/*"] # one pull request for all our clients
ignore:
- dependency-name: "@example/*"
update-types: ["version-update:semver-major"] # majors are a human decision
labels: ["dependencies", "sdk"]
Grouping matters when an estate publishes several clients from one specification: five separate pull requests for five packages generated from the same API is noise, and reviewing them together is how you notice that one is missing. The major-version exclusion exists because a major bump from a spec-diff-driven pipeline means the client surface genuinely changed — see Detecting Breaking Changes with oasdiff.
Step 5 — Understand what pinning does not buy you
A lockfile is a supply-chain control, not an API-compatibility control. The server keeps evolving whether or not your dependency graph moves, which is why a pinned client still needs to read Deprecation and Sunset headers — see Sunset & Deprecation Headers — and why a client built two years ago must tolerate response values it has never seen.
The install command is where a pinning policy is actually enforced or quietly lost:
Standard compliance
| Concern | Standard | Note |
|---|---|---|
| Version ranges | Semantic Versioning 2.0.0 | ^ allows minor and patch; ~ patch only |
| Python version strings | PEP 440 | ==3.8.2, not v3.8.2 |
| Python hash pinning | PEP 665 / --require-hashes |
Hashes are opt-in for pip |
| Go module selection | Go Modules (MVS) | No ranges; go.sum verifies |
| Package provenance | SLSA build provenance | Verifiable link to the building workflow |
SDK / codegen downstream effect
# Reproducible install of a generated client, verified end to end
- npm install @example/billing # resolves fresh; lockfile may drift
+ npm ci # installs exactly what the lock records
+ npm audit signatures # verifies registry signatures + provenance
The second command is the one that connects consumption back to publication: it checks that the tarball you installed was produced by the workflow the publisher claims, which only works because the SDK release pipeline attaches provenance. Pinning without verification protects you from accidental drift; pinning with verification protects you from a compromised republish as well.
Common mistakes
| Mistake | Correct approach |
|---|---|
npm install in CI instead of npm ci |
Use the frozen-install mode for every ecosystem |
| Committing a lockfile in a published library | Express ranges; let consumers resolve |
| Auto-merging major SDK updates | Gate majors on human review |
| Separate pull requests per generated client | Group them; review the estate together |
Plain requirements.txt without hashes |
Compile with hashes and install --require-hashes |
| Assuming a pin freezes server behaviour | Add contract tests and honour deprecation headers |
| Excluding the lockfile from the release artefact | Ship it so rollbacks restore the same graph |
Ignoring npm audit signatures |
Verify provenance where the publisher provides it |
FAQ
Should an application commit its lockfile for a generated API client?
Yes, without exception. An application’s purpose is to run in production, and you want the version that passed CI to be the version that ships — byte for byte, including transitive dependencies you have never heard of. Without a committed lockfile every environment resolves independently, so a developer, the CI runner and the production image can end up on three different patch releases of the same client, and the one that fails is invariably the one you cannot reproduce. Libraries are the mirror image: they should declare the widest range they have actually tested and let the consuming application’s lockfile decide, because a library’s lockfile is ignored by consumers anyway.
Does pinning the SDK version pin the API version?
No, and conflating the two causes real incidents. A lockfile freezes code on your side of the network. The server keeps deploying: it may add an enum value your client’s closed union does not accept, tighten a validation rule so a payload you have always sent now fails, or pass a sunset date and start returning 410. None of that touches your dependency graph. Pin for reproducibility, and cover the server side separately — contract tests against a running instance, tolerant parsing of unknown enum values, and a client that logs Deprecation and Sunset headers rather than discarding them.
How should dependency bots treat generated SDK updates?
Group them, gate them, and let the publisher’s versioning discipline decide how much automation is appropriate. If the SDK’s release pipeline derives its version bump mechanically from a spec diff, a patch or minor update carries a real guarantee and auto-merging on green integration tests is reasonable. A major update never is: by construction it means the generated surface changed, which is precisely the case a human should look at. Grouping all clients from one API into a single pull request also makes the estate legible — you see at a glance whether every language moved together, and notice when one did not.
Related
- SDK Version Pinning — up-link: the section covering how consumers depend on published clients
- API Versioning & Deprecation — up-link: the parent reference for versioning and retirement
- Semver Ranges for Generated SDK Clients — choosing the range a lockfile resolves within
- Publishing Generated SDKs from GitHub Actions — the release process that produces the pinned versions
- Contract Tests vs Schema Diffs — covering the server behaviour a lockfile cannot pin