Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "${localWorkspaceFolderBasename}",
"build": {
"dockerfile": "../Dockerfile",
"dockerfile": "../Containerfile.app",
"args": {
"USERNAME": "${localEnv:USER}"
}
Expand All @@ -27,6 +27,5 @@
"containerEnv": {
"SSH_AUTH_SOCK": "/ssh-agent"
},
"postCreateCommand": "flutter config --no-analytics && dart --disable-analytics",
"postStartCommand": "sudo chown -R --no-dereference ${localEnv:USER}:${localEnv:USER} /home/${localEnv:USER}/.ssh || true; chmod 700 /home/${localEnv:USER}/.ssh; find /home/${localEnv:USER}/.ssh -maxdepth 1 -type f -exec chmod 600 {} + 2>/dev/null || true"
}
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,17 @@ build/
android/.gradle/
ios/
.idea/
.vscode/
*.iml

# Host-side Flutter version cache (FVM downloads its own copy in the container)
.fvm/

# Local APKs/AABs built previously (host-side artifacts)
app-*.apk
app-*.aab

# Local investigation / verification scratch dirs
.tmp_apk_diff/
reproducibility/bullbitcoin_*_verification/
reproducibility/reproducibility_test_*/
37 changes: 37 additions & 0 deletions .github/workflows/build-android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: build-android

on:
workflow_dispatch:
inputs:
mode:
type: choice
options: [release, debug]
default: release

jobs:
build:
# Pinned to 24.04. 26.04 runner not yet provided by GitHub
# (tracked in actions/runner-images#13855). Bump when available.
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6

- name: Free disk space
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: true
swap-storage: false

- name: Build APK
# makefile uses `podman` directly; ubuntu-24.04 ships podman 4.9.3 preinstalled.
run: make apk ${{ inputs.mode }}

- uses: actions/upload-artifact@v7
with:
name: app-${{ inputs.mode }}
path: app-${{ inputs.mode }}.apk
retention-days: 30
35 changes: 35 additions & 0 deletions Containerfile.app
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
FROM bull-tools

# Default "bull" matches the user baked into Containerfile.tools so canonical
# reproducible builds (make container-app with no --build-arg) are unaffected.
# The dev container passes USERNAME=${localEnv:USER} so SSH / gitconfig mounts
# at /home/$USER/... inside the container resolve correctly.
ARG USERNAME="bull"

USER root
RUN if [ "$USERNAME" != "bull" ]; then \
usermod -l "$USERNAME" -d "/home/$USERNAME" -m bull && \
groupmod -n "$USERNAME" bull && \
ln -sf "/home/$USERNAME" /home/bull && \
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/$USERNAME"; \
fi
ENV USER=$USERNAME
USER $USERNAME

ARG GRADLE_HEAP=4g

COPY --chown=$USERNAME:$USERNAME . /app/
WORKDIR /app

# Install Flutter version specified in .fvmrc (no-op if it matches tools stage)
RUN fvm install

# Reuse makefile targets so container build matches local setup
RUN make deps
RUN make build-runner
RUN make translations

# Configure Gradle for containerized builds
RUN mkdir -p $HOME/.gradle && \
echo "org.gradle.daemon=false" > $HOME/.gradle/gradle.properties && \
echo "org.gradle.jvmargs=-Xmx${GRADLE_HEAP} -XX:+HeapDumpOnOutOfMemoryError" >> $HOME/.gradle/gradle.properties
50 changes: 38 additions & 12 deletions Dockerfile → Containerfile.tools
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@ ENV DEBIAN_FRONTEND=noninteractive

ARG USERNAME="bull"
ENV USER=$USERNAME
ARG FVM_VERSION=4.0.5
ARG FLUTTER_VERSION=3.38.5
ARG ANDROID_CMDLINE_TOOLS_VERSION=14742923
ARG FVM_VERSION="4.0.5"
ARG FLUTTER_VERSION="3.38.5"
ARG ANDROID_CMDLINE_TOOLS_VERSION="14742923"

