Reproducibility #10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/ |