Versioning & pinning: from latest to immutable digests¶
An install is only as reproducible as the references it pins. This page covers
how to point at versions of images, git sources, and language packages — from the
loosest (latest, main) to the strictest (content digests, lockfiles) — and
how to give users control over which they get.
Examples use the catalog's case study: the Exasol Nano image, the JSON Tables git
ref, and exasol-mcp-server==1.10.1. See the
case study and the
comparison matrix.
Container images: tag vs semver vs digest¶
Three ways to reference an image, in increasing strictness:
| Reference | Example | Mutable? | Use for |
|---|---|---|---|
| Rolling tag | exasol/nano:latest |
Yes — moves anytime | Never in installers |
| Semver tag | exasol/nano:8.34.0 |
Mostly stable, but reassignable | Human-readable pinning |
| Digest | exasol/nano@sha256:9f86d0… |
No — content-addressed | Reproducible installs |
latestis not a version. It's whatever was pushed last. Two users running the same installer a week apart can get different software. Never bakelatestinto a shipped installer.- Semver tags (
:8.34.0) are readable and good enough for most users, but a tag is a pointer — a publisher can re-push:8.34.0over different bytes. - Digests are immutable.
@sha256:…names the exact content; if the bytes change, the digest changes. This is the strongest pin.
# docker-compose.yaml — pin the digest, comment the human-readable tag
services:
nano:
image: exasol/nano@sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 # nano 8.34.0
Practical pattern: pin the digest, record the tag in a comment so humans know what it is and machines get exactly those bytes. Update the digest deliberately when you bump versions. See Docker Compose.
Git sources: branch vs tag vs commit SHA¶
JSON Tables is consumed from its git repository (it shells out to a Rust cargo
engine at runtime — there's no wheel to pin). The same hierarchy applies:
| Ref | Example | Reproducible? |
|---|---|---|
| Branch | @main |
No — moves with every push |
| Tag | @v0.5.2 |
Mostly — but tags can be re-pointed |
| Commit SHA | @a1b2c3d… |
Yes — immutable |
# Loosest: tracks the branch HEAD (don't ship this)
pip install "git+https://github.com/exasol/json-tables.git@main"
# Better: a release tag
pip install "git+https://github.com/exasol/json-tables.git@v0.5.2"
# Strongest: a full commit SHA
pip install "git+https://github.com/exasol/json-tables.git@a1b2c3d4e5f6..."
Ship a tag or SHA, never main. A tag is the readable default; a SHA is the
truly immutable one.
Language packages: pip / npm / cargo and lockfiles¶
Two layers matter: pinning your direct dependencies, and locking the full transitive tree.
- pip. Pin exact versions with
==and capture the resolved tree:
Use pip-tools / pip freeze or a lockfile (requirements.txt pinned, or
uv.lock / poetry.lock) so the full graph is reproducible. See
pip/pipx/uvx.
- npm. package.json ranges resolve, but package-lock.json (committed)
pins the exact tree; npm ci installs strictly from the lock. See
npm/npx.
- cargo. Cargo.toml carries semver ranges; Cargo.lock pins exact
versions and is committed for binaries/apps. See
other language managers.
Note on version range conflicts. The case study's two tools can't share a
Python env precisely because of incompatible pins — JSON Tables needs
pyexasol>=2.2,<3 while the MCP server needs pyexasol>=1,<2. Pinning made the
conflict visible, which is the point; the resolution was to isolate them in
separate containers rather than paper over it.
Reproducibility¶
Reproducibility means: same inputs in, byte-identical (or at least behaviorally-identical) result out, on any machine, any day. To get there:
- Pin all references to immutable forms (digests, SHAs, locked versions).
- Commit lockfiles to the repo.
- Avoid implicit
latest/main/ unbounded ranges anywhere in the chain. - Record an SBOM per release so you can audit exactly what shipped (see security).
Letting users pin¶
Don't hard-code versions the user can't override. Offer environment variables or flags with safe defaults:
# install.sh respects overrides, defaults to a known-good pin
NANO_IMAGE="${NANO_IMAGE:-exasol/nano@sha256:9f86d081…}"
MCP_VERSION="${MCP_VERSION:-1.10.1}"
JSONTABLES_REF="${JSONTABLES_REF:-v0.5.2}"
This keeps the happy path reproducible while letting advanced users move forward or roll back.
Channels: stable / beta / nightly¶
For ongoing releases, expose a small number of channels rather than asking users to track raw versions:
- stable — the default; well-tested, recommended for everyone.
- beta — release candidates for early adopters.
- nightly — bleeding edge, expect breakage.
Implement channels as moving aliases that resolve to an immutable pin at install
time (so a stable install still records the exact digest it got). Document which
channel a user is on and how to switch.
Upgrade UX¶
- Make the version visible (
--version, a banner, a label on the image). - Make upgrade a single, idempotent command that re-pulls the pinned references and reconciles state — see idempotency & uninstall.
- Don't auto-upgrade silently across breaking changes; respect the user's pin.
- Document the downgrade/rollback path (re-pin to the previous digest/SHA).