# Android versions (passed via --build-arg from Makefile, defaults as fallback)
ARG JVM_TARGET=21
ARG ANDROID_API_LEVEL=36
ARG ANDROID_BUILD_TOOLS=36.0.0
ARG ANDROID_NDK=29.0.14206865
ARG RUST_VERSION=1.95.0
# Rust is pinned explicitly so the rustup default toolchain matches what
# our forked plugins (ark-wallet-dart, boltz-dart) declare in their
# cargokit.yaml. Plugins without cargokit.yaml fall back to "stable" — for
# those, EXPECTED_RUST_VERSION (below) acts as a guard so the build aborts
# if the installed version drifts from what we recorded as canonical.
ARG RUST_VERSION="1.95.0"

# Android versions (passed via --build-arg from Makefile, defaults as fallback)
ARG JVM_TARGET="21"
ARG ANDROID_API_LEVEL="36"
ARG ANDROID_BUILD_TOOLS="36.0.0"
ARG ANDROID_NDK="29.0.14206865"

ENV ANDROID_HOME=/opt/android-sdk
ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
Expand Down Expand Up @@ -49,9 +54,10 @@ RUN adduser $USER sudo
RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
USER $USER

# Install Rust (pinned for reproducible builds; cargokit defaults to 'stable' for
# plugins without cargokit.yaml, so this version determines their output)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain ${RUST_VERSION}
# Install Rust (pinned via RUST_VERSION arg)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup.sh
RUN sh /tmp/rustup.sh -y --no-modify-path --default-toolchain ${RUST_VERSION}
RUN rm /tmp/rustup.sh
ENV PATH="/home/$USER/.cargo/bin:${PATH}"

# Add Android Rust targets
Expand All @@ -61,6 +67,19 @@ RUN rustup target add x86_64-linux-android
RUN rustup target add i686-linux-android
RUN rustc --version && cargo --version

# Belt-and-suspenders verification: abort if the installed Rust does not match
# EXPECTED_RUST_VERSION when provided. Useful when reproducing an older build
# whose canonical Rust differs from the current RUST_VERSION default.
ARG EXPECTED_RUST_VERSION=""
RUN if [ -n "$EXPECTED_RUST_VERSION" ]; then \
INSTALLED=$(rustc --version | awk '{print $2}'); \
if [ "$INSTALLED" != "$EXPECTED_RUST_VERSION" ]; then \
echo "ERROR: Expected Rust $EXPECTED_RUST_VERSION but installed is $INSTALLED"; \
echo "Pass --build-arg RUST_VERSION=$EXPECTED_RUST_VERSION to align"; \
exit 1; \
fi; \
fi

# Install FVM
RUN curl -fsSL https://fvm.app/install.sh -o /tmp/fvm-install.sh
RUN bash /tmp/fvm-install.sh ${FVM_VERSION}
Expand Down Expand Up @@ -88,3 +107,10 @@ RUN sdkmanager --sdk_root=${ANDROID_HOME} "platform-tools"
RUN sdkmanager --sdk_root=${ANDROID_HOME} "platforms;android-${ANDROID_API_LEVEL}"
RUN sdkmanager --sdk_root=${ANDROID_HOME} "build-tools;${ANDROID_BUILD_TOOLS}"
RUN sdkmanager --sdk_root=${ANDROID_HOME} "ndk;${ANDROID_NDK}"

# Pre-cache Flutter engine artifacts
RUN fvm flutter precache --android

# Disable telemetry
RUN fvm flutter config --no-analytics
RUN fvm dart --disable-analytics
42 changes: 0 additions & 42 deletions Dockerfile.apk

This file was deleted.

2 changes: 1 addition & 1 deletion android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ org.gradle.jvmargs=-Xmx4608m
android.useAndroidX=true
android.enableJetifier=true

# Android SDK versions (single source of truth for build.gradle and Dockerfile)
# Android SDK versions (single source of truth for build.gradle and Containerfile.tools)
android.minSdk=26
android.compileSdk=36
android.targetSdk=36
Expand Down
63 changes: 45 additions & 18 deletions makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all setup clean deps build-runner translations hooks ios-pod-update drift-migrations devcontainer docker-build apk verify test unit-test integration-test fvm-check
.PHONY: all setup clean deps build-runner translations hooks ios-pod-update drift-migrations devcontainer container-tools container-app apk verify test unit-test integration-test fvm-check

