The heredoc approach (`cat >> file << EOF`) was introducing leading whitespace to each line, causing Gradle to treat whitespace as part of property keys. This made hasProperty() return false for signingConfig values, breaking release builds. The fix uses explicit echo blocks that write column-0 strings without indentation artifacts.
286 lines
11 KiB
YAML
286 lines
11 KiB
YAML
name: Frontend CI
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
- master
|
|
- develop
|
|
- dev
|
|
pull_request:
|
|
branches:
|
|
- main
|
|
- master
|
|
workflow_dispatch:
|
|
inputs:
|
|
publish_ota:
|
|
description: 'Publish Android OTA'
|
|
required: false
|
|
default: 'true'
|
|
|
|
env:
|
|
REGISTRY: code.littlelan.cn
|
|
IMAGE_NAME: carrot_bbs/frontend-web
|
|
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
|
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
|
|
|
jobs:
|
|
ota:
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
|
permissions:
|
|
contents: read
|
|
strategy:
|
|
matrix:
|
|
platform: [android, ios]
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Node
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '25.6.1'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Resolve runtime version
|
|
id: runtime
|
|
run: |
|
|
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
|
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
|
|
|
- name: Export update bundle
|
|
run: |
|
|
rm -rf dist-${{ matrix.platform }} dist-${{ matrix.platform }}-update.zip
|
|
npx expo export --platform ${{ matrix.platform }} --output-dir dist-${{ matrix.platform }}
|
|
npx expo config --type public --json > dist-${{ matrix.platform }}/expoConfig.json
|
|
|
|
- name: Archive update bundle
|
|
run: |
|
|
python -c "
|
|
import os, zipfile
|
|
p = '${{ matrix.platform }}'
|
|
dist = f'dist-{p}'
|
|
with zipfile.ZipFile(f'dist-{p}-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
for root, _, files in os.walk(dist):
|
|
for name in files:
|
|
src = os.path.join(root, name)
|
|
arc = os.path.relpath(src, dist)
|
|
zf.write(src, arc)
|
|
"
|
|
|
|
- name: Publish OTA
|
|
env:
|
|
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
|
run: |
|
|
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
|
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${{ matrix.platform }}" \
|
|
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
|
-H "Content-Type: application/zip" \
|
|
--data-binary @"dist-${{ matrix.platform }}-update.zip"
|
|
|
|
- name: Verify OTA manifest
|
|
run: |
|
|
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
|
-H "expo-platform: ${{ matrix.platform }}" \
|
|
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
|
-H "expo-protocol-version: 1" \
|
|
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
|
LOCAL="$(python -c "import json; m=json.load(open('dist-${{ matrix.platform }}/metadata.json')); print(m['fileMetadata']['${{ matrix.platform }}']['bundle'].split('/')[-1])")"
|
|
echo "Remote bundle: ${REMOTE}"
|
|
echo "Local bundle: ${LOCAL}"
|
|
test "${REMOTE}" = "${LOCAL}"
|
|
|
|
build-android-apk:
|
|
runs-on: android-builder
|
|
container:
|
|
image: reactnativecommunity/react-native-android:latest
|
|
env:
|
|
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1"
|
|
NODE_OPTIONS: "--max-old-space-size=2048"
|
|
NODE_ENV: "production"
|
|
GRADLE_USER_HOME: /root/.gradle
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Java
|
|
uses: actions/setup-java@v4
|
|
with:
|
|
distribution: temurin
|
|
java-version: '21'
|
|
|
|
- name: Set up Node
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '25.6.1'
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
|
|
- name: Generate Android native project
|
|
run: npx expo prebuild --platform android
|
|
|
|
- name: Configure Gradle with signing
|
|
run: |
|
|
cd android
|
|
|
|
# Decode Android signing keystore
|
|
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore
|
|
|
|
# Append signing properties to gradle.properties (secrets appended after prebuild for better cache hit rate).
|
|
# IMPORTANT: each echo line writes a column-0 string to gradle.properties — gradle.properties keys
|
|
# cannot have leading whitespace (Gradle treats leading whitespace as part of the key name and
|
|
# hasProperty() then returns false, breaking release signing — see app/build.gradle signingConfigs.release).
|
|
# NB: this block runs with `set -e` by default; if any echo fails the step aborts.
|
|
{
|
|
echo "MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore"
|
|
echo "MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"
|
|
echo "MYAPP_UPLOAD_KEY_ALIAS=withyou-key"
|
|
echo "MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}"
|
|
} >> gradle.properties
|
|
|
|
# CI build tuning (low-memory container): force single-worker, reduce JVM heap,
|
|
# plus restore properties that prebuild does NOT generate but the build needs.
|
|
{
|
|
echo "org.gradle.daemon=false"
|
|
echo "org.gradle.parallel=false"
|
|
echo "org.gradle.configureondemand=false"
|
|
echo "org.gradle.workers.max=1"
|
|
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError"
|
|
echo "kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m"
|
|
echo "ndkVersion=27.1.12297006"
|
|
echo "systemProp.org.gradle.internal.http.connectionTimeout=30000"
|
|
echo "systemProp.org.gradle.internal.http.socketTimeout=30000"
|
|
# ExpoAutolinkingPlugin requires this; without it Gradle calls
|
|
# `node ... mirror-kotlin-inline-modules --watched-directories-serialized ''`
|
|
# and node JSON.parse('') throws SyntaxError, exiting 1.
|
|
# See ExpoAutolinkingPlugin.kt:51 (findProperty fallback to emptyList)
|
|
# and ExpoAutolinkingPlugin.kt:67 (passes value to node as-is).
|
|
echo "expo.inlineModules.watchedDirectories=[]"
|
|
} >> gradle.properties
|
|
|
|
# Verify signing config in app/build.gradle
|
|
echo "=== app/build.gradle signing section ==="
|
|
grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found"
|
|
|
|
# Diagnostic: dump full gradle.properties + verify the signing property is readable
|
|
echo "=== gradle.properties (final) ==="
|
|
cat gradle.properties
|
|
echo "=== MYAPP_UPLOAD_STORE_FILE check ==="
|
|
grep -c '^MYAPP_UPLOAD_STORE_FILE=' gradle.properties || echo "MISSING MYAPP_UPLOAD_STORE_FILE (column 0)"
|
|
|
|
- name: Build Android release APK (arm64 only)
|
|
run: |
|
|
cd android
|
|
chmod +x gradlew
|
|
taskset -c 0-7 ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 --stacktrace --info -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g" 2>&1 | tee gradle-build.log
|
|
echo "===== Last 200 lines of build log ====="
|
|
tail -n 200 gradle-build.log
|
|
|
|
- name: Upload APK artifact
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: withyou-android-release-apk
|
|
path: android/app/build/outputs/apk/release/app-release.apk
|
|
|
|
- name: Resolve runtime version
|
|
id: runtime
|
|
run: |
|
|
RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")"
|
|
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
|
|
|
- name: Upload APK to Updates Server
|
|
env:
|
|
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
|
UPDATES_SERVER_URL: https://updates.littlelan.cn
|
|
run: |
|
|
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
|
|
|
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
|
|
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
|
|
|
|
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
|
|
|
|
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
|
|
-X POST \
|
|
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
|
|
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
|
-H "Content-Type: application/vnd.android.package-archive" \
|
|
--data-binary @"${APK_PATH}")
|
|
|
|
echo "HTTP Response Code: ${HTTP_CODE}"
|
|
cat response.json
|
|
|
|
if [ "${HTTP_CODE}" -ne 200 ]; then
|
|
echo "Failed to upload APK"
|
|
exit 1
|
|
fi
|
|
|
|
echo "APK uploaded successfully!"
|
|
|
|
build-and-push-web:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Log in to Gitea Container Registry
|
|
if: github.event_name != 'pull_request'
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ secrets.GIT_USERNAME }}
|
|
password: ${{ secrets.GIT_TOKEN }}
|
|
|
|
- name: Extract metadata for Docker
|
|
id: meta
|
|
uses: docker/metadata-action@v5
|
|
with:
|
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
tags: |
|
|
type=raw,value=latest,enable={{is_default_branch}}
|
|
type=ref,event=branch
|
|
type=semver,pattern={{version}}
|
|
type=semver,pattern={{major}}.{{minor}}
|
|
type=sha
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@v3
|
|
|
|
- name: Build and push Docker image
|
|
uses: docker/build-push-action@v5
|
|
with:
|
|
context: .
|
|
file: Dockerfile.web
|
|
push: ${{ github.event_name != 'pull_request' }}
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
labels: ${{ steps.meta.outputs.labels }}
|
|
platforms: linux/amd64
|
|
provenance: false
|
|
|
|
- name: Show image tags
|
|
run: |
|
|
echo "Built image tags:"
|
|
echo "${{ steps.meta.outputs.tags }}"
|
|
echo "Digest: ${{ steps.meta.outputs.digest }}"
|
|
|
|
- name: Summary
|
|
if: always()
|
|
run: |
|
|
echo "## Frontend CI Summary" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "**Web image:** ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "**Tags:**" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "**Digest:** ${{ steps.meta.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"
|