CI hardening: fast-fail check job (lockfile/tsc/eslint) gates the APK build + optional ntfy alerts; config plugin makes 'expo prebuild' preserve release signing/versioning; node 22 pinned with docker lock script + pre-push hook
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,36 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
|
needs: check
|
||||||
# RN/NDK builds starve the 4-core VPS — run on the desktop runner
|
# 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
|
# (marcusDesktop). Jobs queue while the desktop is off and start when it
|
||||||
# comes online; switch back to ubuntu-latest to build on the VPS.
|
# comes online; switch back to ubuntu-latest to build on the VPS.
|
||||||
@@ -81,3 +110,13 @@ jobs:
|
|||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
-F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null
|
-F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null
|
||||||
echo "published $TAG"
|
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
|
||||||
|
|||||||
12
.githooks/pre-push
Executable file
12
.githooks/pre-push
Executable file
@@ -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"
|
||||||
@@ -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)
|
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.<run>` 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
|
## Deploy
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
1
app/.nvmrc
Normal file
1
app/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
22
|
||||||
@@ -1,18 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<item android:drawable="@color/splashscreen_background"/>
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
<item>
|
||||||
android:width="108dp"
|
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||||
android:height="108dp"
|
</item>
|
||||||
android:viewportWidth="108"
|
</layer-list>
|
||||||
android:viewportHeight="108">
|
|
||||||
<path android:pathData="M0,0h108v108H0z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:type="linear"
|
|
||||||
android:startX="54" android:startY="0"
|
|
||||||
android:endX="54" android:endY="108"
|
|
||||||
android:startColor="#1A1F2B"
|
|
||||||
android:endColor="#0B0D12" />
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
</vector>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- The four GTD quadrants (next/waiting/someday/projects) with a check in
|
|
||||||
"next" — matches the app's home screen. Grid spans 32..76 to stay inside
|
|
||||||
the adaptive-icon safe zone. -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<!-- top-left: next (green) -->
|
|
||||||
<path android:pathData="M37,32 h11 a5,5 0 0 1 5,5 v11 a5,5 0 0 1 -5,5 h-11 a5,5 0 0 1 -5,-5 v-11 a5,5 0 0 1 5,-5 z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:type="linear"
|
|
||||||
android:startX="34" android:startY="34"
|
|
||||||
android:endX="53" android:endY="53"
|
|
||||||
android:startColor="#4FD48C"
|
|
||||||
android:endColor="#2E9E60" />
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<!-- top-right: waiting (amber) -->
|
|
||||||
<path android:pathData="M60,32 h11 a5,5 0 0 1 5,5 v11 a5,5 0 0 1 -5,5 h-11 a5,5 0 0 1 -5,-5 v-11 a5,5 0 0 1 5,-5 z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:type="linear"
|
|
||||||
android:startX="57" android:startY="34"
|
|
||||||
android:endX="76" android:endY="53"
|
|
||||||
android:startColor="#EDBB55"
|
|
||||||
android:endColor="#C8912B" />
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<!-- bottom-left: someday (violet) -->
|
|
||||||
<path android:pathData="M37,55 h11 a5,5 0 0 1 5,5 v11 a5,5 0 0 1 -5,5 h-11 a5,5 0 0 1 -5,-5 v-11 a5,5 0 0 1 5,-5 z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:type="linear"
|
|
||||||
android:startX="34" android:startY="57"
|
|
||||||
android:endX="53" android:endY="76"
|
|
||||||
android:startColor="#AC9FF0"
|
|
||||||
android:endColor="#7E6DD6" />
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<!-- bottom-right: projects (blue) -->
|
|
||||||
<path android:pathData="M60,55 h11 a5,5 0 0 1 5,5 v11 a5,5 0 0 1 -5,5 h-11 a5,5 0 0 1 -5,-5 v-11 a5,5 0 0 1 5,-5 z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:type="linear"
|
|
||||||
android:startX="57" android:startY="57"
|
|
||||||
android:endX="76" android:endY="76"
|
|
||||||
android:startColor="#6FAAEE"
|
|
||||||
android:endColor="#3F7DC4" />
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<!-- checkmark in the green quadrant -->
|
|
||||||
<path
|
|
||||||
android:pathData="M38,42.8 L42,46.8 L48.5,38.8"
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeWidth="3"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:fillColor="#00000000" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:pathData="M37,33.5 h11 a3.5,3.5 0 0 1 3.5,3.5 v11 a3.5,3.5 0 0 1 -3.5,3.5 h-11 a3.5,3.5 0 0 1 -3.5,-3.5 v-11 a3.5,3.5 0 0 1 3.5,-3.5 z"
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeWidth="3"
|
|
||||||
android:fillColor="#00000000" />
|
|
||||||
<path
|
|
||||||
android:pathData="M60,33.5 h11 a3.5,3.5 0 0 1 3.5,3.5 v11 a3.5,3.5 0 0 1 -3.5,3.5 h-11 a3.5,3.5 0 0 1 -3.5,-3.5 v-11 a3.5,3.5 0 0 1 3.5,-3.5 z"
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeWidth="3"
|
|
||||||
android:fillColor="#00000000" />
|
|
||||||
<path
|
|
||||||
android:pathData="M37,56.5 h11 a3.5,3.5 0 0 1 3.5,3.5 v11 a3.5,3.5 0 0 1 -3.5,3.5 h-11 a3.5,3.5 0 0 1 -3.5,-3.5 v-11 a3.5,3.5 0 0 1 3.5,-3.5 z"
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeWidth="3"
|
|
||||||
android:fillColor="#00000000" />
|
|
||||||
<path
|
|
||||||
android:pathData="M60,56.5 h11 a3.5,3.5 0 0 1 3.5,3.5 v11 a3.5,3.5 0 0 1 -3.5,3.5 h-11 a3.5,3.5 0 0 1 -3.5,-3.5 v-11 a3.5,3.5 0 0 1 3.5,-3.5 z"
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeWidth="3"
|
|
||||||
android:fillColor="#FFFFFF" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -28,8 +28,6 @@ android.enablePngCrunchInReleaseBuilds=true
|
|||||||
# Use this property to specify which architecture you want to build.
|
# Use this property to specify which architecture you want to build.
|
||||||
# You can also override it from the CLI using
|
# You can also override it from the CLI using
|
||||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
# ./gradlew <task> -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
|
reactNativeArchitectures=arm64-v8a
|
||||||
|
|
||||||
# Use this property to enable support to the new architecture.
|
# Use this property to enable support to the new architecture.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"./plugins/withAndroidRelease",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
"react-native-android-widget",
|
"react-native-android-widget",
|
||||||
|
|||||||
@@ -39,7 +39,11 @@
|
|||||||
"eslint-config-expo": "~57.0.1",
|
"eslint-config-expo": "~57.0.1",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"lock": "docker run --rm -v $PWD:/w -w /w node:22 npm install --package-lock-only --no-audit --no-fund",
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
|
|||||||
59
app/plugins/withAndroidRelease.js
Normal file
59
app/plugins/withAndroidRelease.js
Normal file
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user