fvm-check:
@echo "🔍 Checking FVM"
Expand Down Expand Up @@ -60,14 +60,25 @@ ios-sqlite-update:
@echo "Updating SQLite"
@cd ios && pod update sqlite3 && cd -

docker-build:
@echo "🏗️ Building Docker image"
@docker build -t bull-mobile \
# Container runtime — default podman, override with CONTAINER=docker for
# environments without podman.
CONTAINER ?= podman

container-tools:
@echo "🔧 Building tools image"
@$(CONTAINER) build -f Containerfile.tools -t bull-tools \
--build-arg FLUTTER_VERSION=$$(awk 'BEGIN{RS="";} { gsub(/\r/,""); s=$$0; sub(/.*"flutter"[[:space:]]*:[[:space:]]*"/,"",s); sub(/".*$$/,"",s); print s; exit }' .fvmrc) \
--build-arg JVM_TARGET=$$(grep 'android.jvmTarget' android/gradle.properties | cut -d= -f2) \
--build-arg ANDROID_API_LEVEL=$$(grep 'android.compileSdk' android/gradle.properties | cut -d= -f2) \
--build-arg ANDROID_BUILD_TOOLS=$$(grep 'android.buildToolsVersion' android/gradle.properties | cut -d= -f2) \
--build-arg ANDROID_NDK=$$(grep 'android.ndkVersion' android/gradle.properties | cut -d= -f2) \
$(if $(EXPECTED_RUST_VERSION),--build-arg EXPECTED_RUST_VERSION=$(EXPECTED_RUST_VERSION)) \
.

container-app: container-tools
@echo "📦 Building app image"
@$(CONTAINER) build -f Containerfile.app -t bull-app \
--build-arg GRADLE_HEAP=$(or $(GRADLE_HEAP),4g) \
.

MODE ?= debug
Expand All @@ -83,26 +94,42 @@ endif
release debug:
@:

apk: docker-build
@echo "🔨 Building $(FORMAT) ($(MODE)) via Docker"
@docker build -f Dockerfile.apk \
--build-arg MODE=$(MODE) \
--build-arg FORMAT=$(FORMAT) \
--build-arg GRADLE_HEAP=$(or $(GRADLE_HEAP),4g) \
# Flutter writes APK and AAB to different paths
ifeq ($(FORMAT),aab)
CONTAINER_OUTPUT := /app/build/app/outputs/bundle/$(MODE)/app-$(MODE).aab
HOST_OUTPUT := ./app-$(MODE).aab
FLUTTER_BUILD := fvm flutter build appbundle --$(MODE)
else
CONTAINER_OUTPUT := /app/build/app/outputs/flutter-apk/app-$(MODE).apk
HOST_OUTPUT := ./app-$(MODE).apk
FLUTTER_BUILD := fvm flutter build apk --$(MODE)
endif

apk: container-app
@echo "🔨 Building $(FORMAT) ($(MODE)) via $(CONTAINER)"
@$(CONTAINER) rm -f bull-build > /dev/null 2>&1 || true
@$(CONTAINER) run --name bull-build \
--ulimit nofile=65536:65536 \
-t bull-mobile-apk .
@docker rm -f bull-apk-extract > /dev/null 2>&1 || true
@docker create --name bull-apk-extract bull-mobile-apk > /dev/null
@docker cp bull-apk-extract:/app/build/app/outputs/flutter-apk/app-$(MODE).apk ./app-$(MODE).apk
@docker rm bull-apk-extract > /dev/null
@echo "✅ APK extracted: ./app-$(MODE).apk"
@sha256sum ./app-$(MODE).apk
bull-app bash -c '\
SOURCE_DATE_EPOCH=$$(git -C /app log -1 --format=%ct) && \
CARGO_ENCODED_RUSTFLAGS=$$(printf "%s\037%s\037%s" \
"--remap-path-prefix=$$HOME/.cargo=/cargo" \
"--remap-path-prefix=$$HOME/.rustup=/rustup" \
"--remap-path-prefix=/app=/build") && \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 && \
export SOURCE_DATE_EPOCH CARGO_ENCODED_RUSTFLAGS CARGO_PROFILE_RELEASE_CODEGEN_UNITS && \
cd /app && \
$(FLUTTER_BUILD)'
@$(CONTAINER) cp bull-build:$(CONTAINER_OUTPUT) $(HOST_OUTPUT)
@$(CONTAINER) rm bull-build > /dev/null
@echo "✅ Output extracted: $(HOST_OUTPUT)"
@sha256sum $(HOST_OUTPUT)

