All posts
Strategy6 min read

Deterministic dependency management for internal scripts across Python, TypeScript, and Go

Jamie

Deterministic dependency management for internal scripts across Python, TypeScript, and Go

Why “just a script” breaks down without deterministic dependencies

Internal scripts tend to start as small, local utilities: a Python one-liner to backfill rows, a TypeScript job that calls an API, a Go CLI that replays Kafka messages. The failure mode shows up a few weeks later: someone reruns the script and gets different results, CI behaves differently than a laptop, or a dependency update silently changes parsing or pagination behavior.

“Deterministic dependency management” means you can answer a simple question with confidence: if we rerun this script next week, on a different machine, will it execute with the exact same dependency graph? For polyglot teams, the hard part isn’t any single ecosystem. It’s coordinating Python, Node/TypeScript, and Go scripts with consistent guarantees—without needing a dedicated platform team to build bespoke tooling.

What determinism looks like in practice

For internal automation, determinism is less about academic purity and more about operational predictability:

  • Repeatable installs: the same lockfile or checksum set produces the same resolved versions.
  • Hermetic-ish execution: the runtime uses pinned dependencies, not whatever happens to be installed globally.
  • Auditable changes: dependency drift shows up as a diff you can review.
  • Portable workflows: developers can run scripts locally, in CI, and in production workers with minimal “works on my machine” variance.

Python: pinning is easy, keeping it consistent is the work

Use lockfiles, not just version ranges

If you publish libraries, semver ranges are normal. For internal scripts, ranges are a liability. Prefer a lockfile-driven approach:

  • Poetry: pyproject.toml plus poetry.lock captures the full resolved tree.
  • Pip-tools: compile a fully pinned requirements.txt from a higher-level requirements.in.
  • uv (if you adopt it): fast resolution with lock support; the key is still committing the lock and enforcing it in CI.

Whatever you choose, make the lockfile required for merges and run installs in “locked” mode in CI.

Stabilize the runtime, not only the packages

Python patch versions can change behavior at the edges. Standardize a Python version per repo (or per script bundle) and enforce it using one of:

  • .python-version (pyenv/asdf)
  • Docker base image pin (including digest, if you need stronger guarantees)
  • CI matrix pinned to a specific version (avoid “latest 3.11”)

Prevent the silent breakages

Two common sources of non-determinism are platform-specific wheels and transitive updates. A light but effective guardrail is a scheduled dependency update window, where you intentionally refresh locks, run a smoke test suite, and ship. Outside that window, installs should be reproducible by design. If this process generates lots of repeated back-and-forth, it’s a sign you’re accumulating coordination overhead; the pattern is similar to what happens in product orgs with escalating queues, described in “The Silent Queue Problem and How to Keep Customer Bugs From Derailing Your Roadmap.”

TypeScript and Node: deterministic installs require more than package-lock

Pick one package manager and lockfile

The fastest way to lose determinism is allowing npm, Yarn, and pnpm interchangeably. Standardize one tool and commit one lockfile:

  • npm: package-lock.json and npm ci in CI.
  • pnpm: pnpm-lock.yaml with pnpm install --frozen-lockfile.
  • Yarn: yarn.lock with the immutable/frozen install mode.

Pin Node itself

Node version drift is a quiet source of differences (OpenSSL changes, fetch behavior, ESM resolution). Commit an .nvmrc or tool-agnostic config via asdf, and ensure CI uses the same Node version. If you have scripts that run as scheduled jobs, the “runner” environment must also be pinned.

Handle native modules intentionally

Native dependencies (like image processing or database drivers) can compromise portability. When possible, prefer pure JS alternatives for internal scripts, or isolate native modules behind a dedicated container image. The goal isn’t to ban native modules—it’s to make them predictable.

Go: reproducible builds are close by default, but don’t skip the last mile

Go modules are your lockfile

Go’s go.mod defines requirements and go.sum pins checksums. Commit both, and make CI fail if go.sum changes unexpectedly. For internal tools, avoid floating to new minor versions during routine builds; make upgrades explicit.

Control the toolchain

Pin the Go version in your tooling (asdf, Docker, CI) and consider using the module’s toolchain directive where appropriate. Small runtime or compiler changes can surface as different binaries or behavior under load.

Build deterministically

If you ship binaries, remove build metadata variance where it matters (timestamps, VCS info) and keep builds consistent across environments. For scripts that run as services/jobs, the primary risk is module drift and different compiled targets; a pinned toolchain and committed go.sum typically covers most cases.

Polyglot reality: one repo, three ecosystems, one repeatability contract

The common mistake is treating each language’s dependency story as independent. Operationally, your scripts execute inside one system: the same CI, the same scheduler, the same incident rotation. A practical repeatability contract for polyglot internal scripts looks like this:

  • One command to run: each script exposes a single entrypoint (Makefile, task runner, or platform command) that installs in locked mode and executes.
  • Locked installs in CI: CI never resolves fresh dependency graphs; it only installs from lockfiles.
  • Deliberate update cadence: dependency updates happen as reviewed changes, not as side effects.
  • Artifacted runtimes: when needed, package the runtime (container or prebuilt environment) so production execution matches CI.

Doing this without a platform team: standardize the workflow, not the infrastructure

You don’t need a bespoke internal platform to get determinism, but you do need a consistent execution model. That’s where a code-first automation platform can help by reducing the “glue” you’d otherwise maintain: secret injection, scheduling, logs, retries, and environment consistency across languages.

Windmill fits this model well because it’s designed for real-code scripts across many languages with managed dependencies and a low-overhead execution engine, while keeping collaboration Git-friendly. Teams can build and run Python, TypeScript, and Go automations with a uniform operational layer—logs, alerts, permissions, and deployment—without inventing a custom framework. For a reference point, see windmill.dev.

Common pitfalls and guardrails that keep reproducibility intact

Guardrail 1: fail fast on lock drift

Add checks that fail CI when lockfiles change during install (for example, npm ci or frozen lock installs). Do the same for Go by verifying a clean git diff after go test.

Guardrail 2: keep “tooling state” out of developer machines

Documented setup isn’t enough—people forget steps. Automate environment setup through repo scripts, dev containers, or a single CLI entrypoint. If your org frequently re-implements the same setup guidance across teams, you’re likely paying a coordination tax similar to inconsistent campaign naming conventions described in “The UTM tax and the fix for inconsistent campaign naming.” The fix is the same: one standard everyone uses.

Guardrail 3: explicitly decide what “reproducible” means for each script

Not every script needs the same rigor. A daily Slack reminder can tolerate looser constraints; a billing backfill cannot. Classify scripts (tier 1/2/3) and match controls to risk: pinned runtimes and containerized execution for high-stakes jobs, lockfile-only for low-risk utilities.

A workable baseline checklist

  • Python: lockfile committed; CI installs in locked mode; Python version pinned.
  • TypeScript: one package manager; lockfile committed; Node pinned; frozen/CI install.
  • Go: go.mod/go.sum committed; Go version pinned; CI verifies no drift.
  • Across all: deliberate dependency upgrade cadence; consistent execution environment; centralized logs and alerts.

Frequently Asked Questions

Related Posts