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