docs/maintainer-release-guide.md
This is the operational runbook for publishing a Bitchat Android release to GitHub and Google Play. Follow it from top to bottom for every release.
The central rule is:
GitHub Actions builds and attests the unsigned release. A maintainer signs those exact files locally. Nothing is rebuilt after the tag.
No keystore or password is stored in GitHub, GitHub Actions, the repository, release notes, or workflow artifacts.
This runbook releases both the phone and Wear OS apps under the shared Play
application ID com.bitchat.droid. Wear releases use the independent version
code range beginning at 1000000001; every phone and Wear artifact uploaded to
one Play listing must have a unique version code.
For the technical trust model and third-party verification instructions, see Reproducible builds.
| Stage | Where it happens | Result |
|---|---|---|
| Approve source | Pull requests and the release gate | One reviewed commit on main |
| Create tag | Maintainer machine | Signed vX.Y.Z tag |
| Build twice | GitHub Actions | Two identical unsigned APK/AAB builds |
| Promote build | GitHub Actions | Attested verified-unsigned-release artifact |
| Sign | Maintainer machine | Four installable APKs and two Play upload AABs |
| Test Play build | Play Console internal track | Play-generated APKs tested before public rollout |
| Prepare release | Maintainer machine and GitHub draft | Checksummed public release assets |
| Publish | GitHub Releases and Play Console | Public GitHub release and promoted Play rollout |
These are four separate signing identities:
git tag -s.BITCHAT_GITHUB_RELEASE_CERT_SHA256 in gradle.properties.APK and AAB signatures are embedded in those files. Do not create or publish
detached .sig files. GitHub build-provenance attestations are stored by GitHub
and verified with gh attestation verify; they are not release asset files.
Never request, export, or use Google's Play app-signing private key during this process.
The maintainer needs:
permissionlesstech/bitchat-android;com.bitchat.droid; andCheck the GitHub login:
gh auth status
Install:
tools/reproducible-builds/TOOLCHAIN.env;apksigner and zipalign; andAndroid Studio's SDK Manager can install the required Build Tools version. Select SDK Tools, enable Show Package Details, and install 37.0.0.
Load the toolchain pins and check the local tools:
export JAVA_HOME=/secure/path/to/jdk-21
export ANDROID_SDK_ROOT=/secure/path/to/android-sdk
source tools/reproducible-builds/TOOLCHAIN.env
gh --version
"$JAVA_HOME/bin/java" -version
"$ANDROID_SDK_ROOT/build-tools/$ANDROID_BUILD_TOOLS_VERSION/apksigner" version
"$ANDROID_SDK_ROOT/build-tools/$ANDROID_BUILD_TOOLS_VERSION/zipalign" -h
The JDK output must match JAVA_VERSION; the Android path must use
ANDROID_BUILD_TOOLS_VERSION.
Keep these in a password manager or encrypted offline storage:
The GitHub APK key and Play upload key may be different. Treat them as different credentials even if the project's historical setup placed them in one keystore.
Do not put a keystore inside the repository checkout. Do not place passwords on a command line, in a shell profile, or in a release-notes file.
versionCode and versionName in app/build.gradle.kts. For a Wear
release, also update them in wear/build.gradle.kts using the reserved Wear
version-code range. Every code must be unique across both form factors.
The phone versionName must match the release tag without the leading v.main.Start from a clean, current checkout:
git switch main
git pull --ff-only
git status --short
git log -1 --oneline
git status --short must print nothing.
Set shell variables for the rest of the release:
export REPOSITORY=permissionlesstech/bitchat-android
export TAG=vX.Y.Z
export VERSION_CODE=NN
export WEAR_VERSION_CODE=1000000001
export RELEASE_DIR="release-${TAG#v}"
Confirm the source version:
grep -nE 'versionCode|versionName' app/build.gradle.kts
test "$(sed -n 's/.*versionName = "\([^"]*\)".*/\1/p' app/build.gradle.kts)" = "${TAG#v}"
test "$(sed -n 's/.*versionCode = \([0-9][0-9]*\).*/\1/p' app/build.gradle.kts)" = "$VERSION_CODE"
test "$(sed -n 's/.*versionCode = \([0-9_][0-9_]*\).*/\1/p' wear/build.gradle.kts | tr -d _)" = "$WEAR_VERSION_CODE"
Stop if either test command fails.
Create a signed annotated tag on the approved commit:
git tag -s "$TAG" -m "Bitchat Android $TAG"
git tag -v "$TAG"
git push origin "$TAG"
Do not create the GitHub Release yet. Pushing the tag starts the Release
workflow, which checks out that exact tag.
Never move or replace a release tag after pushing it. If source must change,
increment versionCode, create a new version, and use a new tag.
Find the Release workflow run:
gh run list \
--repo "$REPOSITORY" \
--workflow release.yml \
--limit 20
Copy the run ID for $TAG, then:
export RUN_ID=123456789
gh run watch "$RUN_ID" --repo "$REPOSITORY" --exit-status
gh run view "$RUN_ID" --repo "$REPOSITORY"
The run is successful only when:
verified-unsigned-release is uploaded.If the tag already existed and the workflow must be dispatched manually, run it against the tag ref:
gh workflow run release.yml \
--repo "$REPOSITORY" \
--ref "$TAG" \
-f tag="$TAG"
The workflow rejects a dispatch from a different ref because that would produce incorrect provenance.
With the CLI:
test ! -e "$RELEASE_DIR"
gh run download "$RUN_ID" \
--repo "$REPOSITORY" \
--name verified-unsigned-release \
--dir "$RELEASE_DIR"
In the GitHub web interface:
$TAG.Do not sign unsigned-release-a or unsigned-release-b. Those are the two
replicas retained for diagnostics. Only verified-unsigned-release passed the
comparison gate. Workflow artifacts expire after 30 days, so finish the release
before then.
The directory must initially contain exactly these canonical files:
BUILDINFO.jsonSHA256SUMS.unsignedbitchat-android-arm64-unsigned.apkbitchat-android-armv7-unsigned.apkbitchat-android-universal-unsigned.apkbitchat-android-x86-unsigned.apkbitchat-android-x86_64-unsigned.apkbitchat-android-release-unsigned.aabbitchat-android-wear-unsigned.apkbitchat-android-wear-release-unsigned.aabVerify the checksum manifest:
(
cd "$RELEASE_DIR"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c SHA256SUMS.unsigned
else
shasum -a 256 -c SHA256SUMS.unsigned
fi
)
Verify that BUILDINFO.json identifies the tag commit:
TAG_COMMIT="$(git rev-list -n 1 "$TAG")"
ARTIFACT_COMMIT="$(
sed -n 's/.*"sourceCommit": *"\([^"]*\)".*/\1/p' \
"$RELEASE_DIR/BUILDINFO.json"
)"
test "$TAG_COMMIT" = "$ARTIFACT_COMMIT"
Verify GitHub's provenance for every unsigned APK and AAB:
for artifact in \
"$RELEASE_DIR"/*-unsigned.apk \
"$RELEASE_DIR"/*-unsigned.aab
do
gh attestation verify "$artifact" --repo "$REPOSITORY"
done
Stop immediately if a checksum, commit, or attestation check fails.
Set the SDK and GitHub release-key locations. Load passwords from the password manager without placing their values in shell history:
export ANDROID_SDK_ROOT=/secure/path/to/android-sdk
export BITCHAT_GITHUB_KEYSTORE=/secure/path/to/github-release.jks
export BITCHAT_GITHUB_KEY_ALIAS=release-key-alias
printf 'GitHub keystore password: '
IFS= read -r -s BITCHAT_GITHUB_KEYSTORE_PASSWORD
printf '\nGitHub key password: '
IFS= read -r -s BITCHAT_GITHUB_KEY_PASSWORD
printf '\n'
export BITCHAT_GITHUB_KEYSTORE_PASSWORD
export BITCHAT_GITHUB_KEY_PASSWORD
Run:
tools/reproducible-builds/sign-release.sh "$RELEASE_DIR"
The helper:
SHA256SUMS.unsigned;SHA256SUMS.It creates:
bitchat-android-arm64.apkbitchat-android-universal.apkbitchat-android-wear.apkbitchat-android-x86_64.apkThe unsigned armv7 and x86 APKs remain available for reproducibility, but are not published as signed install targets under the current release policy.
If the helper stops after creating any signed file, do not continue or overwrite files manually. Start again in a new directory downloaded from the same successful workflow run.
Set the pinned JDK and Play upload-key locations, then load the passwords:
export JAVA_HOME=/secure/path/to/jdk-21
export BITCHAT_PLAY_UPLOAD_KEYSTORE=/secure/path/to/play-upload.jks
export BITCHAT_PLAY_UPLOAD_KEY_ALIAS=upload-key-alias
printf 'Play upload keystore password: '
IFS= read -r -s BITCHAT_PLAY_KEYSTORE_PASSWORD
printf '\nPlay upload key password: '
IFS= read -r -s BITCHAT_PLAY_KEY_PASSWORD
printf '\n'
export BITCHAT_PLAY_KEYSTORE_PASSWORD
export BITCHAT_PLAY_KEY_PASSWORD
Run:
tools/reproducible-builds/sign-play-bundle.sh "$RELEASE_DIR"
The helper creates bitchat-android-play-upload.aab and
bitchat-android-wear-play-upload.aab, verifies their JAR signatures, proves
that every non-signature payload entry matches the corresponding canonical
unsigned AAB, and updates SHA256SUMS.
These are the only files to upload to Play Console:
release-X.Y.Z/bitchat-android-play-upload.aab
release-X.Y.Z/bitchat-android-wear-play-upload.aab
Do not upload an APK or either *-release-unsigned.aab to Play. Do not open
Android Studio and rebuild the bundles.
Remove passwords from the environment after both signing steps:
unset BITCHAT_GITHUB_KEYSTORE_PASSWORD
unset BITCHAT_GITHUB_KEY_PASSWORD
unset BITCHAT_PLAY_KEYSTORE_PASSWORD
unset BITCHAT_PLAY_KEY_PASSWORD
Run:
tools/reproducible-builds/prepare-github-release.sh "$RELEASE_DIR"
The helper verifies all checksums and renames the public build information and checksum manifests. It refuses missing or pre-existing release files.
The final GitHub Release must contain all 17 files below:
| Asset | Signed? | Why it is published |
|---|---|---|
bitchat-android-arm64.apk | APK release key | Primary direct-install APK |
bitchat-android-universal.apk | APK release key | Fallback direct-install APK |
bitchat-android-x86_64.apk | APK release key | x86_64 install APK |
bitchat-android-wear.apk | APK release key | Wear OS direct-install APK |
Five phone bitchat-android-*-unsigned.apk files | No | Reproducibility inputs for every phone ABI target |
bitchat-android-release-unsigned.aab | No | Canonical reproducible Play input |
bitchat-android-play-upload.aab | Play upload key | Exact bundle uploaded to Play |
bitchat-android-wear-unsigned.apk | No | Canonical reproducible Wear install input |
bitchat-android-wear-release-unsigned.aab | No | Canonical reproducible Wear Play input |
bitchat-android-wear-play-upload.aab | Play upload key | Exact bundle uploaded to the Wear OS track |
BITCHAT_BUILDINFO.json | GitHub attestation | Source commit and pinned toolchain |
BITCHAT_SHA256SUMS.unsigned | GitHub attestation | Original canonical CI manifest |
BITCHAT_SHA256SUMS | No detached signature | SHA-256 for every published asset |
Run the public checksum verification once more:
(
cd "$RELEASE_DIR"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c BITCHAT_SHA256SUMS
else
shasum -a 256 -c BITCHAT_SHA256SUMS
fi
)
Do not add keystores, certificate exports, passwords, raw release-gate logs, local paths, or device/user identifiers to this directory.
Create a local release-notes file. At minimum it must contain:
## Bitchat Android vX.Y.Z
- Version code: NN
- Wear version: 0.1.0 (code 1000000001)
- Source tag: vX.Y.Z
- GitHub APK signing certificate SHA-256: FINGERPRINT
- Play upload certificate SHA-256: FINGERPRINT
- Play app-signing certificate SHA-256: FINGERPRINT
Checksums, canonical unsigned APK/AAB inputs, the exact Play upload AABs, and
build information are attached. See `docs/reproducible-builds.md` for public
verification instructions.
## Changes
- User-visible change
The GitHub APK fingerprint comes from gradle.properties. Obtain both Play
certificate fingerprints from Play Console > Setup > App integrity. Publish
only the SHA-256 fingerprints, not certificate subject details or private-key
material.
Create a draft release and upload every prepared asset:
gh release create "$TAG" "$RELEASE_DIR"/* \
--repo "$REPOSITORY" \
--verify-tag \
--draft \
--title "Bitchat Android $TAG" \
--notes-file "release-notes-$TAG.md"
--verify-tag prevents gh from silently creating a tag at the wrong commit.
GitHub automatically exposes source archives for the tag; do not upload separate
source ZIP or tar files.
Inspect the draft:
gh release view "$TAG" --repo "$REPOSITORY" --web
Keep it as a draft until the Play internal-track checks below pass.
com.bitchat.droid.release-X.Y.Z/bitchat-android-play-upload.aab.versionCode, and versionName.bitchat-android-play-upload.aab in BITCHAT_SHA256SUMS.For the initial Wear release, open Test and release > Advanced settings >
Form factors, add Wear OS, upload the required Wear screenshot, and use the
dedicated Wear OS only test track. Upload exactly
bitchat-android-wear-play-upload.aab, confirm version code 1000000001, and
complete the Wear OS opt-in and review flow. Promote the already-tested Wear
artifact on its dedicated track; do not add it to the mobile track.
Promote these same tested Play releases from their test tracks to their respective production tracks. Do not rebuild or upload replacement AABs for production. Use staged production rollouts when appropriate.
Google signs the device APKs with the Play app-signing key, so Play-delivered APKs will not be byte-identical to the GitHub APKs. That is expected.
After both Play test builds pass and the GitHub draft has all 17 assets:
gh release edit "$TAG" \
--repo "$REPOSITORY" \
--draft=false \
--latest
Then complete or schedule the production promotion in Play Console. If managed publishing is enabled, send the approved changes for review and publish them at the coordinated release time.
Do not replace assets after the GitHub Release is public. If any published binary is wrong, create a new version and release.
From a clean checkout of the tag:
git checkout "$TAG"
tools/reproducible-builds/verify-github-release.sh "$TAG" --no-rebuild
For the strongest check, omit --no-rebuild and let the pinned container
rebuild and compare all unsigned artifacts:
tools/reproducible-builds/verify-github-release.sh "$TAG"
Also verify:
versionCode and create a new release.Release workflow and two-build comparison passedverified-unsigned-release downloaded by run IDBITCHAT_SHA256SUMS verifies all 17 release assets