DEVTOOLBOX / GUIDES this guide
Cutting a release: version numbers, file modes, checksums and compression
A release is bytes plus the metadata that tells a stranger how to use them, and most release bugs are metadata bugs: a caret range that admits a breaking minor, an archive that forgot the execute bit, a checksum line published next to the file it guards. This guide orders those decisions, gives a criterion and a cost for each, and marks the four steps that cannot be taken twice.
What a release artifact is: bytes plus the metadata that describes them
The artifact is not the file the compiler produced. It is those bytes plus every value that decides how they land on a machine you will never see: the name, the permission bits, the modification time, the path prefix inside the archive, the digest published next to it, and the version string on the download page. Each of those travels through a different channel, and each channel has its own fidelity.
Rank the channels before trusting any of them. Bytes cross every transport intact or the corruption is detectable. Mode bits survive tar on a Unix host, survive zip only when the writing tool records a Unix host in the header and the reading tool looks, and vanish behind a plain HTTP download, where the protocol speaks in content type and length and has no field for rwx. Timestamps survive an archive and are then overwritten by the extracting machine. The version string survives nothing unless something machine-readable repeats it.
So the criterion is which consumer reads which channel. A package manager reads the version and the digest. A shell reads the mode. A human reads the name. Put the digest beside the bytes, the mode inside the archive, and the version once in an index, because a value that exists in two places is a value that will eventually disagree with itself, and the disagreement is discovered by a user rather than by your tests.
What fails quietly is the mismatch: an artifact that extracts perfectly and then cannot run. The bytes were fine and the metadata lied, and no test of the build caught it.
| Metadata | Carried by | Silently lost when |
|---|---|---|
| File name | URL path, Content-Disposition | A proxy rewrites the path |
| Permission bits | tar member header, zip external attributes | The writer records no Unix host |
| Modification time | tar and zip headers, gzip MTIME | The extractor stamps the current clock |
| Path prefix | Archive member names | A flattener strips the top directory |
| Version string | Package index, file name, manifest | A hand upload skips the index |
The same artifact reaches a consumer through five channels of unequal reliability
Version numbers: SemVer ranges and the upgrades they silently allow
SemVer 2.0.0 fixes a shape: MAJOR.MINOR.PATCH, three numeric identifiers with no leading zeros, then an optional prerelease after a hyphen and optional build metadata after a plus. Precedence compares major, then minor, then patch, and any prerelease sorts below the release it precedes, so 1.0.0-rc.1 is lower than 1.0.0. Build metadata is excluded from precedence entirely, which means 1.0.0+build.5 and 1.0.0+build.6 are the same version to every range check.
The range syntax dilutes the promise. A caret on 1.2.3 admits anything from 1.2.3 up to but excluding 2.0.0, so 1.9.7 arrives on a routine install with whatever the author called a feature. A tilde on the same version stops before 1.3.0. The zero-major case bites: a caret on 0.2.3 stops before 0.3.0, and a caret on 0.0.3 stops before 0.0.4, so one operator acts as a tilde, then as a pin, while the major stays zero. An unqualified range with no lockfile means whatever is newest that day.
By role. A library that lands in someone else tree declares the widest range its tests justify, because a narrow range duplicates the dependency and a wrong one breaks the build of a project that never touched your code. An application commits a lockfile and builds from it, so its range matters only at the moment of resolution, when nobody is watching.
Where it stops being reversible. A published version number cannot be reused. Registries refuse a second upload of 1.2.3, so a bad release is repaired by 1.2.4 and never by a quiet overwrite; deprecation marks a version but cannot remove it from lockfiles already written. A prerelease such as 1.2.0-rc.1 is invisible to ordinary ranges unless the range names a prerelease on the same tuple.
| Range | Expands to | Upgrade the author did not promise |
|---|---|---|
| ^1.2.3 | >=1.2.3 <2.0.0 | Any 1.x minor or patch |
| ~1.2.3 | >=1.2.3 <1.3.0 | Patch releases inside 1.2 |
| ^0.2.3 | >=0.2.3 <0.3.0 | Patches inside 0.2 only |
| ^0.0.3 | >=0.0.3 <0.0.4 | Nothing: a caret acting as a pin |
| 1.2.3 | Exactly 1.2.3 | None at resolution time |
| * | Any published version | Everything, majors included |
What each range actually admits on the next install
File modes: 644 versus 755, umask, and why the archive forgets
A mode is three octal digits, each 0 to 7, one class each for owner, group and other. A digit is a sum of read 4, write 2 and execute 1, so 0644 is rw-r--r-- and 0755 is rwxr-xr-x. On a directory, execute means may enter or list, so 0644 on a directory is a directory nobody can open, and 0755 on a private key is a key every account on the host can read.
umask does not grant permissions; it forbids them. New files start at 0666 and new directories at 0777, and the mask is cleared bitwise from those defaults: umask 022 gives 0644 and 0755, umask 077 gives 0600 and 0700, umask 002 gives 0664 and 0775. This is why the same source tree produces differently permissioned artifacts on a developer laptop and in CI, and why a script that is executable in the checkout can arrive dead.
The archive forgets in two distinct ways. tar writes a mode field per member, so the value survives any extractor that honours it. Zip writes the mode into the high 16 bits of the external file attributes and tags the writer host in the version-made-by field; a tool that writes zero there leaves the extractor to invent 0644 or to apply a local umask, and both outcomes are indistinguishable from a deliberate choice. Git sits below either format and tracks a single execute bit, so 0640 and 0664 both check out as 0644 and only the x bit is ever preserved.
Too late after upload. A chmod repairs nothing for anyone who already downloaded, so the repair is a new artifact with a new digest, the mode fixed inside it, and a release note that says the earlier file cannot run.
| Mode | Symbolic | Reachable by |
|---|---|---|
| 0600 | -rw------- | Owner only: keys, tokens, credentials |
| 0644 | -rw-r--r-- | Everyone reads, owner writes |
| 0664 | -rw-rw-r-- | Group also writes: shared checkouts |
| 0755 | -rwxr-xr-x | Everyone runs, owner writes |
| 0775 | -rwxrwxr-x | Group writes and runs: build output |
| 4755 | -rwsr-xr-x | Anyone runs as the owner: setuid |
The six modes a release actually needs, and who each one admits
Checksums: what publishing a digest proves and what it does not
A digest is a fixed-length function of the bytes and nothing else. SHA-256 returns 256 bits, which is 32 bytes and 64 hex characters; SHA-512 returns 512 bits, 64 bytes, 128 hex characters; SHA-1 returns 160 bits and 40 hex characters, a size that no longer means what it did before chosen-prefix collisions became practical. Every hex character removed divides the search space by 16, so a truncated 8-character digest promises 32 bits and a birthday collision near 2^16, roughly 65,536 artifacts.
What the digest proves depends entirely on the channel that carried it. A checksums file sitting beside the download on the same host proves that transfer completed without corruption, and it proves nothing about who produced the bytes, because whoever can replace the artifact can replace the line that guards it. Serving that file from the same origin over TLS does not help: whoever owns the server owns both responses. Authenticity needs a second channel, which in practice means a signature over a manifest, verified with a key obtained somewhere the attacker does not control.
Match the check to the threat. An internal hand-off from build to deploy, where the risk is a truncated upload, needs a digest and nothing more. A public binary needs a detached signature over a manifest plus a per-file SHA-256, because the consumer has no other way to attribute bytes. A browser asset needs the digest pinned in an integrity attribute, since that is the only check that survives a compromised CDN.
Permanent by design. A digest copied into a lockfile, an integrity attribute, or an image reference pinned by digest is a promise that these exact bytes and no others. Rebuilding changes it, and every consumer must edit the pin.
| Published where | Proves | Does not prove |
|---|---|---|
| Beside the file on the same host | Transfer completed without corruption | That the publisher chose these bytes |
| Same origin over TLS | The connection was not altered in transit | That the server was not compromised |
| Signed manifest, key obtained elsewhere | A named key holder approved the bytes | That the key holder is trustworthy |
| Pinned in a lockfile or integrity attribute | These bytes and no others | That the bytes are free of defects |
Four places to publish a digest, and the one claim each supports
Compression: choosing a format per platform, not per habit
Three decisions hide inside the word compression. The codec turns bytes into fewer bytes. The container decides whether there is a file list, member names, modes, random access. The transfer encoding decides what an HTTP client can decode. Habit collapses all three and ships the right size for the wrong consumer.
gzip, RFC 1952, is deflate from RFC 1951 inside a 10-byte header carrying a modification time and an optional original filename, closed by a CRC-32 and a 32-bit input size. It compresses one stream and has no concept of a file, so modes survive only when it is paired with tar. Zip, per the PKZIP APPNOTE, is a container: deflate per entry, a central directory for random access, a name and attributes per member, plus extra fields any tool may write. Brotli, RFC 7932, and Zstandard, RFC 8878, are stream codecs with their own framing.
Pick by what the target machine already opens. Text over HTTP takes Brotli when the client advertises br and gzip otherwise, because gzip is the codec no client lacks. Source and Unix binaries take tar.gz, since modes and symlinks survive. A double-clickable binary for Windows or macOS takes zip, since both open it without extra tooling and it carries the execute bit. A five percent win that leaves the majority unable to open the file is a net loss.
The gain follows the input, not the tool. PNG, JPEG, MP4, zip and jar are already compressed, so a second pass returns a few percent at best and can add bytes through framing overhead. Level is a cost knob: gzip defaults to 6, level 1 is far faster, level 9 buys single digits over 6 for several times the CPU.
| Format | Container | Choose it when |
|---|---|---|
| gzip (RFC 1952) | Single stream, no file list | HTTP fallback every client can read |
| Brotli (RFC 7932) | Single stream | Text over HTTP with br accepted |
| Zstandard (RFC 8878) | Single stream or frame set | Large artifacts where speed matters |
| Zip (PKZIP APPNOTE) | Archive with central directory | Binary releases for Windows and macOS |
| tar plus gzip | Archive inside one stream | Source or Unix binaries, modes kept |
Codec and container are separate choices; the platform decides both
Archive hygiene: timestamps, path prefixes and reproducibility
Hygiene is the difference between an artifact that works and one that generates tickets. Four fields cause nearly all of it: member paths, timestamps, ownership, and entry order.
Paths first. A well-formed tarball puts every member under one top-level directory named for the project and version, so extraction creates one directory instead of scattering twenty files into whatever directory the user was standing in. Both formats share a security edge: a member whose name starts with a slash, carries a drive letter, or contains a parent-directory segment escapes the extraction root and writes outside it. Reject those entries on creation and on extraction, and resolve each path before writing.
Timestamps next. Zip stores DOS time at two-second granularity on a 1980 epoch, tar stores whole seconds, and gzip stores a modification time and an optional filename in its header. Each of these makes two builds of identical sources differ byte for byte, which destroys the only cheap check that two builders produced the same thing. The recipe is small: export SOURCE_DATE_EPOCH from the commit time, stamp every member with it, force uid and gid to 0 with empty owner names, sort entries under a fixed collation, exclude VCS directories and caches, and tell gzip to omit the name and time it would otherwise record.
Frozen at upload. Ownership and timestamps inside a published archive cannot be corrected without republishing, and extraction as root restores the recorded owner, so an archive built under a stray uid writes files owned by that uid on the target host.
| Field | Recorded in | Pin it by |
|---|---|---|
| Member mtime | tar and zip headers | One SOURCE_DATE_EPOCH for all members |
| Owner and group | ustar header fields | uid and gid 0, empty names |
| Entry order | The order bytes appear in | Sorting names under LC_ALL=C |
| gzip name and time | RFC 1952 header | gzip -n, or mtime set to zero |
| Member path | Every member name | One relative top-level directory |
Five fields that make two builds differ, and the flag that fixes each
An order of operations for cutting a release
The order exists because each step consumes a fact that the previous one froze. Version first, then content, then modes, then the archive, then the digest, then the signature, then the upload, then the pointer. Move any step forward and it certifies something that will change before anyone reads it.
The failure modes are specific. A digest computed before the mode fix signs bytes that will never ship, and the published checksum then rejects the corrected file, which reads exactly like tampering. A signature produced after upload leaves a window in which the digest is public and unsigned, and that window is the whole attack. An index updated before the files exist sends readers to a missing object, and a CDN caches the miss.
Two decisions inside the sequence deserve their own criterion. The release name should be content-derived enough that a re-upload cannot silently replace bytes: keep the human version in the file name, but publish the digest in the manifest and let every pin use the digest. The version pointer, the short mutable name that everyone types, should be updated last and only after the artifact and its manifest are readable from the URL a user will actually use.
The closing check is subtraction. Fetch the artifact from the public URL, recompute the digest, extract into an empty directory as a non-root account, run the entry point, and compare the resulting modes with the ones you intended. Metadata you did not verify this way is metadata you have merely hoped for.
- Bump the version in one place and tag the commit it describes.
- Freeze time and identity: SOURCE_DATE_EPOCH, uid 0, empty owner names.
- Normalise modes before packing, not after; 0755 for entry points, 0644 for data.
- Pack with a single relative top-level directory and a fixed entry order.
- Compute per-file digests, write the manifest, sign the manifest.
- Upload the artifact, then the manifest, then move the version pointer.
| Step | Must already be final | Move it earlier and |
|---|---|---|
| Bump the version | The change set | Two artifacts claim one number |
| Fix time and modes | The file list | The digest certifies other bytes |
| Pack the archive | Paths, modes, order | A stray prefix lands in consumer trees |
| Compute digests | Every byte of the archive | The manifest describes a ghost |
| Sign the manifest | The finished digest list | Bytes go public unsigned |
| Upload and move the pointer | The signed manifest | Readers hit a miss on a live version |
Each step depends on the one above it being finished, not merely started
Frequently asked questions
- Do I still need published checksums when the download is served over HTTPS?
- Yes, for a different reason than most people give. TLS protects one connection between one client and one server, and it says nothing about a mirror that re-hosts the file, an internal proxy that unpacks and repacks bytes, or a copy that sat on a USB stick. A digest in the release notes lets any of those copies be checked independently of how they were fetched.
- Should a release binary be 0644 or 0755?
- Whatever the consumer must do with it decides. A file that is executed, including a shell script with an interpreter line, needs the execute bit for the class that will run it: 0755 when anyone may run it, 0750 when only the group may. A library, a config template, or a data file is 0644. Shipping a launcher as 0644 produces a permission-denied that looks like a broken download.
- Why do two builds of the same commit produce different archives?
- Usually four fields: member modification times, the uid and gid recorded in the tar header, the order entries were added in, and the name and time gzip writes into its header. Directory iteration order and locale-dependent sorting add variation on top. Fix the clock with SOURCE_DATE_EPOCH, sort names under a fixed collation, and the same sources produce byte-identical output.
- Does a caret range let a breaking change through?
- SemVer says no, since a caret stops below the next major, but only the author decides what counts as breaking, and a minor release that tightens validation or raises a runtime requirement breaks consumers in practice. Below 1.0.0 the protection narrows further: a caret on 0.2.3 stops before 0.3.0. Treat ranges as trust in the maintainer, and pin what you deploy.
- tar.gz or zip for a command line tool?
- Ship both when you have both audiences, and pick by what the platform already opens. Windows and macOS extract zip by double-click, and zip carries the execute bit when the writer records a Unix host. Unix users expect tar.gz, which preserves modes and symlinks without extra fields. A single arch-specific tarball for all platforms is the choice that generates the most support mail.