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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 85 additions & 9 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
118 changes: 118 additions & 0 deletions tools/check-release-reproducibility.sh
Original file line number Diff line number Diff line change
@@ -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."
Loading