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.

Reviewed 10 min read

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.

MetadataCarried bySilently lost when
File nameURL path, Content-DispositionA proxy rewrites the path
Permission bitstar member header, zip external attributesThe writer records no Unix host
Modification timetar and zip headers, gzip MTIMEThe extractor stamps the current clock
Path prefixArchive member namesA flattener strips the top directory
Version stringPackage index, file name, manifestA 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.

RangeExpands toUpgrade the author did not promise
^1.2.3>=1.2.3 <2.0.0Any 1.x minor or patch
~1.2.3>=1.2.3 <1.3.0Patch releases inside 1.2
^0.2.3>=0.2.3 <0.3.0Patches inside 0.2 only
^0.0.3>=0.0.3 <0.0.4Nothing: a caret acting as a pin
1.2.3Exactly 1.2.3None at resolution time
*Any published versionEverything, 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.

ModeSymbolicReachable 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-xEveryone runs, owner writes
0775-rwxrwxr-xGroup writes and runs: build output
4755-rwsr-xr-xAnyone 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 whereProvesDoes not prove
Beside the file on the same hostTransfer completed without corruptionThat the publisher chose these bytes
Same origin over TLSThe connection was not altered in transitThat the server was not compromised
Signed manifest, key obtained elsewhereA named key holder approved the bytesThat the key holder is trustworthy
Pinned in a lockfile or integrity attributeThese bytes and no othersThat 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.

FormatContainerChoose it when
gzip (RFC 1952)Single stream, no file listHTTP fallback every client can read
Brotli (RFC 7932)Single streamText over HTTP with br accepted
Zstandard (RFC 8878)Single stream or frame setLarge artifacts where speed matters
Zip (PKZIP APPNOTE)Archive with central directoryBinary releases for Windows and macOS
tar plus gzipArchive inside one streamSource 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.

FieldRecorded inPin it by
Member mtimetar and zip headersOne SOURCE_DATE_EPOCH for all members
Owner and groupustar header fieldsuid and gid 0, empty names
Entry orderThe order bytes appear inSorting names under LC_ALL=C
gzip name and timeRFC 1952 headergzip -n, or mtime set to zero
Member pathEvery member nameOne 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.

StepMust already be finalMove it earlier and
Bump the versionThe change setTwo artifacts claim one number
Fix time and modesThe file listThe digest certifies other bytes
Pack the archivePaths, modes, orderA stray prefix lands in consumer trees
Compute digestsEvery byte of the archiveThe manifest describes a ghost
Sign the manifestThe finished digest listBytes go public unsigned
Upload and move the pointerThe signed manifestReaders 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.

Try it now