verify:
@echo "🔍 Verifying reproducible build"
@./reproducibility/verify_build.sh $(if $(VERSION),--version $(VERSION)) $(if $(APK),--apk $(APK))

devcontainer:
devcontainer: container-tools
@echo "🏗️ Building Dev Container"
@devcontainer up --workspace-folder . --config ./.devcontainer/devcontainer.json

Expand Down
15 changes: 10 additions & 5 deletions reproducibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ Scripts for verifying that the published Bull Bitcoin Mobile app matches a build

Three components work together:

### `../Dockerfile` (root)
### `../Containerfile.tools` and `../Containerfile.app` (root)

Builds the app from source in a clean, hermetic environment. It installs all toolchains (Rust, Flutter via FVM, Android SDK, Gradle), copies the repo into the image, and runs `flutter build`. Two environment variables are set at build time to eliminate sources of non-determinism:
Two-file build setup driven by `make apk release`:

- `Containerfile.tools` installs all toolchains (Rust pinned via `RUST_VERSION`, Flutter via FVM, Android SDK, Gradle).
- `Containerfile.app` copies the repo, runs `pub get` / `build_runner` / `gen-l10n`, and configures Gradle. It does NOT run `flutter build` — that happens via `podman run` against the resulting image so the multi-GB build output is never committed to a layer.

Two environment variables are set at build time to eliminate sources of non-determinism:

- `SOURCE_DATE_EPOCH` — set to the timestamp of the latest git commit (`git log -1 --format=%ct`). OpenSSL embeds a wall-clock build timestamp in compiled binaries by default; setting this variable makes it use a fixed value instead, so any `.so` that links against OpenSSL (`libark_wallet.so`, `libboltz.so`, `libtor.so`) is identical across builds.
- `CARGO_ENCODED_RUSTFLAGS` — three `--remap-path-prefix` flags that rewrite absolute paths baked into Rust binaries at compile time (home directory, `.cargo`, `.rustup`) to fixed strings (`/cargo`, `/rustup`, `/build`). cargokit reads `CARGO_ENCODED_RUSTFLAGS` rather than `RUSTFLAGS`; flags are separated by the ASCII unit separator `\x1f` (octal `\037`).
Expand All @@ -23,13 +28,13 @@ Orchestrates the full verification:

1. Builds the verification tools image from `Dockerfile`
2. Optionally downloads the official APK from the GitHub release, or uses a locally provided APK or split APK directory
3. Builds the app from the current repo checkout using the root `Dockerfile`
4. Extracts the built artifact from the Docker image
3. Builds the app from the current repo checkout via `make apk release` (which uses the root `Containerfile.tools` + `Containerfile.app`)
4. Picks up the extracted APK from the repo root (`./app-release.apk`)
5. Decodes both APKs with apktool (inside the tools container)
6. Diffs the decoded output excluding `META-INF` (signatures are not part of reproducibility)
7. Writes a `RESULTS.md` verdict to the workspace directory

For Docker-to-Docker comparisons to be reproducible, both builds must use the exact same git commit. `SOURCE_DATE_EPOCH` is derived from `git log -1 --format=%ct`, so if two builds are from different commits they will embed different timestamps and the `.so` files will differ.
For build-to-build comparisons to be reproducible, both builds must use the exact same git commit. `SOURCE_DATE_EPOCH` is derived from `git log -1 --format=%ct`, so if two builds are from different commits they will embed different timestamps and the `.so` files will differ.

---

Expand Down
Loading
Loading