fix: normalize file modes when packaging a release

release.sh normalized entry order, ownership and mtimes, but not permissions,
so the archives inherited the builder's umask. A host with umask 002 packaged
664/775 while ubuntu-latest packaged 644/755, and the two archives hashed
differently even though every file inside was byte-identical:

  CI     -rw-r--r--  README.md   local  -rw-rw-r--  README.md
  CI     -rwxr-xr-x  sshkeeper   local  -rwxrwxr-x  sshkeeper

Force 755 on directories and the program, 644 on everything else. Building the
same commit under umask 002 and umask 022 now yields identical checksums.

Also correct the reproducibility claim in the release docs. What is reproducible
is the binary, given the same commit and Go version; the archive hash still
depends on the host tar and gzip, so the documented verification step now
compares the extracted binary instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mirivlad 2026-08-18 19:06:12 +08:00
parent 0a02f0fc60
commit 878f7b4472
2 changed files with 38 additions and 3 deletions

View File

@ -13,7 +13,8 @@ runs, and how to reproduce it by hand when needed.
| `nightly.yml` | push to `main` | rebuilds the tip of `main` and replaces the `nightly` prerelease |
`release.yml` builds through `release.sh` rather than reimplementing packaging,
so a local run produces byte-identical archives.
so CI and a local run stay in step. See [Reproducibility](#reproducibility) for
what that guarantees.
### Release notes
@ -123,6 +124,30 @@ sha256sum -c checksums.txt
Expected result: every archive reports `OK`.
## Reproducibility
Rebuilding the same commit with the same Go version reproduces the **binaries**
byte for byte. `release.sh` pins everything that would otherwise vary:
- `-trimpath` and `CGO_ENABLED=0` keep build paths and the host toolchain out
of the binary;
- `SOURCE_DATE_EPOCH` (the commit timestamp) sets every archive mtime;
- `tar --sort=name --owner=0 --group=0 --numeric-owner` fixes entry order and
ownership, and `gzip -n` drops the compression timestamp;
- `normalize_package` forces 755 on directories and the program and 644 on
everything else, so the builder's umask cannot leak into the archive.
To check a published build, compare the binary inside the archive rather than
the archive hash:
```bash
tar -xzf sshkeeper_<version>_linux_amd64.tar.gz
sha256sum sshkeeper_<version>_linux_amd64/sshkeeper
```
The archive hash additionally depends on the `tar` and `gzip` implementations
on the build host, so it is the weaker check of the two.
## Publish in GitHub Release
`release.yml` does this automatically on tag push. To publish by hand, upload:

View File

@ -28,6 +28,16 @@ package_docs() {
normalize_package() {
local package_dir="$1"
local binary="$2"
# Permissions must not depend on the builder's umask. Without this, a host
# with umask 002 packages 664/775 while one with umask 022 packages
# 644/755, and the archives differ even though every file inside is
# byte-identical.
find "${package_dir}" -type d -exec chmod 755 {} +
find "${package_dir}" -type f -exec chmod 644 {} +
chmod 755 "${package_dir}/${binary}"
find "${package_dir}" -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} +
}
@ -43,7 +53,7 @@ build_tarball() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" .
package_docs "${package_dir}"
normalize_package "${package_dir}"
normalize_package "${package_dir}" "${APP}"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH}" -cf - -C "${DIST_DIR}" "$(basename "${package_dir}")" | gzip -n > "${archive}"
rm -rf "${package_dir}"
}
@ -60,7 +70,7 @@ build_zip() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}.exe" .
package_docs "${package_dir}"
normalize_package "${package_dir}"
normalize_package "${package_dir}" "${APP}.exe"
(cd "${DIST_DIR}" && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
rm -rf "${package_dir}"
}