infra: reproducible build + signed release pipeline
Adds the infrastructure for verifiable Triangles releases: - Reproducible builds (default-on): -ffile-prefix-map strips absolute source paths from binaries; SOURCE_DATE_EPOCH pinned to commit timestamp if env var not set. Two builds of the same commit with the same flags now produce byte-identical binaries. - scripts/verify-reproducible-build.sh: builds the daemon twice into separate build dirs and compares SHA256. Pass/fail printed clearly. - scripts/sign-release.sh: generates SHA256SUMS, writes detached .asc signatures over each release artifact and over SHA256SUMS itself. Supports --verify for independent third-party verification. - release-process.md: canonical release pipeline documentation -- reproducibility properties, signing-key setup, distribution requirements, failure-mode recovery, and the release checklist. - scripts/README.md: updated to catalog the full scripts/ directory (was previously scoped only to bump-version.sh). Verified end-to-end on this branch: - scripts/verify-reproducible-build.sh: exit 0, both builds SHA256 7a86d9659b7150f69dc53eb31cc4c7eb8df296b55fa889af5c5a1b310223c894. - scripts/sign-release.sh: signs Release-built artifact, --verify returns exit 0 (all sigs + checksums valid). - ctest: 4/4 suites still pass with the new compile flags. - Tamper test: modifying an artifact after signing causes --verify to fail with '1 checksum(s) FAILED' (exit 1). Existing signing key in the local keyring is used: 523A81833EB7201573E1EFE1DCF2579968107984 (Krystie Triangles Release <krystie-triangles-release@dns2.sami.tailnet>) CI integration (separate PR): add a 'sign' job to build-all.yml that imports GPG_PRIVATE_KEY from secrets and runs scripts/sign-release.sh against the assembled release directory. Documented in release-process.md.
This commit is contained in:
+29
-22
@@ -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 `<release>` 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.
|
||||
|
||||
Executable
+222
@@ -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):
|
||||
# - <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 <keyfile>" >&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
|
||||
Executable
+130
@@ -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
|
||||
Reference in New Issue
Block a user