release-apk.sh raw
1 #!/usr/bin/env bash
2 # Build a signed release APK (arm64-v8a, R8-minified, resources shrunk),
3 # create + push a semver tag via SSH, then print the Gitea release URL
4 # for manual asset upload.
5 #
6 # First run: auto-generates a release keystore at ~/.config/browser-flow/
7 # Subsequent runs: reuses it.
8 #
9 # Auto-bumps minor of latest vX.Y.Z tag. Override with VERSION env.
10 #
11 # Usage:
12 # ./scripts/release-apk.sh
13 # VERSION=v0.5.0 ./scripts/release-apk.sh
14
15 set -euo pipefail
16
17 cd "$(dirname "$0")/.."
18
19 export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
20 export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
21 export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH"
22
23 # ---- Keystore (auto-generate on first run) -------------------------------
24 KEY_DIR="$HOME/.config/browser-flow"
25 KEY_FILE="$KEY_DIR/release.jks"
26 ENV_FILE="$KEY_DIR/release.env"
27 KEY_ALIAS="browser-flow-release"
28
29 if [[ ! -f "$KEY_FILE" ]]; then
30 echo "→ No keystore at ${KEY_FILE}, generating one..."
31 mkdir -p "$KEY_DIR"
32 STORE_PWD=$(openssl rand -base64 24 | tr -d '/=+' | cut -c1-24)
33 KEY_PWD="$STORE_PWD"
34
35 keytool -genkeypair -v \
36 -keystore "$KEY_FILE" \
37 -alias "$KEY_ALIAS" \
38 -keyalg RSA -keysize 2048 -validity 10000 \
39 -storepass "$STORE_PWD" \
40 -keypass "$KEY_PWD" \
41 -dname "CN=Browser Flow, OU=Apps, O=Gitean" \
42 >/dev/null
43
44 cat > "$ENV_FILE" <<EOF
45 BROWSER_FLOW_STORE_FILE=$KEY_FILE
46 BROWSER_FLOW_STORE_PWD=$STORE_PWD
47 BROWSER_FLOW_KEY_ALIAS=$KEY_ALIAS
48 BROWSER_FLOW_KEY_PWD=$KEY_PWD
49 EOF
50 chmod 600 "$KEY_FILE" "$ENV_FILE"
51 echo "✓ Keystore + env written (chmod 600). DO NOT LOSE THESE — needed to push updates."
52 echo " $KEY_FILE"
53 echo " $ENV_FILE"
54 fi
55
56 # shellcheck disable=SC1090
57 source "$ENV_FILE"
58
59 # ---- Derive Gitea host / repo from `origin` ------------------------------
60 REMOTE="$(git remote get-url origin)"
61 if [[ "$REMOTE" =~ ^git@([^:]+):([^/]+)/(.+)\.git$ ]]; then
62 HOST="${BASH_REMATCH[1]}"
63 OWNER="${BASH_REMATCH[2]}"
64 REPO="${BASH_REMATCH[3]}"
65 elif [[ "$REMOTE" =~ ^https?://([^/]+)/([^/]+)/(.+?)(\.git)?$ ]]; then
66 HOST="${BASH_REMATCH[1]}"
67 OWNER="${BASH_REMATCH[2]}"
68 REPO="${BASH_REMATCH[3]}"
69 else
70 echo "Could not parse remote: $REMOTE" >&2
71 exit 1
72 fi
73 echo "→ Repo: ${HOST} ${OWNER}/${REPO}"
74
75 # ---- Determine next tag --------------------------------------------------
76 git fetch --tags --quiet origin || true
77
78 if [[ -n "${VERSION:-}" ]]; then
79 TAG="$VERSION"
80 else
81 LATEST=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \
82 | sort -t. -k1.2,1n -k2,2n -k3,3n \
83 | tail -1)
84 LATEST="${LATEST:-v0.0.0}"
85 IFS='.' read -r MAJ MIN PAT <<< "${LATEST#v}"
86 TAG="v${MAJ}.$((MIN + 1)).0"
87 echo "→ Latest tag: $LATEST bumping minor to ${TAG}"
88 fi
89
90 if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
91 echo "Tag ${TAG} already exists locally. Pass VERSION=... to override." >&2
92 exit 1
93 fi
94
95 SHA="$(git rev-parse --short HEAD)"
96 TS="$(date +%Y-%m-%d\ %H:%M)"
97 VER_NO_V="${TAG#v}"
98
99 # ---- Sync app version + write build-info ---------------------------------
100 echo "→ Stamping version ${VER_NO_V} and commit ${SHA}..."
101
102 # Bump app.json's expo.version so Constants.expoConfig.version matches the tag
103 jq --arg v "$VER_NO_V" '.expo.version = $v' app.json > app.json.tmp && mv app.json.tmp app.json
104
105 # Bump package.json version too (keeps everything aligned)
106 jq --arg v "$VER_NO_V" '.version = $v' package.json > package.json.tmp && mv package.json.tmp package.json
107
108 # Generate constants/build-info.ts so the UI can show "v0.5.0 · abc123"
109 cat > constants/build-info.ts <<EOF
110 // AUTO-GENERATED at release time by scripts/release-apk.sh. Do not edit by hand.
111 export const BUILD_VERSION = '${VER_NO_V}';
112 export const BUILD_COMMIT = '${SHA}';
113 EOF
114
115 # ---- Build (release, arm64-v8a only, R8 minify + shrink) -----------------
116 echo "→ Running expo prebuild (android only)..."
117 npx expo prebuild --platform android --no-install >/dev/null
118
119 # Detect logical CPU count for Gradle worker pool
120 CPUS=$(sysctl -n hw.logicalcpu 2>/dev/null || nproc 2>/dev/null || echo 8)
121 echo "→ Gradle assembleRelease (arm64-v8a, signed, minified, ${CPUS} workers)..."
122 ( cd android && ./gradlew assembleRelease \
123 -x lint -x test \
124 --parallel \
125 --build-cache \
126 --max-workers="${CPUS}" \
127 -Dorg.gradle.jvmargs="-Xmx6g -XX:+UseParallelGC -Dfile.encoding=UTF-8" \
128 -Dorg.gradle.workers.max="${CPUS}" \
129 -Pkotlin.incremental=true \
130 -Pkotlin.parallel.tasks.in.project=true \
131 -PreactNativeArchitectures=arm64-v8a \
132 -PenableProguardInReleaseBuilds=true \
133 -PenableShrinkResourcesInReleaseBuilds=true \
134 "-Pandroid.injected.signing.store.file=${BROWSER_FLOW_STORE_FILE}" \
135 "-Pandroid.injected.signing.store.password=${BROWSER_FLOW_STORE_PWD}" \
136 "-Pandroid.injected.signing.key.alias=${BROWSER_FLOW_KEY_ALIAS}" \
137 "-Pandroid.injected.signing.key.password=${BROWSER_FLOW_KEY_PWD}" )
138
139 # Locate the produced APK (release output path can vary slightly by AGP)
140 APK_SRC=$(find android/app/build/outputs/apk/release -name '*.apk' -not -name '*unsigned*' | head -1)
141 [[ -f "$APK_SRC" ]] || { echo "Release APK not found"; exit 1; }
142
143 mkdir -p dist
144 # Obtainium-friendly name: AppName-vX.Y.Z.apk. App name is read once from
145 # app.json (expo.name) so renaming the app is a single-source change.
146 APP_NAME="$(jq -r '.expo.name' app.json)"
147 APK_OUT="dist/${APP_NAME}-${TAG}.apk"
148 cp "$APK_SRC" "${APK_OUT}"
149 SIZE=$(du -h "${APK_OUT}" | cut -f1)
150 echo "→ Built: ${APK_OUT} (${SIZE})"
151
152 # ---- Commit version bump + tag + push via SSH ----------------------------
153 echo "→ Committing release bump and tagging ${TAG}..."
154 git add app.json package.json constants/build-info.ts
155 # Only create a commit if there are actually staged changes (idempotent re-runs)
156 if ! git diff --cached --quiet; then
157 git commit -m "release: ${TAG}" >/dev/null
158 fi
159 git tag -a "${TAG}" -m "Release ${TAG} sha=${SHA} built=${TS}"
160
161 echo "→ Pushing main + tag to origin (SSH)..."
162 git push origin main
163 git push origin "refs/tags/${TAG}"
164
165 # ---- Create release + upload asset (Gitea API direct) --------------------
166 # We use curl rather than `tea` because tea sometimes fails silently and we
167 # need explicit error handling / retry logic.
168
169 TEA_CFG="$HOME/Library/Application Support/tea/config.yml"
170 TOKEN=$(grep -m1 'token:' "$TEA_CFG" | awk '{print $2}')
171 [[ -n "$TOKEN" ]] || { echo "Could not read tea token from $TEA_CFG"; exit 1; }
172
173 NOTE="Signed release · arm64-v8a · R8 minified · sha=${SHA} · built=${TS}"
174 echo "→ Creating Gitea release ${TAG}..."
175
176 # Try lookup first (idempotency — tag may already have a release if a previous
177 # run got partway through).
178 REL_JSON=$(curl -sS -H "Authorization: token ${TOKEN}" \
179 "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/releases/tags/${TAG}")
180 REL_ID=$(echo "$REL_JSON" | jq -r 'if type == "object" and .id then .id else empty end')
181
182 if [[ -z "$REL_ID" ]]; then
183 # Create new release
184 BODY=$(jq -n --arg tag "$TAG" --arg name "Warden ${TAG}" --arg body "$NOTE" \
185 '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false, target_commitish:"main"}')
186 REL_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" \
187 -d "$BODY" \
188 "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/releases" \
189 | jq -r 'if type == "object" and .id then .id else empty end')
190 fi
191 [[ -n "$REL_ID" ]] || { echo "Could not create or resolve release for ${TAG}"; exit 1; }
192 echo " release id: ${REL_ID}"
193
194 echo "→ Uploading $(basename "${APK_OUT}") to release ${REL_ID}..."
195 UPLOADED=0
196 for i in 1 2 3 4 5 6 7 8; do
197 HTTP=$(curl -sS -o /tmp/tea_upload.json -w "%{http_code}" --max-time 300 \
198 -H "Authorization: token ${TOKEN}" \
199 -F "attachment=@${APK_OUT};type=application/vnd.android.package-archive" \
200 "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/releases/${REL_ID}/assets?name=$(basename "${APK_OUT}")")
201 if [[ "$HTTP" == "201" ]]; then
202 echo "✓ Uploaded."
203 UPLOADED=1
204 break
205 fi
206 # Exponential backoff: 4, 8, 12, 16, 20, 24, 28 seconds
207 WAIT=$((i * 4))
208 echo " attempt $i: HTTP ${HTTP}, retrying in ${WAIT}s..."
209 sleep "${WAIT}"
210 done
211 if [[ "$UPLOADED" != "1" ]]; then
212 echo "⚠️ Upload failed after 8 attempts. Re-run later with:"
213 echo " curl -H \"Authorization: token \$TOKEN\" -F \"attachment=@${APK_OUT}\" \\"
214 echo " \"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/releases/${REL_ID}/assets?name=$(basename "${APK_OUT}")\""
215 fi
216
217 DOWNLOAD_URL="https://${HOST}/${OWNER}/${REPO}/releases/download/${TAG}/$(basename "${APK_OUT}")"
218
219 echo
220 echo "Done."
221 echo
222 echo "Tag: ${TAG}"
223 echo "APK: $(pwd)/${APK_OUT} (${SIZE})"
224 echo "Download: ${DOWNLOAD_URL}"
225