The lockfile
ketch.lock is one machine's set of tools, pinned to exact releases. Write it,
commit it next to your dotfiles, and any other machine can reproduce the same
set:
ketch lock # write ./ketch.lock from what is installed
ketch lock --check # has the tree drifted from it?
ketch sync # install what the lockfile names, at those versions
Its JSON Schema, generated from the types ketch reads it into, is lock.schema.json.
It is not the lock ketch takes while it works — that one is a mutex over the install tree, held for the length of a command and released at the end.
What one looks like
# ketch.lock — generated by `ketch lock`. Do not edit it by hand:
# run `ketch lock` again instead. Commit it.
version = 1
[[package]]
name = "ripgrep"
source = "github:BurntSushi/ripgrep"
version = "14.1.1"
tag = "14.1.1"
target = "macos-aarch64"
asset = "ripgrep-14.1.1-aarch64-apple-darwin.tar.gz"
sha256 = "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e"
[[package]]
name = "fd"
source = "github:sharkdp/fd"
version = "10.2.0"
tag = "v10.2.0"
target = "macos-aarch64"
asset = "fd-v10.2.0-aarch64-apple-darwin.tar.gz"
sha256 = "d3f0e1a5b3ee2b6d0a1a6d0dd6c72fd0e69f6bb7c5b3a2a94f0e5b9a2f8b8c11"
pinned = true
Packages are written sorted by name, so the file is stable and its diffs are
readable. pinned is only written when it is true, and bin only when a
choice was made.
| Key | What it is |
|---|---|
name | the name it was installed under |
source | owner/repo with its scheme — the stable identity of the package |
version | for humans; tag is what actually gets resolved |
tag | the exact release ketch sync asks for |
target | the machine this entry was captured on |
asset | the release file that was taken on that target |
sha256 | of that file |
pinned | whether the package was held at this version |
bin | the binary chosen when the release ships several sharing the package's name, as a file name (rtok-cli); ketch sync repeats that choice without asking — see the bin section of MANIFESTS.md |
published | for a package on a floating tag that is not a version (nightly), when the locked build was published; written only for such packages |
What is reproducible, and what is not
The tag is. Every machine resolving the same tag gets the same release. That is the point of writing one down.
The asset and its hash are only reproducible on the same target. A lock
written on Apple Silicon names an aarch64 tarball that an x86_64 Linux
machine cannot run. So:
- On a machine whose
targetmatches,syncholds the download to the recordedsha256and refuses it before unpacking anything if it disagrees. A release replaced under a tag it already published is precisely what a lockfile exists to catch. - On any other machine,
syncpicks the asset that fits the host and verifies it against the checksum the source publishes, as a normal install does. Pretending the recorded hash still applied would be a guarantee that quietly is not one. - A floating tag (
nightly,dev) is republished under the same name, so the tag alone does not hold a build still. On the recording target the hash already pins it. On any other machinesynccomparespublishedwith when the build the tag names now was published, and refuses a later one before anything is downloaded; runketch lockagain to accept it. A source that does not say when a release was published leaves only the hash, on the recording target.
A local: package is reproducible only where its path is. The lock records
the absolute path it was installed from and the hash of what was there — the
file's bytes, or for an .app bundle a digest of every file and symlink in
the tree. Each record is framed as kind, path, permission bits, and either
the file bytes or the symlink's own target, so two layouts cannot share a
digest and retargeting a symlink or dropping +x changes it. sync
reinstalls from that path and refuses it, before anything is placed, if its
contents changed since the lock was written. A lock written before that
framing does not match an unchanged bundle; run ketch lock again to record
the new digest. On a machine without the path the entry fails like any other
source that cannot be reached.
ketch sync
ketch sync # install what is missing or at the wrong version
ketch sync --dry-run # show the plan, change nothing
ketch sync --prune # also remove packages the lockfile does not name
ketch sync --file <FILE> # a lockfile somewhere other than ./ketch.lock
The plan reads as a diff:
+ jq 1.7.1
~ ripgrep 14.1.0 -> 14.1.1
- httpie not in the lockfile
2 already match
A package the lockfile does not mention is not drift on its own — a
lockfile records what you want, not necessarily everything you have — so
ketch lock --check ignores extras and only --prune removes them. Pruning
can lose work, so it asks first unless you pass --yes.
pinned is restored after installing, so a package the lock recorded as held
comes back held rather than quietly upgradeable.
What ketch checks
A lockfile is a file somebody else may have written; that is what sharing a
dotfiles repository means. So nothing in it is allowed to choose a filesystem
path. sync asks for a source at a tag, installs it under the recorded name
— which must pass the first check below — and lets the ordinary manifest
resolution decide the binaries and where they go; a recorded bin only settles
which of several binaries sharing the package's name is linked, after any choice
state.json already remembers. The lock pins which release, never where it
lands.
| Refused | Why |
|---|---|
a name that is not usable verbatim as one path component | it is matched against installed packages and shown to you; a name that would have to be rewritten does not mean what it says |
| the same package twice | only one of them could ever be installed |
a github: source that is not a valid owner/repo | it becomes a URL |
| any other source with an empty id | there is nothing to resolve |
a sha256 that is not 64 hex characters | it is compared against a real digest |
a bin that is empty or not usable verbatim as one file name | it names a file in the release; a value with a path separator, .. or control characters cannot |
an empty tag | there is nothing to resolve |
a published that is blank or holds anything but printable ASCII | it is printed in the error that names a moved tag |
a target ketch does not recognise | it silently turns the entry into a cross-target one, so the recorded asset and hash stop applying and a hash that drifted under the tag reads as clean |
| an unknown key | a misspelt key that is silently ignored locks something other than what you wrote |
a version newer than this ketch understands | upgrade with ketch self upgrade |
One bad entry fails the whole file rather than being skipped. Unlike the registry — where a partial answer beats none — a lockfile that installed most of itself would not be a lock at all.
Which name gets resolved
sync asks for the package by name first, because that is how it was found
originally, and which manifest tier answers decides what gets linked and under
what names. Resolving straight from github:BurntSushi/ripgrep would fall
through to inference and could expose different binaries than the registry
entry you actually installed.
The name is used only while it still means the same project. If it now resolves
to a different source, the source in the lockfile wins — a name that changed
hands must not quietly install something else.
Either way the package is installed under the lockfile's name. One installed
with ketch install --name comes back under that name, not the one its source
would infer, so ketch lock --check agrees with the machine sync just set up.