diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b52333d..760bd66 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -5,7 +5,36 @@ on: branches: [main] jobs: + check: + # Fast fail: lockfile sync, types, lint — catches in ~2 min what would + # otherwise kill the APK build twenty minutes in. + runs-on: desktop + container: + image: node:22 + steps: + - name: Checkout + run: | + git init -q . + git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}" + git checkout -q FETCH_HEAD + - name: Lockfile + typecheck + lint + run: | + cd app + npm ci --no-audit --no-fund + npx tsc --noEmit + npx eslint . + - name: Notify failure + if: failure() + env: + NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }} + run: | + [ -n "$NTFY_TOKEN" ] || { echo "NTFY_TOKEN not set — skipping notification"; exit 0; } + curl -s -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: gtd CI: check failed" -H "Priority: high" \ + -d "typecheck/lint/lockfile failed at ${{ github.sha }} — https://git.rehbock.xyz/marcus/gtd/actions" \ + https://ntfy.rehbock.xyz/gtd-ci + build: + needs: check # RN/NDK builds starve the 4-core VPS — run on the desktop runner # (marcusDesktop). Jobs queue while the desktop is off and start when it # comes online; switch back to ubuntu-latest to build on the VPS. @@ -81,3 +110,13 @@ jobs: -H "Authorization: token $TOKEN" \ -F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null echo "published $TAG" + + - name: Notify failure + if: failure() + env: + NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }} + run: | + [ -n "$NTFY_TOKEN" ] || { echo "NTFY_TOKEN not set — skipping notification"; exit 0; } + curl -s -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: gtd CI: APK build failed" -H "Priority: high" \ + -d "release build failed at ${{ github.sha }} — https://git.rehbock.xyz/marcus/gtd/actions" \ + https://ntfy.rehbock.xyz/gtd-ci diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..5da50bd --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,12 @@ +#!/bin/sh +# Reject pushes whose lockfile CI's npm (node 22) would refuse — a lock +# written by a newer local npm fails `npm ci` on the runner half a build in. +# Enable with: git config core.hooksPath .githooks +set -e +root=$(git rev-parse --show-toplevel) +if git diff --quiet origin/main...HEAD -- app/package.json app/package-lock.json 2>/dev/null; then + exit 0 +fi +echo "pre-push: package.json/lock changed — validating with CI's npm (node:22)…" +docker run --rm -v "$root/app:/w" -w /w node:22 npm ci --dry-run --no-audit --no-fund >/dev/null +echo "pre-push: lockfile OK" diff --git a/README.md b/README.md index 270a556..ec6004a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr cd app && npx expo start # scan QR with Expo Go (iPhone or Pixel) ``` +- **Node 22 only** (`app/.nvmrc`): CI's npm 10 rejects lockfiles written by newer npm. Never run bare `npm install` to change deps — use `npm run lock` (regenerates the lock via a `node:22` container), or run npm from node 22. +- **Hook**: `git config core.hooksPath .githooks` enables a pre-push check that validates the lockfile against CI's npm before it can break a build. +- **Local Android builds** need JDK 21 (AGP chokes on newer): pinned via `org.gradle.java.home` in `~/.gradle/gradle.properties` → `~/.local/share/java/jdk-21.0.12+8`. +- **`expo prebuild` is safe to re-run**: `app/plugins/withAndroidRelease.js` re-injects the release signing, env-driven versionCode/Name, and gradle.properties tuning into the committed `android/`. + +## Release (Android APK → Obtainium) + +Every push to `main` runs `.gitea/workflows/release.yml` on the desktop runner: fast check job (lockfile/types/lint), then a signed `assembleRelease`, published as Gitea release `v1.` with the APK attached. Phones track it with Obtainium → source `https://git.rehbock.xyz/marcus/gtd` (Forgejo/Gitea source type). CI failures push to ntfy topic `gtd-ci` when the `NTFY_TOKEN` repo secret is set. + ## Deploy ```sh diff --git a/app/.nvmrc b/app/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/app/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/app/android/app/src/main/res/drawable/ic_launcher_background.xml b/app/android/app/src/main/res/drawable/ic_launcher_background.xml index 35c52df..883b2a0 100644 --- a/app/android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,18 +1,6 @@ - - - - - - - - + + + + + + \ No newline at end of file diff --git a/app/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/android/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 8ea7c54..0000000 --- a/app/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/android/app/src/main/res/drawable/ic_launcher_monochrome.xml deleted file mode 100644 index f44ad24..0000000 --- a/app/android/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - diff --git a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 80faec8..9764d2a 100644 --- a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - - - - + + + + \ No newline at end of file diff --git a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 80faec8..9764d2a 100644 --- a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - - - + + + + \ No newline at end of file diff --git a/app/android/gradle.properties b/app/android/gradle.properties index dbe4c11..6cdbc8e 100644 --- a/app/android/gradle.properties +++ b/app/android/gradle.properties @@ -28,8 +28,6 @@ android.enablePngCrunchInReleaseBuilds=true # Use this property to specify which architecture you want to build. # You can also override it from the CLI using # ./gradlew -PreactNativeArchitectures=x86_64 -# arm64 only: Pixel is arm64-v8a; x86/v7a builds are wasted time + APK bloat. -# Add back x86_64 temporarily if an emulator build is ever needed. reactNativeArchitectures=arm64-v8a # Use this property to enable support to the new architecture. diff --git a/app/app.json b/app/app.json index 0799eb5..c92c441 100644 --- a/app/app.json +++ b/app/app.json @@ -25,6 +25,7 @@ "favicon": "./assets/images/favicon.png" }, "plugins": [ + "./plugins/withAndroidRelease", "expo-router", [ "react-native-android-widget", diff --git a/app/package.json b/app/package.json index 2af2f5a..29392cc 100644 --- a/app/package.json +++ b/app/package.json @@ -39,7 +39,11 @@ "eslint-config-expo": "~57.0.1", "typescript": "~6.0.3" }, + "engines": { + "node": "22.x" + }, "scripts": { + "lock": "docker run --rm -v $PWD:/w -w /w node:22 npm install --package-lock-only --no-audit --no-fund", "start": "expo start", "reset-project": "node ./scripts/reset-project.js", "android": "expo run:android", diff --git a/app/plugins/withAndroidRelease.js b/app/plugins/withAndroidRelease.js new file mode 100644 index 0000000..c273b37 --- /dev/null +++ b/app/plugins/withAndroidRelease.js @@ -0,0 +1,59 @@ +// Makes `expo prebuild` safe to re-run: re-applies the hand-maintained Android +// release config that a fresh prebuild would otherwise reset to template +// defaults (android/ is committed; CI builds it and relies on all of this). +const { withAppBuildGradle, withGradleProperties } = require("expo/config-plugins"); + +const RELEASE_SIGNING = ` release { + // Keystore path + password come from env (CI) or ~/.android-keys (local). + def ksPath = System.getenv("KEYSTORE_FILE") ?: "\${System.properties['user.home']}/.android-keys/gtd-release.jks" + def passFile = new File("\${System.properties['user.home']}/.android-keys/gtd-release.password") + def ksPass = System.getenv("KEYSTORE_PASSWORD") ?: (passFile.exists() ? passFile.text.trim() : null) + if (new File(ksPath).exists() && ksPass != null) { + storeFile file(ksPath) + storePassword ksPass + keyAlias System.getenv("KEY_ALIAS") ?: "gtd" + keyPassword System.getenv("KEY_PASSWORD") ?: ksPass + } + } +`; + +function patchBuildGradle(gradle) { + gradle = gradle.replace(/versionCode \d+\n/, 'versionCode ((System.getenv("VERSION_CODE") ?: "1").toInteger())\n'); + gradle = gradle.replace(/versionName "[^"]*"\n/, 'versionName (System.getenv("VERSION_NAME") ?: "1.0-dev")\n'); + if (!gradle.includes("KEYSTORE_FILE")) { + // Insert the release signing config right after the debug one. + gradle = gradle.replace( + /(signingConfigs \{\n debug \{[\s\S]*?\n \}\n)/, + `$1${RELEASE_SIGNING}`, + ); + } + // Release builds use the release key when present, debug key otherwise. + gradle = gradle.replace( + /(release \{\n)(?:\s*\/\/[^\n]*\n)*\s*signingConfig signingConfigs\.debug\n/, + "$1 signingConfig signingConfigs.release.storeFile ? signingConfigs.release : signingConfigs.debug\n", + ); + return gradle; +} + +const GRADLE_PROPS = [ + // RN/NDK builds want headroom; the template default is 2 GB. + { key: "org.gradle.jvmargs", value: "-Xmx8192m -XX:MaxMetaspaceSize=1024m" }, + // arm64 only: Pixel is arm64-v8a; x86/v7a builds are wasted time + APK bloat. + { key: "reactNativeArchitectures", value: "arm64-v8a" }, +]; + +module.exports = function withAndroidRelease(config) { + config = withAppBuildGradle(config, (c) => { + c.modResults.contents = patchBuildGradle(c.modResults.contents); + return c; + }); + config = withGradleProperties(config, (c) => { + for (const { key, value } of GRADLE_PROPS) { + const existing = c.modResults.find((p) => p.type === "property" && p.key === key); + if (existing) existing.value = value; + else c.modResults.push({ type: "property", key, value }); + } + return c; + }); + return config; +};