diff --git a/CHANGELOG.md b/CHANGELOG.md index ff68392..14e1534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Reproducible unsigned-release build documentation and a two-worktree byte-for-byte APK verification command. - Real-engine performance regression tests covering QuickJS runtime reuse and tolerant cold-start, sentence and paragraph timing budgets. - Runtime stress coverage for prolonged Grammalecte analysis, concurrent engine calls, repeated QuickJS lifecycle cycles and retained QuickJS memory growth. - Initial modular Android project foundation. diff --git a/Makefile b/Makefile index 928e1a2..01d4837 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GRADLE := ./gradlew -.PHONY: bootstrap vendor check performance-check stress-check test lint assemble instrumented refresh-verification-metadata clean +.PHONY: bootstrap vendor check performance-check stress-check release-reproducibility test lint assemble instrumented refresh-verification-metadata clean bootstrap: ./tools/bootstrap-gradle-wrapper.sh @@ -29,6 +29,9 @@ stress-check: -PgrammalecteStress=1 \ --rerun-tasks +release-reproducibility: + ./tools/check-release-reproducibility.sh + test: $(GRADLE) --no-daemon :core:test :engine-grammalecte:test :spellchecker:test diff --git a/README.md b/README.md index e18ab94..43799b5 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,14 @@ Build the debug APK and verify its final permissions: make assemble ``` +Verify that two independent unsigned release builds are byte-for-byte reproducible: + +```sh +make release-reproducibility +``` + +See [`docs/release.md`](docs/release.md) for the complete release procedure. + The debug APK is generated under: ```text diff --git a/ROADMAP.md b/ROADMAP.md index 42c1c73..eca1f54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ Exit criterion: correction remains usable in common editable applications even w ## Phase 5 — Public distribution -- [ ] Reproducible release build documentation +- [x] Reproducible release build documentation - [ ] Signed release pipeline - [ ] SBOM / dependency inventory - [ ] F-Droid metadata and reproducibility checks diff --git a/docs/release.md b/docs/release.md index 45ee44e..db2841c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -4,6 +4,82 @@ The repository now contains the committed Gradle wrapper and pinned Grammalecte A public release should still be produced only from a clean checkout and after all release checks below succeed. +## Reproducible unsigned release build + +The current reproducibility guarantee covers the unsigned release APK. +Signing is handled separately and is not yet part of this guarantee. + +Two builds are considered reproducible when they: + +- start from the exact same Git commit; +- use separate source worktrees; +- use the committed Gradle wrapper; +- disable the Gradle build and configuration caches; +- produce byte-for-byte identical APK files. + +Run: + + make release-reproducibility + +The command builds the application twice from separate temporary Git +worktrees and compares both SHA-256 hashes and the final APK bytes. + +The expected artifact is: + + app/build/outputs/apk/release/app-release-unsigned.apk + +The initial validation produced identical unsigned APKs from two independent +worktrees. + +The release build currently uses: + +- Gradle 9.6.1; +- Android Gradle Plugin 9.4.1; +- Kotlin 2.4.20; +- JDK 17; +- compileSdk 36; +- targetSdk 36; +- minSdk 26; +- Grammalecte 2.3.0; +- Grammalecte commit 47af2080202e647110e5199ebd5d4b51d2bd51db. + +Gradle dependency verification is enabled through +`gradle/verification-metadata.xml`. + +The Gradle distribution checksum is pinned in +`gradle/wrapper/gradle-wrapper.properties`. + +### Manual release build + +Build the unsigned release APK with: + + ./gradlew :app:assembleRelease \ + --no-daemon \ + --no-build-cache \ + --no-configuration-cache + +Verify the offline privacy invariant with: + + ./tools/check-apk-permissions.sh \ + app/build/outputs/apk/release/app-release-unsigned.apk + +Record its SHA-256 with: + + sha256sum app/build/outputs/apk/release/app-release-unsigned.apk + +The current release build may warn that `libquickjs.so` cannot be stripped. +This is currently non-fatal. The reproducibility check compares the final APK +bytes and therefore still detects differences in the packaged native library. + +### Signing boundary + +The reproducibility procedure intentionally stops at the unsigned APK. + +Private signing keys must never be committed to the repository. + +The signed release pipeline will separately define signing-key storage, +artifact signing, provenance and signed-artifact verification. + ## Release checklist 1. CI is green on the target commit. @@ -73,14 +149,14 @@ Do not rebuild a published version from a later commit under the same version nu ## Before the first stable release -The following project-level items are still expected before declaring a stable public release: +The remaining Phase 5 work includes: -- application icon and final visual identity; -- polished first-run setup; -- compatibility matrix; -- broader Android-version testing; -- IME instrumentation coverage; -- broader end-to-end Android integration coverage for the IME and platform spell-checker paths; - signed release pipeline; -- reproducibility review; -- final privacy and security review. +- SBOM and dependency inventory; +- F-Droid metadata and reproducibility checks; +- final Android package namespace decision; +- final third-party license review; +- first stable release. + +Broader physical-device validation across supported Android versions also +remains useful before making broad compatibility claims. diff --git a/tools/check-release-reproducibility.sh b/tools/check-release-reproducibility.sh new file mode 100755 index 0000000..ff8d0f6 --- /dev/null +++ b/tools/check-release-reproducibility.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMMIT="${1:-$(git -C "$ROOT" rev-parse HEAD)}" + +WORKTREE_A="$(mktemp -d /tmp/grammalecte-release-a.XXXXXX)" +WORKTREE_B="$(mktemp -d /tmp/grammalecte-release-b.XXXXXX)" + +cleanup() { + git -C "$ROOT" worktree remove --force "$WORKTREE_A" >/dev/null 2>&1 || true + git -C "$ROOT" worktree remove --force "$WORKTREE_B" >/dev/null 2>&1 || true + + rm -rf "$WORKTREE_A" "$WORKTREE_B" +} + +trap cleanup EXIT + +for command in git cmp; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command not found: $command" >&2 + exit 1 + fi +done + +hash_file() { + local file="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + return + fi + + echo "Unable to find sha256sum or shasum." >&2 + exit 1 +} + +copy_local_properties() { + local destination="$1" + + if [[ -f "$ROOT/local.properties" ]]; then + cp "$ROOT/local.properties" "$destination/local.properties" + fi +} + +build_release() { + local directory="$1" + + ( + cd "$directory" + + ./gradlew \ + :app:assembleRelease \ + --no-daemon \ + --no-build-cache \ + --no-configuration-cache + ) +} + +echo "Testing unsigned release reproducibility" +echo "Commit: $COMMIT" + +git -C "$ROOT" worktree add \ + --detach \ + "$WORKTREE_A" \ + "$COMMIT" + +git -C "$ROOT" worktree add \ + --detach \ + "$WORKTREE_B" \ + "$COMMIT" + +copy_local_properties "$WORKTREE_A" +copy_local_properties "$WORKTREE_B" + +echo +echo "========== BUILD A ==========" +build_release "$WORKTREE_A" + +echo +echo "========== BUILD B ==========" +build_release "$WORKTREE_B" + +APK_A="$WORKTREE_A/app/build/outputs/apk/release/app-release-unsigned.apk" +APK_B="$WORKTREE_B/app/build/outputs/apk/release/app-release-unsigned.apk" + +if [[ ! -f "$APK_A" ]]; then + echo "Release APK A not found: $APK_A" >&2 + exit 1 +fi + +if [[ ! -f "$APK_B" ]]; then + echo "Release APK B not found: $APK_B" >&2 + exit 1 +fi + +HASH_A="$(hash_file "$APK_A")" +HASH_B="$(hash_file "$APK_B")" + +echo +echo "========== SHA-256 ==========" +printf '%s %s\n' "$HASH_A" "$APK_A" +printf '%s %s\n' "$HASH_B" "$APK_B" + +echo +echo "========== COMPARISON ==========" + +if [[ "$HASH_A" != "$HASH_B" ]] || ! cmp -s "$APK_A" "$APK_B"; then + echo "NOT REPRODUCIBLE: unsigned release APK files differ." >&2 + exit 2 +fi + +echo "REPRODUCIBLE: unsigned release APK files are byte-for-byte identical."