Repository navigation
Fix CI: the plugin guard grepped a string cap sync never writes #10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Dev Build Android APK | |
| # Dev build: same build steps as build-android.yml (triggered on `build` | |
| # branch), but: | |
| # - triggers on `dev` branch pushes + manual dispatch | |
| # - NEVER publishes a GitHub Release | |
| # - ALWAYS uploads APKs as GitHub Actions artifacts (for debug) | |
| on: | |
| push: | |
| branches: [ dev ] | |
| workflow_dispatch: | |
| # GitHub Actions already skips workflows when the commit message contains | |
| # [skip ci], [ci skip], [no ci], [skip actions], or [actions skip]. | |
| # The job-level `if` below is an explicit, belt-and-suspenders guard. | |
| jobs: | |
| build: | |
| name: Build electerm Android APK (dev) | |
| runs-on: ubuntu-latest | |
| if: ${{ !contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[no ci]') && !contains(github.event.head_commit.message, '[skip actions]') && !contains(github.event.head_commit.message, '[actions skip]') }} | |
| permissions: | |
| contents: read | |
| environment: build | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: npm | |
| - name: Set up Java (JDK 21) | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 21 | |
| - name: Set up Android SDK | |
| uses: android-actions/setup-android@v3 | |
| - name: Accept Android SDK licenses | |
| run: yes | sdkmanager --licenses || true | |
| - name: Install required Android SDK packages | |
| run: sdkmanager "platforms;android-36" "build-tools;36.0.0" | |
| - name: ci init | |
| run: npm config set legacy-peer-deps true | |
| - name: Install root dependencies (electerm + build tooling) | |
| run: npm i | |
| - name: Install Capacitor dependencies | |
| run: npm --prefix build/android install | |
| - name: Build web frontend + Node.js backend bundle | |
| env: | |
| # JWT secret for the on-device electerm server. Baked into the APK | |
| # at build time by build/android/build.mjs. Must be set in the | |
| # repository GitHub Actions secrets: | |
| # gh secret set SERVER_SECRET --body "$(openssl rand -hex 32)" | |
| SERVER_SECRET: ${{ secrets.SERVER_SECRET }} | |
| run: npm run build:android | |
| - name: Add Android platform (first run only) | |
| run: cd build/android && [ -d android ] || npx cap add android | |
| - name: Sync Capacitor (copies web assets + native plugins) | |
| run: cd build/android && npx cap sync android | |
| - name: Verify file-sharer plugin is registered | |
| # @capgo/capacitor-file-sharer is the secondary (non-Android) download | |
| # path in src/client/web-components/native-file-save.js. If `cap sync` | |
| # doesn't register it (missing dep, sync failure) that fallback is gone. | |
| # | |
| # NOTE: `cap sync` writes the npm package name and the native classpath, | |
| # i.e. {"pkg":"@capgo/capacitor-file-sharer","classpath":"app.capgo. | |
| # filesharer.FileSharerPlugin"}. There is no bare "FileSharer" key, so | |
| # match on the package name (case-insensitive) — an earlier version of | |
| # this guard grepped for '"FileSharer"' and therefore always failed. | |
| run: | | |
| PLUGINS_JSON=build/android/android/app/src/main/assets/capacitor.plugins.json | |
| if [ ! -f "$PLUGINS_JSON" ]; then | |
| echo "::error::capacitor.plugins.json not found at $PLUGINS_JSON" | |
| exit 1 | |
| fi | |
| grep -qi 'capacitor-file-sharer' "$PLUGINS_JSON" || { | |
| echo "::error::@capgo/capacitor-file-sharer NOT registered in $PLUGINS_JSON" | |
| cat "$PLUGINS_JSON" | |
| exit 1 | |
| } | |
| echo "file-sharer plugin registered OK" | |
| - name: Ensure nodejs-mobile gradle variable is defined | |
| # @capawesome/capacitor-nodejs expects $nodejsMobileVersion to be set. | |
| # Append on its own line (variables.gradle has no trailing newline, so a | |
| # plain `echo >>` would concatenate onto the closing `}` and break Gradle). | |
| run: | | |
| cd build/android/android | |
| grep -q "nodejsMobileVersion" variables.gradle || \ | |
| printf '\next.nodejsMobileVersion = "18.20.4-capawesome.1"\n' >> variables.gradle | |
| - name: Apply icon / splash overlay | |
| run: | | |
| # AndroidManifest.xml must go at app/src/main/, NOT inside res/. | |
| # Copy the electerm manifest (with icon, theme, network config) to | |
| # the correct location, overwriting Capacitor's default. | |
| MANIFEST_SRC=build/android/res-overlay/AndroidManifest.xml | |
| MANIFEST_DST=build/android/android/app/src/main/AndroidManifest.xml | |
| if [ -f "$MANIFEST_SRC" ]; then | |
| cp "$MANIFEST_SRC" "$MANIFEST_DST" | |
| echo "Overlayed AndroidManifest.xml -> $MANIFEST_DST" | |
| fi | |
| # Custom MainActivity.java: overrides Capacitor's BridgeActivity to | |
| # opt out of edge-to-edge (setDecorFitsSystemWindows(true)). Without | |
| # this, on Android 15+ (Pixel 9, etc.) the WebView content extends | |
| # under the status bar and the tab bar is unclickable. The Java source | |
| # lives under res-overlay/java/ and must go to the Gradle source tree | |
| # (app/src/main/java/), NOT to res/. | |
| JAVA_SRC=build/android/res-overlay/java | |
| JAVA_DST=build/android/android/app/src/main/java | |
| if [ -d "$JAVA_SRC" ]; then | |
| mkdir -p "$JAVA_DST" | |
| cp -r "$JAVA_SRC/." "$JAVA_DST/" | |
| echo "Overlayed MainActivity.java -> $JAVA_DST" | |
| fi | |
| # Copy all resource dirs (drawable, mipmap-*, values, xml) into res/. | |
| # Using overlay/. merges directory contents into the destination | |
| # without creating extra nesting levels. | |
| cp -r build/android/res-overlay/. build/android/android/app/src/main/res/ | |
| # The bulk cp above also copies res-overlay/java/ into res/java/ — | |
| # Java sources must NOT live inside res/. Remove the stray copy. | |
| rm -rf build/android/android/app/src/main/res/java | |
| # Remove Capacitor's default resources that conflict with / shadow | |
| # the electerm overlay. These are generated by `cap add android` and | |
| # must be deleted AFTER the overlay copy: | |
| # drawable-v24/ic_launcher_foreground.xml — vector foreground that | |
| # takes precedence over our density-specific foreground PNGs | |
| # on API 24+, showing the generic Capacitor logo instead of electerm. | |
| # values/ic_launcher_background.xml — same color name as our | |
| # colors-electerm.xml → duplicate-resource build error. | |
| # drawable*/splash.png — Capacitor ships splash.png in drawable/ | |
| # AND in every density × orientation bucket (drawable-land-hdpi, | |
| # drawable-port-xxxhdpi, …). Our splash is drawable/splash.xml | |
| # (a layer-list). Android resource resolution prefers a | |
| # density/orientation-specific splash.png over the default-density | |
| # splash.xml, so on high-density phones (e.g. Huawei Mate 40 Pro | |
| # = xxxhdpi portrait) the Capacitor plug icon was shown instead | |
| # of the electerm splash. Remove ALL of them. | |
| # NOTE: ic_launcher_round.* are NOT removed — the overlay provides | |
| # electerm round icons (referenced by android:roundIcon) that | |
| # overwrite Capacitor's defaults during the copy above. | |
| RES=build/android/android/app/src/main/res | |
| rm -f "$RES/drawable-v24/ic_launcher_foreground.xml" | |
| rm -f "$RES/values/ic_launcher_background.xml" | |
| find "$RES" -type f -name 'splash.png' -path '*/drawable*' -delete | |
| - name: Verify native download bridge is wired | |
| # The UI page is served by the in-app Node.js server on | |
| # http://127.0.0.1:5577 — NOT by Capacitor's own origin — so Capacitor | |
| # never injects PluginHeaders there and every Capacitor plugin silently | |
| # degrades to its web implementation (a no-op inside the WebView). | |
| # "Download to Downloads" therefore goes through our own | |
| # @JavascriptInterface bridge instead. If the overlay copy above ever | |
| # silently stops landing these files, the APK still builds but downloads | |
| # are broken with no error, so assert they are in the Gradle source tree. | |
| run: | | |
| JAVA=build/android/android/app/src/main/java/org/electerm/electerm | |
| fail=0 | |
| [ -f "$JAVA/ElectermSaveBridge.java" ] || { | |
| echo "::error::ElectermSaveBridge.java missing at $JAVA"; fail=1; } | |
| grep -q 'ElectermNative' "$JAVA/MainActivity.java" || { | |
| echo "::error::MainActivity.java does not install the ElectermNative bridge"; fail=1; } | |
| [ "$fail" -eq 0 ] || exit 1 | |
| echo "native download bridge wired OK" | |
| - name: Configure per-ABI APK splits | |
| # Produce one APK per architecture (arm64-v8a, armeabi-v7a, x86_64) | |
| # plus a universal build, instead of a single ~160MB universal blob. | |
| # The second `android { }` block is cumulative with Capacitor's | |
| # template app/build.gradle; output renaming is done post-build in | |
| # shell to avoid AGP-version-specific output-name APIs. | |
| run: | | |
| GRADLE=build/android/android/app/build.gradle | |
| grep -q 'splits' "$GRADLE" || printf '%s\n' \ | |
| '' \ | |
| '// electerm: split per ABI (injected by CI)' \ | |
| 'android {' \ | |
| ' splits {' \ | |
| ' abi {' \ | |
| ' enable true' \ | |
| ' reset()' \ | |
| " include 'arm64-v8a', 'armeabi-v7a', 'x86_64'" \ | |
| ' universalApk true' \ | |
| ' }' \ | |
| ' }' \ | |
| '}' >> "$GRADLE" | |
| tail -n 15 "$GRADLE" | |
| - name: Cache Gradle distribution | |
| # The Gradle wrapper downloads gradle-8.14.3-all.zip (~150 MB) from | |
| # services.gradle.org on every run. If that download fails with a | |
| # transient network error (e.g. "Unexpected end of file from server"), | |
| # the entire build fails. Caching the extracted distribution avoids | |
| # the download entirely on subsequent runs. | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.gradle/wrapper/dists | |
| key: gradle-wrapper-${{ runner.os }}-${{ hashFiles('build/android/android/gradle/wrapper/gradle-wrapper.properties') }} | |
| restore-keys: | | |
| gradle-wrapper-${{ runner.os }}- | |
| - name: Build debug APK | |
| # Retry up to 3 times to handle transient network errors when | |
| # downloading the Gradle distribution for the first time (i.e. cache miss). | |
| run: | | |
| cd build/android/android && chmod +x gradlew | |
| for i in 1 2 3; do | |
| ./gradlew assembleDebug && break | |
| if [ "$i" -eq 3 ]; then | |
| echo "::error::Build failed after 3 attempts" | |
| exit 1 | |
| fi | |
| echo "Attempt $i failed, retrying in 15s…" | |
| sleep 15 | |
| done | |
| - name: Build release APK (signed with persistent keystore) | |
| env: | |
| ANDROID_RELEASE_KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }} | |
| ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} | |
| ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} | |
| ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} | |
| run: | | |
| cd build/android/android | |
| KEYSTORE="$PWD/app/release.keystore" | |
| printf '%s' "$ANDROID_RELEASE_KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE" | |
| keytool -list -keystore "$KEYSTORE" -storepass "$ANDROID_RELEASE_KEYSTORE_PASSWORD" | |
| ./gradlew assembleRelease \ | |
| -Pandroid.injected.signing.store.file="$KEYSTORE" \ | |
| -Pandroid.injected.signing.store.password="$ANDROID_RELEASE_KEYSTORE_PASSWORD" \ | |
| -Pandroid.injected.signing.key.alias="$ANDROID_RELEASE_KEY_ALIAS" \ | |
| -Pandroid.injected.signing.key.password="$ANDROID_RELEASE_KEY_PASSWORD" | |
| - name: Rename APKs to electerm-android-{arch}-{version}.apk | |
| # Version comes from package.json. Each per-ABI APK is renamed to | |
| # electerm-android-{arch}-{version}.apk (release) / | |
| # electerm-android-{arch}-{version}-debug.apk (debug). The {arch} is | |
| # parsed from gradle's per-ABI output filename; falls back to | |
| # "universal" for the universal build (or if splits are off). | |
| id: rename | |
| run: | | |
| VERSION=$(node -p "require('./package.json').version") | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| APKROOT=build/android/android/app/build/outputs/apk | |
| mkdir -p "$APKROOT/named" | |
| for variant in debug release; do | |
| dir="$APKROOT/$variant" | |
| [ -d "$dir" ] || continue | |
| for apk in "$dir"/*.apk; do | |
| [ -f "$apk" ] || continue | |
| name=$(basename "$apk") | |
| arch=$(printf '%s' "$name" | grep -oE 'arm64-v8a|armeabi-v7a|x86_64|universal' | head -1) | |
| [ -z "$arch" ] && arch=universal | |
| if [ "$variant" = release ]; then | |
| out="electerm-android-$arch-$VERSION.apk" | |
| else | |
| out="electerm-android-$arch-$VERSION-debug.apk" | |
| fi | |
| cp "$apk" "$APKROOT/named/$out" | |
| echo " $variant/$name -> named/$out" | |
| done | |
| done | |
| echo "=== named APKs ===" | |
| ls -la "$APKROOT/named/" | |
| - name: Upload APKs (artifact) | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: electerm-android-dev-apks | |
| path: build/android/android/app/build/outputs/apk/named/*.apk | |
| if-no-files-found: error | |
| retention-days: 30 | |
| - name: Configure AWS credentials for R2 | |
| run: | | |
| mkdir -p ~/.aws | |
| cat > ~/.aws/credentials << 'EOF' | |
| [default] | |
| aws_access_key_id = ${{ secrets.CF_R2_ACCESS_KEY_ID }} | |
| aws_secret_access_key = ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} | |
| EOF | |
| cat > ~/.aws/config << 'EOF' | |
| [default] | |
| region = auto | |
| endpoint_url = ${{ secrets.CF_R2_ENDPOINT }} | |
| EOF | |
| - name: Upload dev APKs to R2 (root folder) | |
| env: | |
| CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} | |
| run: | | |
| for file in build/android/android/app/build/outputs/apk/named/*.apk; do | |
| [ -f "$file" ] || continue | |
| fname=$(basename "$file") | |
| aws s3 cp "$file" "s3://${CF_R2_BUCKET}/${fname}" \ | |
| --acl public-read \ | |
| --cache-control "max-age=31536000" | |
| echo "Uploaded: ${fname}" | |
| done | |
| - name: Verify uploads | |
| env: | |
| CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} | |
| run: | | |
| aws s3 ls "s3://${CF_R2_BUCKET}/" --recursive |