diff --git a/docs/release.md b/docs/release.md index 97df88f..95e3c93 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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__linux_amd64.tar.gz +sha256sum sshkeeper__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: diff --git a/release.sh b/release.sh index 0a482c6..524f93f 100755 --- a/release.sh +++ b/release.sh @@ -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}" }