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: with_you/frontend-web OTA_PLATFORM: android OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest jobs: ota-android: runs-on: ubuntu-latest if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true') permissions: contents: read 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 Android update bundle run: | rm -rf dist-android dist-android-update.zip npx expo export --platform android --output-dir dist-android npx expo config --type public --json > dist-android/expoConfig.json - name: Archive Android update bundle run: | python - <<'PY' import os import zipfile with zipfile.ZipFile('dist-android-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf: for root, _, files in os.walk('dist-android'): for name in files: src = os.path.join(root, name) arc = os.path.relpath(src, 'dist-android') zf.write(src, arc) PY - 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=${OTA_PLATFORM}" \ -H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \ -H "Content-Type: application/zip" \ --data-binary @"dist-android-update.zip" - name: Verify OTA manifest run: | REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \ -H "expo-platform: ${OTA_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-android/metadata.json')); print(m['fileMetadata']['android']['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: "4" 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: Cache Gradle uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper ~/.android/build-cache key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }} restore-keys: | ${{ runner.os }}-gradle- - name: Cache node_modules uses: actions/cache@v4 id: cache-node-modules with: path: node_modules key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} restore-keys: | ${{ runner.os }}-node-modules- - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci - name: Generate Android native project run: npx expo prebuild --platform android - name: Decode Android signing keystore run: | echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/withyou-release-key.keystore - name: Patch app/build.gradle with signing config run: | cd android/app python3 -c " import re with open('build.gradle', 'r') as f: content = f.read() release_signing = ''' signingConfigs { release { storeFile file(WITHYOU_UPLOAD_STORE_FILE) storePassword WITHYOU_UPLOAD_STORE_PASSWORD keyAlias WITHYOU_UPLOAD_KEY_ALIAS keyPassword WITHYOU_UPLOAD_KEY_PASSWORD } } ''' content = content.replace('signingConfigs {}', release_signing) content = content.replace('signingConfig signingConfigs.debug', 'signingConfig signingConfigs.release') with open('build.gradle', 'w') as f: f.write(content) " - name: Configure Gradle with Aliyun Maven mirror run: | cd android # Update settings.gradle with Aliyun Maven mirror cat > settings.gradle << 'SETTINGS_EOF' pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/central' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } } 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") } 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 Aliyun Maven mirror cat > build.gradle << 'BUILD_EOF' // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { repositories { google() mavenCentral() maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/central' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } } dependencies { classpath('com.android.tools.build:gradle') classpath('com.facebook.react:react-native-gradle-plugin') classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') } } allprojects { repositories { google() mavenCentral() maven { url 'https://www.jitpack.io' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/central' } } } apply plugin: "expo-root-project" apply plugin: "com.facebook.react.rootproject" BUILD_EOF # Update gradle.properties 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 expo.useLegacyPackaging=false PROPS_EOF - name: Configure Gradle signing properties run: | cd android cat >> gradle.properties << SIGNING_PROPS WITHYOU_UPLOAD_STORE_FILE=withyou-release-key.keystore WITHYOU_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} WITHYOU_UPLOAD_KEY_ALIAS=withyou-key WITHYOU_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }} SIGNING_PROPS - 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 cache-from: type=registry,ref=code.littlelan.cn/with_you/frontend-web:buildcache cache-to: type=registry,ref=code.littlelan.cn/with_you/frontend-web:buildcache,mode=max - 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"