●2.1.273 — A round of connection work landed together: five opt-in headers for LLM gateways, and a notice when Claude Code stops trying to reconnect an MCP server●09/29 — The date beside claude-sonnet-4-5 is 12 days out, but it is an earliest-possible estimate. The model is still Active, and public retirements get at least 60 days notice●MCP — People keep asking to reconnect a dropped server without ending the session. The disconnect is now announced, but reattaching is still something you do by hand●NEW — A scheduled task ran some days and not others. The cause was that only one folder had been bound to it●WINDOWS — When Cowork fails on its very first task, check developer mode and the setup state before going looking for a cause●HANDOFF — Before a long draft gets too heavy for one chat, decide on the three things the summary must carry into the next one●2.1.273 — A round of connection work landed together: five opt-in headers for LLM gateways, and a notice when Claude Code stops trying to reconnect an MCP server●09/29 — The date beside claude-sonnet-4-5 is 12 days out, but it is an earliest-possible estimate. The model is still Active, and public retirements get at least 60 days notice●MCP — People keep asking to reconnect a dropped server without ending the session. The disconnect is now announced, but reattaching is still something you do by hand●NEW — A scheduled task ran some days and not others. The cause was that only one folder had been bound to it●WINDOWS — When Cowork fails on its very first task, check developer mode and the setup state before going looking for a cause●HANDOFF — Before a long draft gets too heavy for one chat, decide on the three things the summary must carry into the next one
Same Content, Different SHA-256: Making Pinned Plugin Archives Reproducible
Plugins can now be installed from an HTTPS zip with an optional SHA-256 pin. Building the same tree twice produced archives that differed in 50 bytes out of 22,669 and broke the pin every time. Here is the byte-level measurement and a packaging recipe that produced an identical hash 20 times out of 20.
I decided to package one of my own plugins for internal distribution the same day I learned that zip sources were supported. No git, no npm — one fixed location, contents verified by SHA-256. As an indie developer shipping to a handful of my own machines, that felt like exactly the right amount of machinery.
I recorded the hash of the archive I built, paused for a moment, then rebuilt it from the same directory. Not a single character had changed.
The hash was different.
The size was identical at 22,669 bytes. Extracting both archives produced identical files and identical structure. The SHA-256 still disagreed. Within ten minutes of adopting a pinning mechanism, I had broken it myself.
What follows is a byte-level account of where that difference lives, and how far I had to go to get twenty consecutive builds to produce the same hash. Every number here comes from an actual run in a Linux sandbox using Info-ZIP zip 3.0 and Python 3.
Where the 50 bytes live
The first thing to reproduce was a pair of independent checkouts. Git does not preserve mtimes. Every fresh clone stamps every file with the moment of checkout — which is precisely what a CI job doing clean checkouts produces.
# Two identical trees created two seconds apart, standing in for two clonescp -r src co1 && sleep 2 && cp -r src co2( cd co1 && zip -qr ../co1.zip . )( cd co2 && zip -qr ../co2.zip . )sha256sum co1.zip co2.zip
Archive
SHA-256 (first 16)
Size
co1.zip
e550b24486bb82f9
22,669 bytes
co2.zip
c35df325dd913655
22,669 bytes
Counting the actual differences:
a = open('co1.zip', 'rb').read()b = open('co2.zip', 'rb').read()diff = [i for i, (x, y) in enumerate(zip(a, b)) if x != y]print(len(diff), "/", len(a)) # -> 50 / 22669runs = []for i in diff: # collapse into contiguous ranges if runs and i == runs[-1][1] + 1: runs[-1][1] = i else: runs.append([i, i])print(len(runs), sorted({r[1] - r[0] + 1 for r in runs})) # -> 50 [1]
Fifty bytes out of 22,669 — 0.22% — and all fifty were isolated single bytes rather than contiguous blocks.
The extracted trees, meanwhile, were identical:
( cd x1 && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum )# 87f1d6cc4b71348a23ab7237f92d07fc7e1b04d3c993b5054ac67b75225df440( cd x2 && find . -type f | LC_ALL=C sort | xargs sha256sum | sha256sum )# 87f1d6cc4b71348a23ab7237f92d07fc7e1b04d3c993b5054ac67b75225df440
The culprit is timestamps. This archive held 11 entries (5 of them directories), and each entry carried the modification time in three separate places.
Location
Format
Per entry
Local file header
MS-DOS time and date
4 bytes
Central directory
MS-DOS time and date
4 bytes
UT extra field
32-bit Unix time
4 bytes, twice
UT is the extended timestamp Info-ZIP adds by default; in this tree the extra fields totaled 264 bytes. Because it stores whole seconds, it moves more readily than the DOS field. With only a two-second gap between builds, mostly low-order bytes shift — which is exactly why the diff came out as fifty scattered single bytes rather than a few solid runs.
The archive faithfully records when the files were read, not just what they contain, and the hash covers all of it. Obvious in hindsight. Not remotely obvious while you are filling in the pin.
Two consecutive builds can match by accident
This is the part I found genuinely dangerous.
The natural way to test reproducibility is to build twice in a row and compare. That is what I did. And that test passes.
MS-DOS timestamps have two-second resolution. Two builds that land in the same two-second window can share both the DOS field and the Unix seconds. Measuring across intervals:
Gap between tree creations
Build 1 (first 12)
Build 2
Result
0 seconds
034179b40720
034179b40720
match
1 second
fd37aad07691
f83beb9efdb4
differ
3 seconds
bdc8402f83a5
d978834c43c9
differ
My expectation had been the opposite: if timestamps are embedded, surely the hash changes every single time. Instead, testing quickly makes the problem disappear. Local check green, CI red the next morning, and a reproduction condition that depends on wall-clock timing — a combination that tends to cost an afternoon.
The workaround is trivial. If you want to verify reproducibility, leave at least three seconds between builds, or deliberately scramble the mtimes as shown below — I recommend baking that scrambling into the check itself. Two back-to-back builds agreeing proves nothing.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A byte-level breakdown of why two independently built archives with identical contents differ in exactly 50 isolated bytes while the extracted tree hashes the same
✦MS-DOS timestamps have two-second granularity, so back-to-back builds can match by accident. Measured at 0, 1, and 3 second intervals to show how a reproducibility check silently passes
✦A pack.sh that normalizes mtimes, fixes entry order with LC_ALL=C, strips extra fields, and pins the compression level, producing the same hash 20 out of 20 times, plus a verify.sh for the consuming side
✦An audit script that reads only the central directory and judges repack risk from UT, ux, and entry order, plus a measured breakdown showing that of the four normalization steps only the mtime fix carries the determinism
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Ordering, compression level, and extra fields all matter
Timestamps are not the only variable. Holding mtimes fixed and changing only the order in which files are fed to zip:
find ord -exec touch -d @1000000000 {} +( cd ord && find . -type f | LC_ALL=C sort | zip -qX ../ord_sorted.zip -@ )( cd ord && find . -type f | LC_ALL=C sort -r | zip -qX ../ord_rev.zip -@ )
Entry order
SHA-256 (first 16)
Size
Byte order
18e68b4d24fcf025
21,579 bytes
Reversed
daf1243d606a69ed
21,579 bytes
Same size, different hash. zip -r walks the tree in whatever order the filesystem returns, which depends on directory internals — stable on one machine, not something to rely on across machines.
Compression settings behave the same way:
Command
SHA-256 (first 16)
Size
zip -q
aa50bf6552d70f39
21,891 bytes
zip -qX
18e68b4d24fcf025
21,579 bytes
zip -qX -0
39c8ec8872fb1d05
28,109 bytes
zip -qX -6
18e68b4d24fcf025
21,579 bytes
zip -qX -9
fc6fbadb81cc38dd
21,579 bytes
Three things fall out of that table.
-X (drop extra fields) took the archive from 21,891 to 21,579 bytes. The UT and ux (UID/GID) fields for six files accounted for 312 bytes. Removing owner IDs from a distributed artifact is desirable on its own merits.
The default compression level is -6, so plain -qX and -qX -6 hashed identically.
-6 and -9 produced the same 21,579 bytes but different hashes. The deflate search strategy changed, yielding a different byte stream at the same length. Size equality is not a proxy for identity — something I would not have caught without measuring.
Twenty builds, one hash
The fix reduces to four operations: normalize mtimes to a fixed value, fix entry order in a locale-independent way, strip extra fields, and pin the compression level.
#!/usr/bin/env bash# pack.sh — build a deterministic plugin zip. Same contents, same SHA-256, always.set -euo pipefailSRC="${1:?usage: pack.sh <plugin-dir> <out.zip>}"OUT="${2:?}"[ -f "$SRC/plugin.json" ] || { echo "no plugin.json in: $SRC" >&2; exit 1; }STAGE="$(mktemp -d)"trap 'rm -rf "$STAGE"' EXITcp -a "$SRC/." "$STAGE/"# Drop things that must never ship (.git alone guarantees a changing hash)rm -rf "$STAGE/.git" "$STAGE/node_modules"find "$STAGE" -name '.DS_Store' -delete# 1) Normalize mtimes; -h covers symlinks themselvesfind "$STAGE" -exec touch -h -d @1000000000 {} +# 2) Fix entry order in byte order, immune to locale collation# 3) -X strips UID/GID and the extended timestamp# 4) Pin the compression level explicitly, so a future default change cannot move the hash( cd "$STAGE" && find . -type f -print | LC_ALL=C sort | zip -qX -9 "$OLDPWD/$OUT" -@ )printf '%s %s\n' "$(sha256sum "$OUT" | cut -d' ' -f1)" "$OUT"
I verified it by scrambling mtimes before every iteration, specifically to defeat the accidental-match case:
for i in $(seq 20); do find det -type f -exec touch -d "@$((1700000000 + RANDOM))" {} + ./pack.sh det det.zipdone
All twenty runs produced fc6fbadb81cc38dd... at 21,579 bytes. Twenty out of twenty.
The separate-checkout case passes too. Forcing one tree to 2025-era mtimes and the other to 2026-era mtimes still yields:
A single changed byte still moves the hash, which is the whole point. Appending one character to summarize.md took fc6fbadb81cc38dd to 1be5cbf22e3fea20.
What the pin does not cover
Once the producing side is deterministic, tighten the consuming side.
--proto '=https' closes the redirect-to-plaintext path. The hash would catch tampering either way, but detecting a problem and refusing to create the opportunity are different things.
Beyond that, it helps to know the pin's boundaries. The clearest measurable case is packager differences. Given identical input, identical timestamps, and identical ordering, Python's zipfile and Info-ZIP disagree:
Packager
SHA-256 (first 16)
Size
Info-ZIP zip -qX -9
fc6fbadb81cc38dd
21,579 bytes
Python zipfile (compresslevel=9)
e58348bb23262ce4
21,607 bytes
Identical contents, different hash, different size. A mismatch is therefore not evidence of tampering — someone may simply have swapped the packaging tool. If you rely on pinning, the packaging procedure itself belongs in version control alongside the artifact. That is a large part of why pack.sh ships with the plugin.
The same reasoning extends outward. If your plugin shells out to npx at runtime, that dependency resolves independently of the archive hash. Archive identity is not runtime identity. For the environmental assumptions plugins tend to carry silently, see four implicit assumptions in plugin portability.
Measuring an archive before you pin it
Everything so far has been about the side that builds. In practice you pin other people's archives more often than your own, and what you want to know then isn't what's inside — it's whether this distribution was produced in a form that can be rebuilt byte-for-byte for the next release.
For a while I settled for reading unzip -l. That listing gives names, sizes, and timestamps. The things that actually move the hash — extra fields and entry order — never appear in it. I was making a judgment about bytes I had never looked at.
So I wrote a small check that reads only the central directory and counts the things that will move the hash on a repack. It never extracts.
#!/usr/bin/env python3"""Audit a zip for pinning: look only at what threatens a byte-identical rebuild."""import sys, zipfile, structKNOWN = {0x5455: "UT (extended timestamp)", 0x7875: "ux (UID/GID)", 0x000a: "NTFS times", 0x0001: "Zip64", 0x9901: "AES"}def parse_extra(blob): """Split the extra field into (id, body) pairs; stop at the first malformed record.""" out, i = [], 0 while i + 4 <= len(blob): hid, size = struct.unpack_from("<HH", blob, i) body = blob[i + 4: i + 4 + size] if len(body) != size: break out.append((hid, body)) i += 4 + size return outdef uid_gid(body): """Read UID/GID out of ux (0x7875). Anything but version 1 is left alone.""" if not body or body[0] != 1: return None i, vals = 1, [] for _ in range(2): if i >= len(body): return None n = body[i]; i += 1 if i + n > len(body): return None vals.append(int.from_bytes(body[i:i + n], "little")); i += n return tuple(vals)def inspect(path): with zipfile.ZipFile(path) as z: infos = z.infolist() names = [i.filename for i in infos] stamps = {i.date_time for i in infos} extra_bytes = sum(len(i.extra) for i in infos) ids, owners = {}, set() for i in infos: for hid, body in parse_extra(i.extra): ids[hid] = ids.get(hid, 0) + 1 if hid == 0x7875: og = uid_gid(body) if og: owners.add(og) ordered = names == sorted(names) print(f"# {path}") print(f" entries : {len(infos)} ({sum(1 for n in names if n.endswith('/'))} directories)") print(f" distinct DOS ts : {len(stamps)}") print(f" extra field bytes: {extra_bytes}") for hid, cnt in sorted(ids.items()): print(f" - 0x{hid:04x} {KNOWN.get(hid, 'unknown'):<24} {cnt} entries") print(f" UID/GID : {sorted(owners) if owners else 'none'}") print(f" compression : {sorted({i.compress_type for i in infos})}") print(f" byte-sorted order: {ordered}") risky = (len(stamps) > 1) or (0x5455 in ids) or (0x7875 in ids) or (not ordered) print(f" verdict : {'WARN - a repack will move the hash' if risky else 'OK - normalized'}") return 1 if risky else 0if __name__ == "__main__": sys.exit(max(inspect(p) for p in sys.argv[1:]))
I built a small plugin tree (6 files, 5 directories), packed it once with a plain zip -qr and once through pack.sh, and ran the check on both. This is the actual output:
# naive.zip
entries : 11 (5 directories)
distinct DOS ts : 1
extra field bytes: 264
- 0x5455 UT (extended timestamp) 11 entries
- 0x7875 ux (UID/GID) 11 entries
UID/GID : [(1199, 1199)]
compression : [0, 8]
byte-sorted order: False
verdict : WARN - a repack will move the hash
# norm.zip
entries : 6 (0 directories)
distinct DOS ts : 1
extra field bytes: 0
UID/GID : none
compression : [8]
byte-sorted order: True
verdict : OK - normalized
The line worth staring at is that the naive archive also reports a single distinct DOS timestamp. Files written in one sitting land inside the same two-second window, so counting timestamps tells you nothing about the risk. The accidental-match trap from earlier in this article reappears here, on the auditing side. That's why the verdict keys off UT, ux, and entry order instead of the timestamps you can see.
Finding my own (1199, 1199) sitting in ux was its own small surprise. Beyond reproducibility, I'd rather not ship account numbers from my machine, and -X handles that too.
Then I isolated which of the four normalization steps is actually load-bearing. Two trees whose mtimes sit about four months apart — standing in for two independent checkouts — packed under three different conditions:
Condition applied
Tree 1 (first 16)
Tree 2
Result
Plain zip -qr
7d5aae9ca9b91d41
54cd2139c54f75ea
differ
Sorted order + -X + -9 (mtimes untouched)
69b360fb0402f219
10ee2f59d9eb798a
differ
The above plus mtime normalization (pack.sh)
82e65d5cb955580c
82e65d5cb955580c
match
The middle row is the one I'd underlined. Stripping UT with -X still leaves the DOS timestamps in the local file headers and the central directory, so the hashes stay apart. Dropping extra fields and fixing times are two different jobs. Ordering and compression level narrow the variance; the determinism itself rests on that single touch line.
You cannot pin what you never measured. Wiring this check into CI against the output of pack.sh, and letting the exit code fail the build, means that dropping one of the four steps some months from now breaks on my machine rather than in someone else's install.
Putting it into practice
For an internal distribution, five things are worth setting up before you publish anything.
Commit pack.sh and call it from CI. Never hand-build the zip. Fixing the packager settles half the reproducibility question by itself.
Ship the hash as a manifest. Put plugin.zip.sha256 next to plugin.zip and move the URL and the hash in the same commit, so neither can be replaced alone.
Put the version in the URL. Pinning a mutable entry point like latest to a SHA-256 breaks every consumer on the next release. Pair a fixed URL with a fixed hash and publish a new pair to migrate.
Ignore the verification cost. SHA-256 over a 50 MB file took 224 ms on this VM. Plugin archives are typically two orders of magnitude smaller, so verifying on every install is imperceptible.
Always pass zip -X. Beyond reproducibility, it keeps UID and GID out of the artifact.
One thing I could not verify: locale-dependent collation. I built a tree containing a Japanese-named directory to test it, but this VM only carried C/POSIX locales (locale -a returned four entries), so I could not reproduce a difference. What I did measure is that entry order changes the hash, and there is no good reason to let the build environment's default collation decide it — hence LC_ALL=C.
Zip distribution is new enough that the tooling may eventually normalize archives on its own. Even then, a deterministic producer costs nothing.
Where to go next
If you are evaluating zip distribution, write pack.sh before you fill in a single pin. Discovering the reproducibility problem after publishing means your users are the ones who hit it. Reversing the order removes the failure entirely.
I had let the word "hash" do too much work in my head — same input, same output, obviously. The definition of "input" quietly included wall-clock time and filesystem ordering. That is the kind of assumption you only see once you measure it.
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.