diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc1bf2ee6..6964168f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,15 @@ jobs: go-version-file: go.mod cache: true + - name: Verify clean checkout + run: | + set -euo pipefail + if [ -n "$(git status --porcelain)" ]; then + echo "Refusing to release from a dirty working tree:" >&2 + git status --porcelain >&2 + exit 1 + fi + - name: Compute release variables run: | set -euo pipefail @@ -84,6 +93,11 @@ jobs: LDFLAGS: ${{ env.LDFLAGS }} PKG_VERSION: ${{ env.PKG_VERSION }} + - name: Verify binary provenance stamps + run: | + set -euo pipefail + buildscripts/verify-build-provenance.sh + - name: Install nFPM run: | set -euo pipefail diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 8f453404b..9dd4f4fb5 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -5,10 +5,16 @@ on: pull_request: paths: - ".github/goreleaser.yml" + - ".github/nfpm.yml" - "Dockerfile.goreleaser" - "minio.service" + - "buildscripts/package-release.sh" + - "buildscripts/sign-release-rpms.sh" + - "buildscripts/verify-build-provenance.sh" + - "buildscripts/gen-ldflags.go" - ".github/workflows/release.yml" - ".github/workflows/test-release.yml" + - ".gitignore" permissions: contents: read @@ -35,9 +41,11 @@ jobs: VERSION_COLON="2026-02-14T12:00:00Z" PKG_VERSION="20260214120000.0.0" LDFLAGS="$(MINIO_RELEASE=RELEASE go run buildscripts/gen-ldflags.go "${VERSION_COLON}")" - echo "RELEASE_TAG=${RELEASE_TAG}" >> "${GITHUB_ENV}" - echo "PKG_VERSION=${PKG_VERSION}" >> "${GITHUB_ENV}" - echo "LDFLAGS=${LDFLAGS}" >> "${GITHUB_ENV}" + { + echo "RELEASE_TAG=${RELEASE_TAG}" + echo "PKG_VERSION=${PKG_VERSION}" + echo "LDFLAGS=${LDFLAGS}" + } >> "${GITHUB_ENV}" echo "PKG_VERSION: ${PKG_VERSION}" echo "LDFLAGS: ${LDFLAGS}" @@ -56,27 +64,61 @@ jobs: LDFLAGS: ${{ env.LDFLAGS }} PKG_VERSION: ${{ env.PKG_VERSION }} - - name: Install nFPM + - name: Verify binary provenance stamps + run: | + set -euo pipefail + buildscripts/verify-build-provenance.sh + + - name: Install package validation tools run: | set -euo pipefail go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0 echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}" + sudo apt-get update + sudo apt-get install --yes rpm binutils - name: Package snapshot binaries with nFPM run: | set -euo pipefail buildscripts/package-release.sh - - name: Verify package metadata - working-directory: dist/packages + - name: Validate package names, checksums, metadata, and payload run: | set -euo pipefail + cd dist/packages + + # These are the public download names; a drift here breaks every + # script that fetches packages by URL. + expected=( + "minio-${PKG_VERSION}-1.aarch64.rpm" + "minio-${PKG_VERSION}-1.x86_64.rpm" + "minio_${PKG_VERSION}_aarch64.apk" + "minio_${PKG_VERSION}_amd64.deb" + "minio_${PKG_VERSION}_arm64.deb" + "minio_${PKG_VERSION}_x86_64.apk" + ) + + for package in "${expected[@]}"; do + test -s "${package}" + test -s "${package}.sha256sum" + sha256sum --check "${package}.sha256sum" + done + + test "$(find . -maxdepth 1 -type f \( -name '*.rpm' -o -name '*.deb' -o -name '*.apk' \) | wc -l)" -eq 6 # The signing script asserts these same values, but it runs on the # maintainer's machine after the release workflow has already built # and uploaded. Take its expectations as the single source of truth # so nfpm.yml and the signing script cannot drift apart without # failing here first, while a fix is still cheap. + # + # This grep is deliberately limited to the seven identity variables, + # all of which are single-line. That is what makes the eval safe: + # should one ever become multi-line, the grep captures an + # unterminated quote and the eval aborts on a syntax error under + # set -e rather than quietly binding an empty value and comparing + # against nothing. expected_payload is multi-line by design and must + # stay out of this set for the same reason. eval "$(grep -E '^expected_(vendor|packager|url|summary|description|license|group)=' \ ../../buildscripts/sign-release-rpms.sh)" for value in "${expected_vendor}" "${expected_packager}" "${expected_url}" \ @@ -85,6 +127,8 @@ jobs: test -n "${value}" done + service_sha="$(sha256sum ../../minio.service | awk '{print $1}')" + rpm_file="minio-${PKG_VERSION}-1.x86_64.rpm" test "$(rpm -qp --queryformat '%{VENDOR}' "${rpm_file}")" = "${expected_vendor}" test "$(rpm -qp --queryformat '%{PACKAGER}' "${rpm_file}")" = "${expected_packager}" @@ -98,6 +142,7 @@ jobs: # nothing to start it. rpm -qpl "${rpm_file}" | grep -Fx '/usr/local/bin/minio' rpm -qpl "${rpm_file}" | grep -Fx '/lib/systemd/system/minio.service' + test "$(rpm -qpl "${rpm_file}" | wc -l)" -eq 2 deb_file="minio_${PKG_VERSION}_amd64.deb" test "$(dpkg-deb --field "${deb_file}" Maintainer)" = "${expected_packager}" @@ -116,5 +161,64 @@ jobs: grep -Fx "license = ${expected_license}" <<< "${apk_info}" grep -Fx "pkgdesc = ${expected_description}" <<< "${apk_info}" tar -tzf "minio_${PKG_VERSION}_x86_64.apk" | grep -Fx 'usr/local/bin/minio' + tar -tzf "minio_${PKG_VERSION}_x86_64.apk" | grep -Fx 'lib/systemd/system/minio.service' + + for arch in amd64 arm64; do + if [ "${arch}" = amd64 ]; then + rpm_arch=x86_64 + deb_arch=amd64 + apk_arch=x86_64 + else + rpm_arch=aarch64 + deb_arch=arm64 + apk_arch=aarch64 + fi + + test "$(rpm -qp --queryformat '%{ARCH}' "minio-${PKG_VERSION}-1.${rpm_arch}.rpm")" = "${rpm_arch}" + test "$(dpkg-deb --field "minio_${PKG_VERSION}_${deb_arch}.deb" Architecture)" = "${deb_arch}" + grep -Fx "arch = ${apk_arch}" <<< "$(tar -xOzf "minio_${PKG_VERSION}_${apk_arch}.apk" .PKGINFO)" + + # Accepted weakness: this takes the first match, unsorted, where + # find_binary in package-release.sh demands exactly one. It cannot + # be reached with an ambiguous match today, because packaging runs + # earlier in this same job and hard-fails on one. Revisit if + # goamd64 gains a second level, or if find_binary's exactly-one + # contract is ever relaxed -- at that point this weak copy would be + # the only one left choosing silently. + source_binary="$(find .. -maxdepth 2 -type f -path "../minio_linux_${arch}*/minio" | head -n 1)" + source_sha="$(sha256sum "${source_binary}" | awk '{print $1}')" + + # Do not pipe rpm2cpio here: Debian's build exits non-zero even when + # it writes a correct payload, which trips `set -o pipefail`. Use + # rpm's own digests instead -- -K checks the payload against the + # header, and FILEDIGESTS is the sha256 rpm itself verifies on + # install. + rpm -K "minio-${PKG_VERSION}-1.${rpm_arch}.rpm" + rpm_sha="$(rpm -qp --queryformat '[%{FILENAMES} %{FILEDIGESTS}\n]' \ + "minio-${PKG_VERSION}-1.${rpm_arch}.rpm" | awk '$1 == "/usr/local/bin/minio" { print $2 }')" + rpm_service_sha="$(rpm -qp --queryformat '[%{FILENAMES} %{FILEDIGESTS}\n]' \ + "minio-${PKG_VERSION}-1.${rpm_arch}.rpm" | awk '$1 == "/lib/systemd/system/minio.service" { print $2 }')" + deb_sha="$(ar p "minio_${PKG_VERSION}_${deb_arch}.deb" data.tar.gz | tar -xzOf - ./usr/local/bin/minio | sha256sum | awk '{print $1}')" + deb_service_sha="$(ar p "minio_${PKG_VERSION}_${deb_arch}.deb" data.tar.gz | tar -xzOf - ./lib/systemd/system/minio.service | sha256sum | awk '{print $1}')" + apk_sha="$(tar -xzOf "minio_${PKG_VERSION}_${apk_arch}.apk" usr/local/bin/minio | sha256sum | awk '{print $1}')" + apk_service_sha="$(tar -xzOf "minio_${PKG_VERSION}_${apk_arch}.apk" lib/systemd/system/minio.service | sha256sum | awk '{print $1}')" + + test "${source_sha}" = "${rpm_sha}" + test "${source_sha}" = "${deb_sha}" + test "${source_sha}" = "${apk_sha}" + test "${service_sha}" = "${rpm_service_sha}" + test "${service_sha}" = "${deb_service_sha}" + test "${service_sha}" = "${apk_service_sha}" + done find . -maxdepth 1 -type f | sort + + - name: Validate release scripts + run: | + set -euo pipefail + bash -n buildscripts/package-release.sh + bash -n buildscripts/sign-release-rpms.sh + bash -n buildscripts/verify-build-provenance.sh + test -x buildscripts/package-release.sh + test -x buildscripts/sign-release-rpms.sh + test -x buildscripts/verify-build-provenance.sh diff --git a/buildscripts/verify-build-provenance.sh b/buildscripts/verify-build-provenance.sh new file mode 100755 index 000000000..5679a283e --- /dev/null +++ b/buildscripts/verify-build-provenance.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Asserts that every binary GoReleaser produced is stamped by the Go toolchain +# as built from this exact commit with a clean working tree. A stray untracked +# file (for example an un-ignored dist/) silently turns every release binary +# into a "+dirty" pseudo-version, which destroys the link between a published +# artifact and its tag. Catch that here instead of after publishing. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd "${script_dir}/.." && pwd)" +dist_dir="${DIST_DIR:-${repo_dir}/dist}" +expected_count="${EXPECTED_BINARY_COUNT:-6}" + +if ! command -v go >/dev/null 2>&1; then + echo "go is required" >&2 + exit 1 +fi + +if [ ! -d "${dist_dir}" ]; then + echo "Missing GoReleaser dist directory: ${dist_dir}" >&2 + exit 1 +fi + +revision="$(git -C "${repo_dir}" rev-parse HEAD)" + +count=0 +while IFS= read -r binary; do + count=$((count + 1)) + info="$(go version -m "${binary}")" + + if ! grep -qF "vcs.revision=${revision}" <<< "${info}"; then + echo "Unexpected vcs.revision in ${binary} (expected ${revision})" >&2 + grep -F 'vcs.' <<< "${info}" >&2 || true + exit 1 + fi + + if ! grep -qF 'vcs.modified=false' <<< "${info}"; then + echo "Binary was built from a dirty working tree: ${binary}" >&2 + grep -F 'vcs.' <<< "${info}" >&2 || true + exit 1 + fi +done < <(find "${dist_dir}" -maxdepth 2 -type f \( -name 'minio' -o -name 'minio.exe' \) | sort) + +if [ "${count}" -ne "${expected_count}" ]; then + echo "Expected ${expected_count} release binaries, found ${count}" >&2 + exit 1 +fi + +echo "Verified ${count} binaries built from ${revision} with a clean tree"