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' cache: 'npm' - name: Install dependencies run: npm ci - name: Resolve runtime version id: runtime run: | RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")" 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=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m" _JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m" NODE_OPTIONS: "--max-old-space-size=8192" NODE_ENV: "production" NDK_NUM_JOBS: "4" CMAKE_BUILD_PARALLEL_LEVEL: "8" 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' cache: 'npm' - name: Install dependencies run: npm ci - name: Cache Android NDK uses: actions/cache@v4 id: cache-ndk with: path: /opt/android/ndk key: ndk-v2 - name: Install Android NDK if: steps.cache-ndk.outputs.cache-hit != 'true' run: | echo "Existing NDK versions:" ls /opt/android/ndk/ 2>/dev/null || echo "No NDK dir" echo "Installing required NDK versions..." yes | sdkmanager --install "ndk;27.1.12297006" "ndk;27.0.12077973" echo "NDK after install:" ls /opt/android/ndk/ - name: Cache Gradle uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper ~/.android/build-cache key: ${{ runner.os }}-gradle-${{ hashFiles('package-lock.json') }} restore-keys: | ${{ runner.os }}-gradle- - name: Generate Android native project run: npx expo prebuild --platform android - name: Configure Gradle with China mirrors and signing run: | cd android # Switch Gradle distribution to Tencent mirror PROPS="gradle/wrapper/gradle-wrapper.properties" if [ -f "$PROPS" ]; then sed -i 's|distributionUrl=https\\://services.gradle.org/distributions/|distributionUrl=https\\://mirrors.cloud.tencent.com/gradle/|' "$PROPS" echo "Updated gradle-wrapper.properties:" cat "$PROPS" else echo "gradle-wrapper.properties not found" exit 1 fi # Decode Android signing keystore echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > app/withyou-release-key.keystore # Update settings.gradle with China Maven mirrors cat > settings.gradle << 'SETTINGS_EOF' pluginManagement { repositories { maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/central' } maven { url 'https://developer.huawei.com/repo/' } google() mavenCentral() gradlePluginPortal() } def reactNativeGradlePlugin = new File( providers.exec { workingDir(rootDir) commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") }.standardOutput.asText.get().trim() ).getParentFile().absolutePath includeBuild(reactNativeGradlePlugin) def expoPluginsPath = new File( providers.exec { workingDir(rootDir) commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") }.standardOutput.asText.get().trim(), "../android/expo-gradle-plugin" ).absolutePath includeBuild(expoPluginsPath) } plugins { id("com.facebook.react.settings") id("expo-autolinking-settings") id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/central' } maven { url 'https://developer.huawei.com/repo/' } maven { url 'https://www.jitpack.io' } google() mavenCentral() } } extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { ex.autolinkLibrariesFromCommand() } else { ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) } } expoAutolinking.useExpoModules() rootProject.name = '威友' expoAutolinking.useExpoVersionCatalog() include ':app' includeBuild(expoAutolinking.reactNativeGradlePlugin) SETTINGS_EOF # Update build.gradle with China Maven mirrors cat > build.gradle << 'BUILD_EOF' // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { repositories { maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/central' } maven { url 'https://developer.huawei.com/repo/' } google() mavenCentral() } dependencies { classpath('com.huawei.agconnect:agcp:1.9.1.301') { exclude group: 'com.huawei.agconnect', module: 'agconnect-apms-plugin' } classpath('com.google.gms:google-services:4.4.4') classpath('com.android.tools.build:gradle:8.12.0') classpath('com.facebook.react:react-native-gradle-plugin') classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') } } allprojects { repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/central' } maven { url 'https://www.jitpack.io' } maven { url 'https://developer.huawei.com/repo/' } google() mavenCentral() } } apply plugin: "expo-root-project" apply plugin: "com.facebook.react.rootproject" BUILD_EOF # Update gradle.properties (without secrets for better cache hit rate) cat > gradle.properties << 'PROPS_EOF' org.gradle.daemon=false org.gradle.parallel=true org.gradle.configureondemand=true org.gradle.workers.max=4 org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.enableJetifier=false android.useAndroidX=true hermesEnabled=true reactNativeArchitectures=arm64-v8a newArchEnabled=true edgeToEdgeEnabled=true expo.gif.enabled=true expo.webp.enabled=true expo.webp.animated=false ndkVersion=27.1.12297006 expo.useLegacyPackaging=false systemProp.org.gradle.internal.http.connectionTimeout=30000 systemProp.org.gradle.internal.http.socketTimeout=30000 PROPS_EOF # Append signing properties (secrets appended, not cached) cat >> gradle.properties << SIGNING_PROPS MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} MYAPP_UPLOAD_KEY_ALIAS=withyou-key MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }} SIGNING_PROPS # Verify signing config in app/build.gradle echo "=== app/build.gradle signing section ===" grep -A 20 'signingConfigs' app/build.gradle || echo "signingConfigs not found" - name: Build Android release APK (arm64 only) run: | cd android chmod +x gradlew ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel - 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.version")" 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"