diff --git a/.github/workflows/reproducible-build.yml b/.github/workflows/reproducible-build.yml new file mode 100644 index 00000000..b7e01249 --- /dev/null +++ b/.github/workflows/reproducible-build.yml @@ -0,0 +1,57 @@ +name: Reproducible build + +# Verifiable release pipeline (parallel to the Netlify deployment): +# on every version tag, build the bundle in the pinned environment, +# fingerprint it, and publish artifact + hash on the GitHub Release. +# Anyone can check the fingerprint with: distribution/verify.sh + +on: + push: + tags: ['v*'] + workflow_dispatch: {} + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verifiable build (pinned environment) + run: bash distribution/build.sh HEAD + + - name: Determinism check — rebuild from scratch and compare + run: | + HASH1="$(cat distribution/out/BUILDHASH)" + cp distribution/out/BUILDMANIFEST /tmp/manifest1 + bash distribution/build.sh --no-cache HEAD + HASH2="$(cat distribution/out/BUILDHASH)" + echo "first: $HASH1" + echo "second: $HASH2" + if [ "$HASH1" != "$HASH2" ]; then + echo "::error::Build is not reproducible on this runner" + diff /tmp/manifest1 distribution/out/BUILDMANIFEST || true + exit 1 + fi + + - name: Publish release with artifact + fingerprint + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + HASH="$(cat distribution/out/BUILDHASH)" + cp distribution/out/BUILDHASH distribution/out/BUILDHASH.txt + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes "BUILDHASH: \`${HASH}\` + +Verify independently: +\`\`\` +git clone https://github.com/${GITHUB_REPOSITORY} && cd homebase-app +distribution/verify.sh ${GITHUB_REF_NAME} ${HASH} +\`\`\`" \ + distribution/out/homebase-build.tar.gz \ + distribution/out/BUILDHASH.txt \ + distribution/out/BUILDMANIFEST diff --git a/distribution/Dockerfile.build b/distribution/Dockerfile.build new file mode 100644 index 00000000..f7af0195 --- /dev/null +++ b/distribution/Dockerfile.build @@ -0,0 +1,43 @@ +# Verifiable production build environment for Homebase. +# +# Pinned by DIGEST, not tag: a tag ("node:22.12.0") is a moving label the +# registry can repoint; the digest is the sha256 of the image itself and +# can never refer to different bytes. node 22.12.0 matches .nvmrc. +FROM node@sha256:35531c52ce27b6575d69755c73e65d4468dba93a25644eed56dc12879cae9213 + +WORKDIR /app + +# CI=false: CRA escalates the repo's pre-existing lint warnings to errors +# under CI=true; lint policy is enforced elsewhere (lint-staged / lint:ci), +# not by the verifiable build. +ENV CI=false \ + HUSKY=0 + +# Dependencies first (better layer caching for local iteration) +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --network-timeout 600000 + +COPY . . + +# SOURCE_DATE_EPOCH: the commit timestamp of the source being built. +# Build tools that would otherwise stamp "now" into output use this +# instead, so wall-clock time stops being an input to the build. +ARG SOURCE_DATE_EPOCH +ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} + +# The canonical public env is the only .env a verifiable build may see. +RUN cp distribution/env.build .env + +# Source maps embed builder-machine paths; the verifiable artifact omits +# them (transparency comes from reproducibility, not from maps). +ENV GENERATE_SOURCEMAP=false + +RUN yarn build + +# Deterministic artifact + hash, computed inside the pinned environment: +# - tar: sorted entries, fixed mtime, no owners - gzip -n: no timestamp +# - BUILDHASH: sha256 over the sorted per-file sha256s of build/ +RUN cd /app && \ + find build -type f -print0 | sort -z | xargs -0 sha256sum | tee /app/BUILDMANIFEST | sha256sum | cut -d' ' -f1 > /app/BUILDHASH && \ + tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf - build | gzip -n > /app/homebase-build.tar.gz && \ + echo "BUILDHASH: $(cat /app/BUILDHASH)" diff --git a/distribution/README.md b/distribution/README.md new file mode 100644 index 00000000..56ca0bc9 --- /dev/null +++ b/distribution/README.md @@ -0,0 +1,55 @@ +# Verifiable builds + +This directory makes Homebase releases **reproducible**: the same git ref +always produces a byte-identical bundle, on any machine. That turns "the +deployed app matches the public source" from a promise into a check. + +**Why it matters here:** for a governance platform, control of the frontend +is a governance question — a tampered bundle can misrepresent what users +sign, without any trace in the source repository. Reproducibility is the +foundation for distributing Homebase through channels that don't require +trusting any single host or maintainer (content-addressed hosting, release +registries, independent build attestation). It runs **in parallel** with the +existing hosted deployment, which continues unchanged. + +## How determinism is achieved + +| Leak | Fix | +|---|---| +| Toolchain drift (node/yarn versions) | Builder image pinned by **digest** in `Dockerfile.build` (node 22.12.0, matching `.nvmrc`) | +| Machine paths in output | Build always runs at `/app` inside the container; source maps disabled for the artifact | +| Timestamps | `SOURCE_DATE_EPOCH` = the commit's timestamp; `gzip -n`; fixed tar mtimes | +| Uncommitted/untracked files | Docker context is `git archive ` — only committed code can enter a build | +| Environment variance | `env.build` is the canonical, committed, public-only production env — the only `.env` a verifiable build sees | + +## Usage + +```bash +# Build the current commit; artifact + fingerprint land in distribution/out/ +distribution/build.sh + +# Verify a release against its published hash (from the GitHub Release page) +distribution/verify.sh v1.2.3 +``` + +`BUILDHASH` is the sha256 over the sorted per-file sha256s of `build/` +(`BUILDMANIFEST` holds the per-file list, so any mismatch can be localized +to the exact file). + +## Release flow + +Pushing a `v*` tag triggers `.github/workflows/reproducible-build.yml`, +which builds, **rebuilds from scratch and fails unless both hashes match**, +then publishes the artifact, `BUILDHASH.txt`, and the manifest on the +GitHub Release. + +## Boundaries (honest ones) + +- `env.build` must never contain a secret. A browser bundle cannot keep + secrets; anything here is public the moment the app ships. +- Reproducibility certifies the *bundle*, not the *services* it talks to — + the app still depends on the indexer/API endpoints listed in `env.build`. +- The hosted deployment (Netlify) builds separately and is not expected to + match `BUILDHASH`; verifiable distribution channels (content-addressed + mirrors) are the next phase and will serve exactly the fingerprinted + artifact. diff --git a/distribution/build.sh b/distribution/build.sh new file mode 100644 index 00000000..430b2ad8 --- /dev/null +++ b/distribution/build.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Verifiable production build. +# +# Builds ONLY committed code: the Docker context is `git archive HEAD`, so +# uncommitted changes and untracked files cannot enter the artifact. The +# environment is fully pinned by distribution/Dockerfile.build. +# +# Usage: distribution/build.sh [--no-cache] [git-ref] +# git-ref defaults to HEAD. Output lands in distribution/out/: +# build/ the deployable bundle +# homebase-build.tar.gz deterministic tarball of build/ +# BUILDHASH sha256 fingerprint of the bundle +# BUILDMANIFEST per-file sha256s (for pinpointing any mismatch) +set -euo pipefail + +NO_CACHE="" +REF="HEAD" +for arg in "$@"; do + case "$arg" in + --no-cache) NO_CACHE="--no-cache" ;; + *) REF="$arg" ;; + esac +done + +cd "$(git rev-parse --show-toplevel)" + +# Commit timestamp of the ref being built — the only "time" the build sees. +SDE="$(git log -1 --format=%ct "$REF")" +COMMIT="$(git rev-parse "$REF")" + +echo "Building $REF ($COMMIT, SOURCE_DATE_EPOCH=$SDE)" + +git archive --format=tar "$REF" | docker build $NO_CACHE \ + -f distribution/Dockerfile.build \ + --build-arg SOURCE_DATE_EPOCH="$SDE" \ + -t homebase-verifiable-build:latest \ + - + +cid="$(docker create homebase-verifiable-build:latest true)" +trap 'docker rm -f "$cid" >/dev/null' EXIT +rm -rf distribution/out +mkdir -p distribution/out +docker cp "$cid":/app/build distribution/out/build +docker cp "$cid":/app/homebase-build.tar.gz distribution/out/ +docker cp "$cid":/app/BUILDHASH distribution/out/ +docker cp "$cid":/app/BUILDMANIFEST distribution/out/ + +echo "commit: $COMMIT" +echo "BUILDHASH: $(cat distribution/out/BUILDHASH)" diff --git a/distribution/env.build b/distribution/env.build new file mode 100644 index 00000000..0ed91573 --- /dev/null +++ b/distribution/env.build @@ -0,0 +1,21 @@ +# Canonical PUBLIC build environment for verifiable production builds. +# Every value here is public by definition — it ships inside the browser +# bundle. Secrets must never appear in this file (a frontend bundle cannot +# keep a secret anyway). +# +# This file is the single source of truth for what a verifiable Homebase +# build talks to. Changing any value changes the build hash. +REACT_APP_ENV=PROD +REACT_APP_NETWORK=mainnet +REACT_APP_URL=https://tezos-homebase.io +REACT_APP_V2_URL=https://v2.tezos-homebase.io +REACT_APP_HASURA_URL=https://v3-homebase-indexer.tezos-homebase.io/v1/graphql +REACT_APP_HASURA_URL_V2=https://v2-homebase-indexer.w3api.dev/v1/graphql +REACT_APP_LITE_API_URL=https://homebase-backend.netlify.app +REACT_APP_DAO_DEPLOYER_API=https://homebase-dao-deployer.tezos-homebase.io +REACT_APP_BASEDAO_DOCKERISED_URL=https://v3-basedao-dockerised.herokuapp.com/steps +REACT_APP_CORS_PROXY_URL=https://dorg-cors-proxy.herokuapp.com +REACT_APP_POSTHOG_KEY=phc_o6mUWsxRDEbXyHLuyCfkAploVj0azEyV64DGlF7fvqd +REACT_APP_POSTHOG_HOST=https://us.i.posthog.com +REACT_APP_IS_NOT_TESTING=true +REACT_APP_IGNORE_DAO_CYCLE_CHECK=false diff --git a/distribution/verify.sh b/distribution/verify.sh new file mode 100644 index 00000000..1700a415 --- /dev/null +++ b/distribution/verify.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Independently verify a Homebase release. +# +# Rebuilds the given git ref from source in the pinned environment and +# compares the resulting fingerprint against an expected hash (e.g. the +# BUILDHASH published on the GitHub Release, or — in later phases — the +# hash recorded in the on-chain release registry). +# +# Usage: distribution/verify.sh +# +# Exit codes: 0 = MATCH (the source provably produces the published bundle) +# 1 = MISMATCH (investigate: toolchain drift or tampering) +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +REF="$1" +EXPECTED="$2" + +"$(dirname "$0")/build.sh" --no-cache "$REF" + +ACTUAL="$(cat "$(git rev-parse --show-toplevel)/distribution/out/BUILDHASH")" + +echo +echo "expected: $EXPECTED" +echo "actual: $ACTUAL" +if [ "$ACTUAL" = "$EXPECTED" ]; then + echo "MATCH — the published bundle is exactly this source." +else + echo "MISMATCH — do not trust the published bundle until explained." >&2 + echo "Compare distribution/out/BUILDMANIFEST against the published one to locate differing files." >&2 + exit 1 +fi