diff --git a/CMakeLists.txt b/CMakeLists.txt index b6a9ce7..7b1eafc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,41 @@ if(ENABLE_UNITY_BUILD) set(CMAKE_UNITY_BUILD_BATCH_SIZE 8) endif() +# ── Reproducible-build support ───────────────────────────────────────────── +# REPRODUCIBLE_BUILD=ON strips absolute source paths from the final binary +# via -ffile-prefix-map. Two builds of the same commit with the same +# toolchain then produce byte-identical binaries (modulo any source paths +# that aren't routed through the macro — see scripts/verify-reproducible-build.sh +# for the full verification protocol). +# +# Default ON: this is a security property we want by default. Disable if +# you need stack traces with absolute paths (e.g. debugging a post-mortem). +option(REPRODUCIBLE_BUILD "Strip absolute source paths from binaries for reproducibility" ON) +if(REPRODUCIBLE_BUILD) + add_compile_options( + "-ffile-prefix-map=${CMAKE_SOURCE_DIR}=." + "-ffile-prefix-map=${CMAKE_BINARY_DIR}=." + ) + # SOURCE_DATE_EPOCH is the canonical reproducible-build env var + # (https://reproducible-builds.org/docs/source-date-epoch/). If the + # user hasn't set it explicitly, fall back to the commit timestamp from + # git. This means binaries built without SOURCE_DATE_EPOCH still embed + # a deterministic timestamp (the commit time, not wall-clock). + if(NOT DEFINED ENV{SOURCE_DATE_EPOCH}) + execute_process( + COMMAND git log -n 1 --format=%ct + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + OUTPUT_VARIABLE SOURCE_DATE_EPOCH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + if(NOT SOURCE_DATE_EPOCH) + set(SOURCE_DATE_EPOCH "1700000000") # 2023-11-14 fallback + endif() + endif() + message(STATUS "Reproducible build: ON (SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH})") +endif() + # ── Output directories ── set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") diff --git a/release-process.md b/release-process.md new file mode 100644 index 0000000..b47b5a2 --- /dev/null +++ b/release-process.md @@ -0,0 +1,234 @@ +# Triangles Release Process + +> Canonical release pipeline for `SamiAhmed7777/triangles_v5`. This document +> is the source of truth for *how* a release is cut. The implementation lives +> in `scripts/verify-reproducible-build.sh` and `scripts/sign-release.sh`. + +## Goals + +1. **Reproducible** — any two builders with the same source tree, same + toolchain, and same flags produce byte-identical binaries. +2. **Signed** — every release artifact has a detached PGP signature that + verifiers can check against a known public key. +3. **Verifiable end-to-end** — a third party can confirm a release is + legitimate using only `gpg` and `sha256sum`, both installed by default + on every Linux distribution. + +## Pipeline overview + +``` + source tag (e.g. v6.1.4) + │ + ▼ + ┌─────────────────────┐ + │ CI builds all 4 │ .github/workflows/build-all.yml + │ targets on each │ (ubuntu / windows / macos) + │ platform │ + └──────────┬───────────┘ + │ produces: daemon.tar.gz, qt.tar.gz, .deb, .dmg, .exe, ... + ▼ + ┌─────────────────────┐ + │ Local maintainer │ scripts/sign-release.sh + │ signs artifacts │ (uses release signing key in local keyring) + └──────────┬───────────┘ + │ produces: SHA256SUMS, *.asc detached signatures + ▼ + ┌─────────────────────┐ + │ Push to GitHub │ .github/workflows/distribute.yml + │ release + Docker │ (uploads artifacts, builds Docker image, + │ + Homebrew tap + │ updates Homebrew formula, submits + │ WinGet + Snap │ WinGet + Snap PRs) + └──────────┬───────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Verifier │ scripts/sign-release.sh --verify + │ independently │ + gpg --import + │ confirms │ + └─────────────────────┘ +``` + +## Reproducibility — how it works today + +The Triangles build is already reproducible for Release builds with the +following properties: + +| Property | Implementation | +|---|---| +| `BUILD_DESC` | Git describe output, written to `build.h` at build time | +| `BUILD_DATE` | **Commit timestamp** (NOT wall-clock), from `git log -n 1 --format=%ci` | +| `__DATE__`/`__TIME__` fallback | Dead code in practice — `build.h` always defines `BUILD_DATE` | +| Build paths in binaries | Currently absolute; should add `-ffile-prefix-map` for full reproducibility | + +### Verifying reproducibility + +Run on a clean checkout: + +```bash +scripts/verify-reproducible-build.sh +``` + +This builds `trianglesd` twice into two separate build directories and +compares SHA256 hashes. Exits 0 on success. + +Options: +- `BUILD_TYPE=Debug scripts/verify-reproducible-build.sh` +- `TARGET=triangles-qt scripts/verify-reproducible-build.sh` +- `BUILD_DIR_A=/tmp/A BUILD_DIR_B=/tmp/B scripts/verify-reproducible-build.sh` + +## Signing — how it works + +### Generate (or import) a release signing key + +**One-time setup** (the maintainer's machine): + +```bash +# Generate a fresh Ed25519 signing subkey under your existing PGP master. +# Ed25519 is preferred over RSA-4096: smaller signatures, faster, quantum-resistant +# at the security level we need for code-signing. +gpg --quick-generate-key 'Sami Ahmed ' ed25519 sign never + +# Print the public key block to publish on the website / GitHub. +gpg --armor --export 'sami@cryptographic-triangles.org' > release-pubkey.asc + +# Export your secret key BACKUP. Store this on airgapped / offline media. +# Without this backup, lost local keyring = lost ability to sign new releases. +gpg --export-secret-keys 'sami@cryptographic-triangles.org' > release-seckey-BACKUP.asc +chmod 600 release-seckey-BACKUP.asc +``` + +**Import an existing key** (e.g. on a new maintainer machine): + +```bash +gpg --import release-seckey-BACKUP.asc +``` + +### Sign a release directory + +After CI has produced the artifacts in a known directory: + +```bash +scripts/sign-release.sh /path/to/release-dir +``` + +This will: +1. Generate `SHA256SUMS` for every release artifact (.tar.gz, .deb, .dmg, + .exe, .zip, .AppImage) +2. Write a detached PGP signature (`.asc`) for each artifact +3. Write a detached PGP signature over `SHA256SUMS` itself +4. Refuse to run if the signing key isn't in the local keyring (safety) + +### Verify a release + +A third party (user, exchange, package maintainer) verifies with: + +```bash +# 1. Import the public key (one-time). +gpg --import release-pubkey.asc + +# 2. Verify everything in the release directory. +scripts/sign-release.sh --verify /path/to/release-dir +``` + +This checks: +- `SHA256SUMS.asc` against `SHA256SUMS` (the master signature) +- Each `.asc` against its `` (belt-and-suspenders) +- Each artifact's SHA256 against `SHA256SUMS` (integrity) + +## Why both per-artifact signatures AND a SHA256SUMS signature? + +- **SHA256SUMS + signature**: small, fast to verify, single point of trust. + If the SHA256SUMS.asc checks out and a file's SHA256 matches an entry, + you're done — you trust that entry. +- **Per-artifact signatures**: defense against a hypothetical attack where + someone modifies `SHA256SUMS` but not the artifacts (or vice versa). + Two independent signature chains. + +For most verifiers, checking `SHA256SUMS.asc` + `sha256sum -c SHA256SUMS` +is sufficient. The per-artifact .asc files are insurance. + +## CI integration + +`.github/workflows/build-all.yml` already produces the artifacts. The +remaining work (separate PR) is to add a "sign" job that runs +`scripts/sign-release.sh` against the assembled release directory using a +key stored as a GitHub Actions secret. + +**Required secrets (one-time setup in repo Settings → Secrets):** +- `GPG_PRIVATE_KEY` — base64-encoded `release-seckey-BACKUP.asc` + (see [GitHub docs on encrypted secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets)) +- `GPG_PASSPHRASE` — passphrase for the signing key (if any) +- `GITHUB_TOKEN` — already provided by Actions + +**Suggested job sketch** (in `.github/workflows/build-all.yml` after all +build jobs complete): + +```yaml +sign: + name: Sign release artifacts + needs: [build-linux-daemon, build-linux-qt, build-windows-daemon, build-windows-qt, build-macos] + runs-on: ubuntu-22.04 + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + + - name: Import signing key + run: | + echo "${{ secrets.GPG_PRIVATE_KEY }}" | base64 -d | gpg --import + + - name: Sign artifacts + run: scripts/sign-release.sh release-artifacts/ + env: + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} +``` + +## Public key distribution + +The release public key MUST be published in **at least three independent +places** so a keyserver takedown or DNS hijack cannot prevent verification: + +1. **This repository** — `release-pubkey.asc` at the repo root, committed + on every release tag. +2. **The website** — `https://cryptographic-triangles.org/release-pubkey.asc` +3. **Public keyservers** — submit to `keys.openpgp.org`, `keyserver.ubuntu.com`, + `pgp.mit.edu`. Each is independently operated. + +Distribution list refreshed with every key rotation (rare; treat as +multi-year commitment). + +## Failure modes & recovery + +| Scenario | Recovery | +|---|---| +| Signing key compromised | Revoke via pre-published revocation certificate. Re-cut release. Document incident. | +| Signing key lost (no backup) | Cannot sign new releases. Existing artifacts still verify against the published public key. Treat as catastrophic; re-mint a new key and treat the chain as fork-vulnerable until community updates. | +| Public key not yet distributed | User gets `gpg: Can't check signature: No public key`. Provide clear "first verify the key fingerprint out-of-band" instructions on the website. | +| CI secret leaked | Rotate the signing key immediately; treat all artifacts signed with the old key as suspect. | +| `SHA256SUMS` signed but artifacts don't match | `sha256sum -c` fails. Either an artifact was corrupted in transit, or someone tampered. Re-download from GitHub and re-verify. | + +## Checklist for cutting a release + +- [ ] Source tree is clean (no uncommitted changes) +- [ ] `scripts/verify-reproducible-build.sh` passes (builds are reproducible) +- [ ] All CI jobs on the release tag are green +- [ ] Release artifacts are in a single directory (`release-artifacts/`) +- [ ] `scripts/sign-release.sh release-artifacts/` runs without error +- [ ] `scripts/sign-release.sh --verify release-artifacts/` passes +- [ ] `release-pubkey.asc` is current and committed to the repo +- [ ] GitHub release created with all artifacts + SHA256SUMS + SHA256SUMS.asc +- [ ] `distribute.yml` workflow ran (Docker Hub, Homebrew, WinGet, Snap) +- [ ] Announcement posted (Twitter/Mastodon, Discord/Telegram, mailing list if any) + +## Future work + +- **Reproducibility hardening**: add `-ffile-prefix-map` to compile flags so + absolute source paths don't leak into the binary (would also fix the + simd.c:265 UBSan build-id drift). +- **Gitian-style deterministic builds**: containerized build environment + pinned to a specific GCC/binutils version, so multiple independent + verifiers can rebuild from source and get identical hashes. +- **Transparency log**: publish each release artifact hash to a Sigstore / + sigsum / Certificate Transparency-style log so any tampering is publicly + auditable. +- **Key rotation policy**: document how/when the signing key gets rotated + (probably never, but state the policy). \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md index 65c55bc..734d398 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,29 +1,36 @@ -# Version Bump Script +# Scripts -Updates the version number across all files in the repo from a single command. +Operational scripts for the Triangles project. See also `release-process.md` +at the repo root for the canonical release pipeline documentation. -## Usage +## Build verification -**Set a specific version:** -```bash -bash scripts/bump-version.sh 5.7.0 -``` +- **`verify-reproducible-build.sh`** — builds the daemon (or another target) + twice from the same source tree and verifies the SHA256 hashes match. + Catches accidental introduction of non-determinism (e.g. `__DATE__`/`__TIME__` + regressions, dirty git state, PIE base-address drift). -**Or edit `src/clientversion.h` first, then sync everything else:** -```bash -bash scripts/bump-version.sh -``` +## Release signing -## What it updates +- **`sign-release.sh`** — generates `SHA256SUMS`, writes detached PGP + signatures (`.asc`) over each release artifact and over `SHA256SUMS`. + Supports `--verify` for independent third-party verification. + Uses `TRIANGLES_RELEASE_KEY` env var (defaults to + `sami@cryptographic-triangles.org`). -- `src/clientversion.h` (source of truth) -- `src/version.h` -- `triangles-qt.pro` -- `Dockerfile` -- All packaging manifests (Docker, Snap, Scoop, WinGet, RPM, Flatpak, Debian, AppImage) +## Existing infrastructure -## What still needs manual review after running - -- `packaging/appstream/...metainfo.xml` — add a new `` entry -- `README.md` — update header version if desired -- Any documentation with download URLs +- **`bump-version.sh`** — sync version numbers across all manifests from + `src/clientversion.h`. +- **`sign-snapshot.sh`** — sign a UTXO snapshot file with the wallet's + signing address (not a PGP key; this is a chain-level signature, not a + release signature). +- **`validate_onion_seeds.py`** — validate every `.onion` address in + `triangles.conf` against the v3 hidden-service checksum. +- **`ibd-smoke-test.sh`** — fresh-datadir IBD smoke test for catching the + classic "stalls early / loops around 570" failure mode. +- **`ci/build-rocksdb.sh`** — build and install a pinned RocksDB version + for CI. +- **`ci/package-linux-daemon.sh`** — Linux packaging step (.deb). +- **`ci/package-windows-daemon.sh`** — Windows packaging step. +- **`tri/`** — operator-facing CLI for node administration. diff --git a/scripts/sign-release.sh b/scripts/sign-release.sh new file mode 100755 index 0000000..d84c568 --- /dev/null +++ b/scripts/sign-release.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# sign-release.sh +# +# Sign Triangles release artifacts (the binaries/.debs/.dmgs/.exes built +# by the GitHub Actions release pipeline) with a long-term PGP key, and +# write SHA256SUMS + detached .asc signatures alongside each artifact. +# +# Usage: +# scripts/sign-release.sh /path/to/release-dir +# scripts/sign-release.sh /path/to/release-dir --key 0xDEADBEEF +# scripts/sign-release.sh --verify /path/to/release-dir +# +# Inputs (in the release directory): +# - *.tar.gz, *.deb, *.dmg, *.exe, *.zip, *.AppImage (any release artifact) +# - SHA256SUMS file (if present, re-signed; if absent, generated) +# +# Outputs (written next to each artifact): +# - .asc - detached PGP signature (binary or clearsigned) +# - SHA256SUMS - canonical checksum list (overwrites any existing) +# - SHA256SUMS.asc - detached PGP signature over SHA256SUMS +# +# Verification mode (--verify): +# For each *.asc, runs `gpg --verify` against the artifact. +# Then runs `sha256sum -c SHA256SUMS` if present. +# Exits 0 if all artifacts verify; non-zero on any failure. +# +# Requirements: +# - gpg2 or gpg on PATH +# - Signing key already in the local keyring (or use --key to select) +# - For verification: the signer's public key must be importable +# (either already in the keyring, or fetched from a keyserver) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_KEY="${TRIANGLES_RELEASE_KEY:-sami@cryptographic-triangles.org}" + +usage() { + sed -n '2,30p' "$0" + exit "${1:-1}" +} + +# ── Parse args ───────────────────────────────────────────────────────────── +MODE="sign" +RELEASE_DIR="" +SIGN_KEY="$DEFAULT_KEY" + +while [ $# -gt 0 ]; do + case "$1" in + --verify) + MODE="verify" + shift + ;; + --key) + SIGN_KEY="$2" + shift 2 + ;; + -h|--help) + usage 0 + ;; + *) + RELEASE_DIR="$1" + shift + ;; + esac +done + +if [ -z "$RELEASE_DIR" ]; then + echo "ERROR: release directory required" >&2 + usage 2 +fi + +if [ ! -d "$RELEASE_DIR" ]; then + echo "ERROR: not a directory: $RELEASE_DIR" >&2 + exit 2 +fi + +cd "$RELEASE_DIR" + +# ── Sign mode ────────────────────────────────────────────────────────────── +if [ "$MODE" = "sign" ]; then + command -v gpg >/dev/null || { echo "ERROR: gpg not found" >&2; exit 3; } + + # Verify the signing key actually exists in the keyring (don't want to + # silently create a new key with the same email). + if ! gpg --list-secret-keys "$SIGN_KEY" >/dev/null 2>&1; then + echo "ERROR: signing key '$SIGN_KEY' not found in local keyring" >&2 + echo " import it first: gpg --import " >&2 + exit 3 + fi + + echo "Signing artifacts in $RELEASE_DIR with key $SIGN_KEY..." + + # Generate (or regenerate) SHA256SUMS for every release artifact in the dir. + # Recognized extensions: .tar.gz, .deb, .dmg, .exe, .zip, .AppImage, .dmg.blockmap + # Excludes: .asc files, SHA256SUMS itself, README/notes text files. + ARTIFACTS=() + while IFS= read -r -d '' f; do + case "$f" in + *.asc|SHA256SUMS|SHA256SUMS.asc|*.txt|*.md) continue ;; + esac + ARTIFACTS+=("$f") + done < <(find . -maxdepth 1 -type f -print0 | sort -z) + + if [ ${#ARTIFACTS[@]} -eq 0 ]; then + echo "ERROR: no release artifacts found in $RELEASE_DIR" >&2 + echo " expected: .tar.gz, .deb, .dmg, .exe, .zip, .AppImage" >&2 + exit 4 + fi + + echo " Found ${#ARTIFACTS[@]} artifact(s):" + for a in "${ARTIFACTS[@]}"; do echo " - $a"; done + echo "" + + # Regenerate SHA256SUMS from scratch (deterministic sort). + : > SHA256SUMS + for a in "${ARTIFACTS[@]}"; do + sha256sum "$a" >> SHA256SUMS + done + echo "✓ Wrote SHA256SUMS" + + # Detached signature over each artifact. + for a in "${ARTIFACTS[@]}"; do + rm -f "${a}.asc" + if gpg --batch --yes \ + --local-user "$SIGN_KEY" \ + --armor --detach-sign \ + --output "${a}.asc" \ + "$a" 2>/dev/null; then + echo "✓ Signed ${a}" + else + echo "✗ Failed to sign ${a}" >&2 + exit 5 + fi + done + + # Detached signature over SHA256SUMS (this is what verifiers actually check + # first; individual .asc files are belt-and-suspenders). + rm -f SHA256SUMS.asc + if gpg --batch --yes \ + --local-user "$SIGN_KEY" \ + --armor --detach-sign \ + --output SHA256SUMS.asc \ + SHA256SUMS 2>/dev/null; then + echo "✓ Signed SHA256SUMS" + else + echo "✗ Failed to sign SHA256SUMS" >&2 + exit 5 + fi + + echo "" + echo "Done. To verify from this directory:" + echo " gpg --verify SHA256SUMS.asc SHA256SUMS" + echo " sha256sum -c SHA256SUMS" + echo "" + echo "Or run: $0 --verify $RELEASE_DIR" + exit 0 +fi + +# ── Verify mode ─────────────────────────────────────────────────────────── +if [ "$MODE" = "verify" ]; then + command -v gpg >/dev/null || { echo "ERROR: gpg not found" >&2; exit 3; } + + FAILED=0 + + echo "Verifying signatures in $RELEASE_DIR..." + echo "" + + # Verify SHA256SUMS.asc if present (this is the master signature). + if [ -f SHA256SUMS ] && [ -f SHA256SUMS.asc ]; then + if gpg --verify SHA256SUMS.asc SHA256SUMS 2>/dev/null; then + echo "✓ SHA256SUMS signature: VALID ($(gpg --list-packets < SHA256SUMS.asc 2>/dev/null | grep -oP 'keyid \K[A-F0-9]+' | head -1 || echo unknown))" + else + echo "✗ SHA256SUMS signature: INVALID" + FAILED=$((FAILED + 1)) + fi + else + echo "(no SHA256SUMS / SHA256SUMS.asc; skipping master signature)" + fi + + # Verify each artifact's individual signature. + while IFS= read -r -d '' asc; do + artifact="${asc%.asc}" + if [ ! -f "$artifact" ]; then + echo "✗ $asc: artifact missing ($artifact)" + FAILED=$((FAILED + 1)) + continue + fi + if gpg --verify "$asc" "$artifact" 2>/dev/null; then + echo "✓ $artifact signature: VALID" + else + echo "✗ $artifact signature: INVALID" + FAILED=$((FAILED + 1)) + fi + done < <(find . -maxdepth 1 -name "*.asc" -not -name "SHA256SUMS.asc" -print0 | sort -z) + + # Verify checksums. + if [ -f SHA256SUMS ]; then + echo "" + echo "Verifying checksums..." + if sha256sum -c SHA256SUMS 2>&1 | tail -n +3; then + : # sha256sum -c outputs per-file status; aggregate below + fi + # Count any "FAILED" lines from sha256sum -c output. + CHECKSUM_FAILS="$(sha256sum -c SHA256SUMS 2>&1 | grep -c ': FAILED' || true)" + if [ "$CHECKSUM_FAILS" -gt 0 ]; then + echo "✗ $CHECKSUM_FAILS checksum(s) FAILED" + FAILED=$((FAILED + CHECKSUM_FAILS)) + else + echo "✓ All checksums match SHA256SUMS" + fi + fi + + echo "" + if [ "$FAILED" -eq 0 ]; then + echo "✓ ALL VERIFICATIONS PASSED" + exit 0 + else + echo "✗ $FAILED VERIFICATION(S) FAILED" + exit 1 + fi +fi \ No newline at end of file diff --git a/scripts/verify-reproducible-build.sh b/scripts/verify-reproducible-build.sh new file mode 100755 index 0000000..6f07da1 --- /dev/null +++ b/scripts/verify-reproducible-build.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# verify-reproducible-build.sh +# +# Builds the Triangles daemon (trianglesd) twice from the same source tree +# into two separate build directories, then compares the resulting +# SHA256 hashes. Exits 0 if the two builds produce byte-identical binaries, +# non-zero otherwise. +# +# Usage: +# scripts/verify-reproducible-build.sh # default: trianglesd, Release +# BUILD_TYPE=Debug scripts/verify-reproducible-build.sh # override build type +# TARGET=triangles-qt scripts/verify-reproducible-build.sh # build Qt wallet instead +# +# What "reproducible" means here: +# Given identical source tree, identical compiler toolchain, identical +# build flags, identical SOURCE_DATE_EPOCH (if set) -- the resulting +# binary must hash identically across separate build directories. +# +# This script does NOT enforce compiler version pinning. Two different +# GCC versions will legitimately produce different binaries even with +# identical flags. The verification is "same source + same toolchain = +# same binary." +# +# Pass criteria: +# 1. Both builds succeed +# 2. Both binaries exist +# 3. SHA256 of the two binaries is equal +# +# On failure: prints the two SHA256s and the diff in size so a reviewer +# can investigate. Common causes of non-determinism: +# - __DATE__/__TIME__ embedded (we eliminate this in CMakeLists.txt) +# - absolute paths in __FILE__ (mitigated by -ffile-prefix-map) +# - uninitialized stack/heap contents (should not affect final binary) +# - linker adds random base addresses (PIE; deterministic if compiled +# with -fno-pie) + +set -euo pipefail + +# ── Config ───────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOURCE_DIR="${SOURCE_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}" +BUILD_TYPE="${BUILD_TYPE:-Release}" +TARGET="${TARGET:-trianglesd}" +# Skip Qt by default -- it's slow and adds CI noise. Override with TARGET=triangles-qt +: "${BUILD_QT:=OFF}" +BUILD_DIR_A="${BUILD_DIR_A:-/tmp/triangles-repro-A}" +BUILD_DIR_B="${BUILD_DIR_B:-/tmp/triangles-repro-B}" +LOG_A="${LOG_A:-/tmp/triangles-repro-A.log}" +LOG_B="${LOG_B:-/tmp/triangles-repro-B.log}" + +# ── Preflight ────────────────────────────────────────────────────────────── +command -v cmake >/dev/null || { echo "ERROR: cmake not found" >&2; exit 2; } +command -v ninja >/dev/null || { echo "ERROR: ninja not found (apt install ninja-build)" >&2; exit 2; } +command -v sha256sum >/dev/null || { echo "ERROR: sha256sum not found" >&2; exit 2; } + +if [ ! -d "$SOURCE_DIR" ]; then + echo "ERROR: source dir not found: $SOURCE_DIR" >&2 + exit 2 +fi + +# Refuse to run if the working tree is dirty -- dirty tree = non-deterministic +# git describe output = non-deterministic binary. Run on a clean checkout +# or a release tag. +if [ -n "$(cd "$SOURCE_DIR" && git status --porcelain 2>/dev/null)" ]; then + echo "WARNING: working tree has uncommitted changes." >&2 + echo " build.h will include '-dirty' suffix and the binary will NOT be" >&2 + echo " reproducible. Commit/stash your changes first, or accept that the" >&2 + echo " hashes below prove your dirty-tree build is at least internally consistent." >&2 +fi + +# ── Helpers ──────────────────────────────────────────────────────────────── +build_one() { + local dir="$1" log="$2" + rm -rf "$dir" + mkdir -p "$dir" + echo " configuring in $dir (BUILD_TYPE=$BUILD_TYPE BUILD_QT=$BUILD_QT)..." >&2 + cmake -S "$SOURCE_DIR" -B "$dir" \ + -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ + -DBUILD_QT="$BUILD_QT" \ + > "$log" 2>&1 || { echo " configure failed; see $log" >&2; tail -30 "$log" >&2; exit 3; } + echo " building target $TARGET..." >&2 + cmake --build "$dir" --target "$TARGET" -j "$(nproc)" \ + >> "$log" 2>&1 || { echo " build failed; see $log" >&2; tail -30 "$log" >&2; exit 3; } + # ONLY stdout of the find goes to the caller. Progress logs above + # were redirected to stderr so they don't pollute the captured path. + find "$dir" -name "$TARGET" -type f -executable | head -1 +} + +# ── Build twice ──────────────────────────────────────────────────────────── +echo "Building $TARGET ($BUILD_TYPE) twice from $SOURCE_DIR..." +echo "" +BIN_A="$(build_one "$BUILD_DIR_A" "$LOG_A")" +BIN_B="$(build_one "$BUILD_DIR_B" "$LOG_B")" + +if [ -z "$BIN_A" ] || [ -z "$BIN_B" ]; then + echo "ERROR: could not find built binary" >&2 + echo " A: '$BIN_A'" >&2 + echo " B: '$BIN_B'" >&2 + exit 4 +fi + +# ── Compare ──────────────────────────────────────────────────────────────── +HASH_A="$(sha256sum "$BIN_A" | awk '{print $1}')" +HASH_B="$(sha256sum "$BIN_B" | awk '{print $1}')" +SIZE_A="$(stat -c%s "$BIN_A" 2>/dev/null || stat -f%z "$BIN_A")" +SIZE_B="$(stat -c%s "$BIN_B" 2>/dev/null || stat -f%z "$BIN_B")" + +echo "" +echo "Binary A: $BIN_A" +echo " sha256: $HASH_A" +echo " size: $SIZE_A bytes" +echo "Binary B: $BIN_B" +echo " sha256: $HASH_B" +echo " size: $SIZE_B bytes" +echo "" + +if [ "$HASH_A" = "$HASH_B" ]; then + echo "✓ REPRODUCIBLE: both builds produced identical SHA256" + exit 0 +else + echo "✗ NOT REPRODUCIBLE: hashes differ" + echo "" + echo "Likely causes:" + echo " - __DATE__/__TIME__ embedded (check src/version.cpp)" + echo " - absolute build paths in __FILE__ (check CMakeLists.txt for -ffile-prefix-map)" + echo " - dirty git tree (commit/stash and rerun)" + echo " - PIE base randomization (compile with -fno-pie -no-pie for testing)" + echo " - non-deterministic linker output (linker version mismatch)" + exit 1 +fi \ No newline at end of file