●RUNNER — claude self-hosted-runner turns your own machines or containers into a place web, mobile, and desktop sessions can run, on Team and Enterprise plans●PLUGIN — Plugins can now be installed from a zip over HTTPS, with no git or npm required and optional SHA-256 pinning●BEDROCK — ANTHROPIC_BEDROCK_REGION_PREFIX lets Bedrock prefer a specific cross-region inference profile instead of the one derived from AWS_REGION●STATUS — Sessions waiting on a sandbox, MCP input, or managed-settings prompt now read as Needs input rather than Working●DLP — Inference Hooks are in beta for Enterprise. Prompts route to your own security server for an allow or deny verdict, typically within five seconds, before the model sees them●WORKBENCH — The legacy Workbench and experimental prompt tools API retire on August 17, and Sonnet 5 promotional pricing runs through August 31●RUNNER — claude self-hosted-runner turns your own machines or containers into a place web, mobile, and desktop sessions can run, on Team and Enterprise plans●PLUGIN — Plugins can now be installed from a zip over HTTPS, with no git or npm required and optional SHA-256 pinning●BEDROCK — ANTHROPIC_BEDROCK_REGION_PREFIX lets Bedrock prefer a specific cross-region inference profile instead of the one derived from AWS_REGION●STATUS — Sessions waiting on a sandbox, MCP input, or managed-settings prompt now read as Needs input rather than Working●DLP — Inference Hooks are in beta for Enterprise. Prompts route to your own security server for an allow or deny verdict, typically within five seconds, before the model sees them●WORKBENCH — The legacy Workbench and experimental prompt tools API retire on August 17, and Sonnet 5 promotional pricing runs through August 31
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
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.
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.