Skip to content

Reproducibility

Reproducibility #9

name: Reproducibility
# Validates that the published archives for a release tag can be byte-
# reproduced from source.
#
# - Linux + macOS .tar.gz archives are compared whole (deterministic
# packaging via scripts/pack.py).
# - Windows .zip archives embed an Authenticode-signed .exe and are
# therefore not byte-reproducible at the .zip level. For those, the
# reproduce-windows job strips the Authenticode signature off the
# published .exe and compares its sha256 against a locally-rebuilt
# unsigned .exe (issue #78, Option E).
#
# Triggers:
# * workflow_run on "Release" success — automatically validates each new tag.
# * workflow_dispatch — manual revalidation; supply the tag (e.g. v0.2.1).
on:
workflow_run:
workflows: ["Release"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to validate (e.g. v0.2.1)'
required: true
type: string
permissions:
contents: read
jobs:
resolve-tag:
name: Resolve tag
runs-on: ubuntu-latest
# Skip auto-runs that come from a failed Release workflow.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
outputs:
tag: ${{ steps.tag.outputs.tag }}
version: ${{ steps.tag.outputs.version }}
steps:
- id: tag
shell: bash
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG="${{ inputs.tag }}"
else
TAG="${{ github.event.workflow_run.head_branch }}"
fi
if [[ -z "$TAG" || "$TAG" != v* ]]; then
echo "::error::Could not resolve a release tag (got '$TAG')"
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
reproduce:
name: Reproduce ${{ matrix.name }}
needs: resolve-tag
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
# Mirror the non-Windows entries of release.yml so each rebuild
# uses the same os/target pairing as the original release.
matrix:
include:
- os: ubuntu-22.04
target: x86_64-linux
name: linux-x64
- os: ubuntu-22.04-arm
target: aarch64-linux
name: linux-arm64
- os: macos-14
target: aarch64-macos
name: macos-arm64
- os: ubuntu-22.04
target: x86_64-macos
name: macos-x64
- os: ubuntu-22.04
target: x86_64-linux-musl
name: linux-musl-x64
- os: ubuntu-22.04
target: aarch64-linux-musl
name: linux-musl-arm64
steps:
- name: Checkout source at ${{ needs.resolve-tag.outputs.tag }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: refs/tags/${{ needs.resolve-tag.outputs.tag }}
# Need full history so `git log -1 --pretty=%ct` on the tagged
# commit works for SOURCE_DATE_EPOCH.
fetch-depth: 0
- name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
with:
version: 0.16.0
- name: Compute SOURCE_DATE_EPOCH
shell: bash
run: |
SDE=$(git log -1 --pretty=%ct HEAD)
echo "SOURCE_DATE_EPOCH=$SDE" >> "$GITHUB_ENV"
echo "Using SOURCE_DATE_EPOCH=$SDE"
- name: Build
run: zig build -Doptimize=ReleaseSafe -Dtarget=${{ matrix.target }} -Dstrip=true "-Dversion=${{ needs.resolve-tag.outputs.version }}"
- name: Repackage deterministically
shell: bash
run: |
set -euo pipefail
PKGNAME="ghr-${{ needs.resolve-tag.outputs.version }}-${{ matrix.name }}"
mkdir -p "rebuilt/$PKGNAME/bin"
cp "zig-out/bin/ghr" "rebuilt/$PKGNAME/bin/"
cp LICENSE README.md "rebuilt/$PKGNAME/" 2>/dev/null || true
( cd rebuilt && python3 ../scripts/pack.py "$PKGNAME" "$PKGNAME.tar.gz" )
echo "PKGNAME=$PKGNAME" >> "$GITHUB_ENV"
- name: Download published archive + sha256
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p published
cd published
gh release download "${{ needs.resolve-tag.outputs.tag }}" \
--repo "${{ github.repository }}" \
--pattern "${PKGNAME}.tar.gz" \
--pattern "${PKGNAME}.tar.gz.sha256"
ls -la
- name: Verify published sha256 matches its archive
shell: bash
run: |
set -euo pipefail
cd published
# The sidecar holds the published hash; this just confirms the
# downloaded archive matches what GitHub served alongside it.
if command -v sha256sum >/dev/null; then
sha256sum -c "${PKGNAME}.tar.gz.sha256"
else
shasum -a 256 -c "${PKGNAME}.tar.gz.sha256"
fi
- name: Compare published vs rebuilt sha256
id: compare
shell: bash
run: |
set -euo pipefail
PUB=$(awk '{print $1}' "published/${PKGNAME}.tar.gz.sha256")
NEW=$(awk '{print $1}' "rebuilt/${PKGNAME}.tar.gz.sha256")
echo "published=$PUB" >> "$GITHUB_OUTPUT"
echo "rebuilt=$NEW" >> "$GITHUB_OUTPUT"
{
echo "### ${{ matrix.name }}"
echo ""
echo "| | sha256 |"
echo "|---|---|"
echo "| published | \`$PUB\` |"
echo "| rebuilt | \`$NEW\` |"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$PUB" == "$NEW" ]]; then
echo "result=match" >> "$GITHUB_OUTPUT"
echo "✅ REPRODUCIBLE: ${{ matrix.name }}"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "✅ Reproducible." >> "$GITHUB_STEP_SUMMARY"
else
echo "result=mismatch" >> "$GITHUB_OUTPUT"
echo "::error::Reproducibility mismatch for ${{ matrix.name }}: published=$PUB rebuilt=$NEW"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "❌ Mismatch — see diagnostics step for details." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Diagnose mismatch
if: steps.compare.outputs.result == 'mismatch'
shell: bash
run: |
set -uo pipefail
mkdir -p diag/published diag/rebuilt
tar -xzf "published/${PKGNAME}.tar.gz" -C diag/published
tar -xzf "rebuilt/${PKGNAME}.tar.gz" -C diag/rebuilt
echo "=== Archive metadata (tar tvf) — published ==="
tar tvf "published/${PKGNAME}.tar.gz" | sort > diag/published.tar.list
cat diag/published.tar.list
echo "=== Archive metadata (tar tvf) — rebuilt ==="
tar tvf "rebuilt/${PKGNAME}.tar.gz" | sort > diag/rebuilt.tar.list
cat diag/rebuilt.tar.list
echo "=== Metadata diff ==="
diff -u diag/published.tar.list diag/rebuilt.tar.list || true
echo "=== Binary diff (first 100 byte differences) ==="
cmp -l "diag/published/${PKGNAME}/bin/ghr" "diag/rebuilt/${PKGNAME}/bin/ghr" | head -100 || true
echo "=== sha256 of extracted binary ==="
if command -v sha256sum >/dev/null; then
sha256sum "diag/published/${PKGNAME}/bin/ghr" "diag/rebuilt/${PKGNAME}/bin/ghr"
else
shasum -a 256 "diag/published/${PKGNAME}/bin/ghr" "diag/rebuilt/${PKGNAME}/bin/ghr"
fi
# Fail the job so the matrix entry is red.
exit 1
- name: Upload diagnostic artifacts
if: always() && steps.compare.outputs.result == 'mismatch'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: repro-diag-${{ matrix.name }}
path: |
published/
rebuilt/
diag/
# ---------------------------------------------------------------------------
# Windows reproducibility (issue #78, Option E).
#
# The published Windows .zip embeds an Authenticode-signed .exe, so the
# .zip itself is intentionally not byte-reproducible. We compare the
# signed .exe against a locally-rebuilt unsigned .exe by stripping the
# signature off the published one. The strip is performed by a
# host-native ghr binary we build in the same job (the cross-target
# rebuild — e.g. aarch64-windows — isn't necessarily runnable on the
# x64 runner).
# ---------------------------------------------------------------------------
reproduce-windows:
name: Reproduce ${{ matrix.name }}
needs: resolve-tag
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-windows
name: windows-x64
- os: windows-latest
target: aarch64-windows
name: windows-arm64
steps:
- name: Checkout source at ${{ needs.resolve-tag.outputs.tag }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: refs/tags/${{ needs.resolve-tag.outputs.tag }}
fetch-depth: 0
- name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
with:
version: 0.16.0
- name: Compute SOURCE_DATE_EPOCH
shell: bash
run: |
SDE=$(git log -1 --pretty=%ct HEAD)
echo "SOURCE_DATE_EPOCH=$SDE" >> "$GITHUB_ENV"
echo "Using SOURCE_DATE_EPOCH=$SDE"
- name: Build host stripper (x86_64-windows native)
shell: bash
run: |
# No -Dtarget: builds for the host. On windows-latest this is
# x86_64-windows; the resulting ghr.exe is runnable directly
# and supplies `validate strip-authenticode` for the strip
# step below. We don't care that this binary is itself
# reproducible — only that it executes correctly.
zig build -Doptimize=ReleaseSafe -Dstrip=true "-Dversion=${{ needs.resolve-tag.outputs.version }}"
mv zig-out/bin/ghr.exe stripper.exe
./stripper.exe version
- name: Build target unsigned ghr.exe
shell: bash
run: |
rm -rf zig-out
zig build -Doptimize=ReleaseSafe -Dtarget=${{ matrix.target }} -Dstrip=true "-Dversion=${{ needs.resolve-tag.outputs.version }}"
PKGNAME="ghr-${{ needs.resolve-tag.outputs.version }}-${{ matrix.name }}"
echo "PKGNAME=$PKGNAME" >> "$GITHUB_ENV"
ls -l zig-out/bin/ghr.exe
- name: Download published .zip + .sha256
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p published
cd published
gh release download "${{ needs.resolve-tag.outputs.tag }}" \
--repo "${{ github.repository }}" \
--pattern "${PKGNAME}.zip" \
--pattern "${PKGNAME}.zip.sha256"
ls -la
- name: Verify published sha256 matches its archive
shell: bash
run: |
set -euo pipefail
cd published
# GitHub-served sha256 of the signed zip — we don't compare
# this to anything, but confirm the download is internally
# consistent.
if command -v sha256sum >/dev/null; then
sha256sum -c "${PKGNAME}.zip.sha256"
else
shasum -a 256 -c "${PKGNAME}.zip.sha256"
fi
- name: Extract published .exe
shell: bash
run: |
set -euo pipefail
mkdir -p published-extract
unzip -o "published/${PKGNAME}.zip" -d published-extract
ls -l "published-extract/${PKGNAME}/bin/ghr.exe"
- name: Strip Authenticode signature off published .exe
shell: bash
run: |
set -euo pipefail
./stripper.exe validate strip-authenticode \
"published-extract/${PKGNAME}/bin/ghr.exe" \
"stripped.exe"
ls -l stripped.exe
- name: Compare stripped published vs rebuilt
id: compare
shell: bash
run: |
set -euo pipefail
if command -v sha256sum >/dev/null; then
HASHER="sha256sum"
else
HASHER="shasum -a 256"
fi
STRIPPED=$($HASHER stripped.exe | awk '{print $1}')
REBUILT=$($HASHER zig-out/bin/ghr.exe | awk '{print $1}')
echo "stripped=$STRIPPED" >> "$GITHUB_OUTPUT"
echo "rebuilt=$REBUILT" >> "$GITHUB_OUTPUT"
{
echo "### ${{ matrix.name }}"
echo ""
echo "| | sha256 |"
echo "|---|---|"
echo "| stripped published | \`$STRIPPED\` |"
echo "| rebuilt | \`$REBUILT\` |"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$STRIPPED" == "$REBUILT" ]]; then
echo "result=match" >> "$GITHUB_OUTPUT"
echo "✅ REPRODUCIBLE: ${{ matrix.name }} (stripped == rebuilt)"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "✅ Reproducible (Option E: signed → strip == unsigned rebuild)." >> "$GITHUB_STEP_SUMMARY"
else
echo "result=mismatch" >> "$GITHUB_OUTPUT"
echo "::error::Reproducibility mismatch for ${{ matrix.name }}: stripped=$STRIPPED rebuilt=$REBUILT"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "❌ Mismatch — see diagnostics step for details." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Diagnose mismatch
if: steps.compare.outputs.result == 'mismatch'
shell: bash
run: |
set -uo pipefail
mkdir -p diag
if command -v sha256sum >/dev/null; then
sha256sum stripped.exe zig-out/bin/ghr.exe > diag/sha256.txt || true
else
shasum -a 256 stripped.exe zig-out/bin/ghr.exe > diag/sha256.txt || true
fi
echo "=== sha256 ==="
cat diag/sha256.txt
echo "=== sizes ==="
ls -l stripped.exe zig-out/bin/ghr.exe
echo "=== Binary diff (first 200 byte differences) ==="
cmp -l stripped.exe zig-out/bin/ghr.exe | head -200 || true
# The first ~512 bytes contain the DOS + NT headers; print
# them side-by-side to make any header-level drift obvious.
echo "=== Header dump: stripped ==="
od -An -t x1 -N 512 stripped.exe | head -32
echo "=== Header dump: rebuilt ==="
od -An -t x1 -N 512 zig-out/bin/ghr.exe | head -32
exit 1
- name: Upload diagnostic artifacts
if: always() && steps.compare.outputs.result == 'mismatch'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: repro-diag-${{ matrix.name }}
path: |
published/
published-extract/
stripped.exe
zig-out/bin/ghr.exe
diag/