commit 3c26f05b279530e13cad8a590f088571bfc9fd90
parent 3d0d4c5f6fe5db2697484ff3f6eaf62e0d4e7eca
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Fri, 28 Aug 2026 13:07:32 +0530
Merge pull request #98 from notamitgamer/feat/offline-apk-build
Add separate, opt-in workflow to build an offline Android APK
Diffstat:
2 files changed, 279 insertions(+), 0 deletions(-)
diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml
@@ -0,0 +1,195 @@
+name: Build Offline APK
+
+# Fully separate from main.yml on purpose: if anything here misbehaves,
+# delete this file and the site build/deploy is completely unaffected.
+#
+# Fully separate from main.yml on purpose: if anything here misbehaves,
+# delete this file and the site build/deploy is completely unaffected.
+#
+# This builds the APK SHELL only (the installable app itself) — the
+# expensive part (Android SDK + Gradle). Manual trigger only: the
+# shell rarely if ever needs to change once the app is installed —
+# @capgo/capacitor-updater (baked into the shell) handles all content
+# updates on its own via update-apk-content.yml, which runs cheaply
+# on every push without touching Gradle at all. Run this only when
+# something about the native shell itself actually changes (app icon,
+# plugin versions, native config, etc.).
+on:
+ workflow_dispatch:
+ inputs:
+ release_tag:
+ description: 'Release tag to attach the APK to (created if it does not exist)'
+ required: false
+ default: 'apk-latest'
+
+permissions:
+ contents: write
+
+jobs:
+ build-apk:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Resolve release tag
+ id: tag
+ run: |
+ echo "value=${{ inputs.release_tag || 'apk-latest' }}" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+
+ - name: Install site deps
+ run: npm ci
+
+ - name: Build VitePress site
+ run: npm run docs:build
+
+ # ubuntu-latest ships with Android SDK + a JDK preinstalled, so no
+ # extra SDK download step is needed here.
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Scaffold Capacitor Android project
+ run: |
+ mkdir -p apk-build
+ cd apk-build
+ npm init -y
+ npm install @capacitor/core @capacitor/cli @capacitor/android @capgo/capacitor-updater
+
+ mkdir -p www
+ cp -r ../docs/.vitepress/dist/. www/
+
+ cat > capacitor.config.json <<'EOF'
+ {
+ "appId": "dev.amit.bsc",
+ "appName": "BSc Code Index",
+ "webDir": "www",
+ "bundledWebRuntime": false,
+ "plugins": {
+ "CapacitorUpdater": {
+ "autoUpdate": true,
+ "updateUrl": "https://raw.githubusercontent.com/notamitgamer/bsc/main/apk-manifest/manifest.json"
+ }
+ }
+ }
+ EOF
+
+ npx cap add android
+ npx cap sync android
+
+ # Repo secrets required, added once by hand under
+ # Settings > Secrets and variables > Actions:
+ # ANDROID_KEYSTORE_BASE64 base64 of the .keystore file
+ # ANDROID_KEYSTORE_PASSWORD
+ # ANDROID_KEY_ALIAS
+ # ANDROID_KEY_PASSWORD
+ # Same keystore must be reused for every future build — Android
+ # rejects updates signed with a different key than the installed app.
+ - name: Decode signing keystore
+ working-directory: apk-build/android/app
+ env:
+ ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > release.keystore
+
+ - name: Add signing config to Gradle
+ working-directory: apk-build/android/app
+ env:
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ run: |
+ cat > keystore.properties <<EOF
+ storeFile=release.keystore
+ storePassword=${ANDROID_KEYSTORE_PASSWORD}
+ keyAlias=${ANDROID_KEY_ALIAS}
+ keyPassword=${ANDROID_KEY_PASSWORD}
+ EOF
+
+ python3 - <<'PYEOF'
+ import re
+ path = "build.gradle"
+ with open(path) as f:
+ content = f.read()
+
+ props_load = (
+ "def keystoreProperties = new Properties()\n"
+ "def keystorePropertiesFile = rootProject.file('app/keystore.properties')\n"
+ "if (keystorePropertiesFile.exists()) {\n"
+ " keystoreProperties.load(new FileInputStream(keystorePropertiesFile))\n"
+ "}\n\n"
+ )
+ if "keystoreProperties" not in content:
+ content = props_load + content
+
+ signing_block = (
+ " signingConfigs {\n"
+ " release {\n"
+ " storeFile file(keystoreProperties['storeFile'])\n"
+ " storePassword keystoreProperties['storePassword']\n"
+ " keyAlias keystoreProperties['keyAlias']\n"
+ " keyPassword keystoreProperties['keyPassword']\n"
+ " }\n"
+ " }\n"
+ )
+ content = re.sub(
+ r"(android\s*\{)",
+ r"\1\n" + signing_block,
+ content,
+ count=1,
+ )
+
+ content = re.sub(
+ r"(buildTypes\s*\{\s*release\s*\{)",
+ r"\1\n signingConfig signingConfigs.release",
+ content,
+ count=1,
+ )
+
+ with open(path, "w") as f:
+ f.write(content)
+ PYEOF
+
+ - name: Build signed release APK
+ working-directory: apk-build/android
+ run: |
+ chmod +x ./gradlew
+ ./gradlew assembleRelease --no-daemon
+
+ - name: Locate APK
+ id: apk
+ run: |
+ APK_PATH=$(find apk-build/android -path "*release*" -name "*.apk" | head -n 1)
+ echo "path=$APK_PATH" >> "$GITHUB_OUTPUT"
+
+ - name: Upload APK as workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: bsc-offline-apk
+ path: ${{ steps.apk.outputs.path }}
+
+ - name: Publish to GitHub Release
+ id: release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.tag.outputs.value }}
+ name: Offline APK build (${{ github.run_number }})
+ body: |
+ Signed offline Android APK of the BSc code index site.
+
+ - Release-signed with the repo's dedicated keystore.
+ - Works fully offline after install.
+ - Auto-pulls newer content when online, via the manifest at
+ apk-manifest/manifest.json (raw.githubusercontent.com) —
+ kept up to date on every push by update-apk-content.yml,
+ independently of this shell build.
+ files: |
+ ${{ steps.apk.outputs.path }}
+ prerelease: true
diff --git a/.github/workflows/update-apk-content.yml b/.github/workflows/update-apk-content.yml
@@ -0,0 +1,84 @@
+name: Update APK Content
+
+# The cheap counterpart to build-apk.yml. Runs on every push to main —
+# no Android SDK, no Gradle, just the normal site build plus a zip and
+# a small commit. This is what makes per-push updates affordable: the
+# installed app's shell never rebuilds, it just downloads this new
+# content bundle next time it's online and notices the version changed.
+#
+# paths-ignore excludes apk-manifest/manifest.json since this workflow
+# writes that file itself — without the exclusion, every run would
+# trigger another run in an infinite loop.
+on:
+ push:
+ branches:
+ - main
+ paths-ignore:
+ - 'CHANGELOG.md'
+ - 'apk-manifest/manifest.json'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ update-content:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'npm'
+
+ - name: Install site deps
+ run: npm ci
+
+ - name: Build VitePress site
+ run: npm run docs:build
+
+ - name: Package content bundle
+ id: bundle
+ run: |
+ cd docs/.vitepress/dist
+ zip -qr ../../../bundle.zip .
+ cd ../../..
+ echo "checksum=$(sha256sum bundle.zip | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
+ echo "version=${{ github.run_number }}" >> "$GITHUB_OUTPUT"
+
+ # Reuses the same 'apk-content' release across runs — each push
+ # just replaces the bundle.zip asset in place, it doesn't create
+ # a new release every time.
+ - name: Publish content bundle to release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: apk-content
+ name: APK content bundle (latest)
+ body: |
+ Latest site content bundle for the offline app's auto-updater.
+ Not a standalone release — this backs apk-manifest/manifest.json.
+ files: bundle.zip
+ prerelease: true
+
+ - name: Commit updated manifest to repo
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ mkdir -p apk-manifest
+ cat > apk-manifest/manifest.json <<EOF
+ {
+ "version": "${{ steps.bundle.outputs.version }}",
+ "url": "https://github.com/${{ github.repository }}/releases/download/apk-content/bundle.zip",
+ "checksum": "${{ steps.bundle.outputs.checksum }}",
+ "built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ }
+ EOF
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add apk-manifest/manifest.json
+ git diff --cached --quiet && echo "No manifest changes" || git commit -m "chore: update APK content manifest (run ${{ github.run_number }})"
+ git push origin HEAD:main