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 with: fetch-depth: 0 - 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.config.js').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 with: fetch-depth: 0 - 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 1: each echo line writes a column-0 string — gradle.properties keys cannot have # leading whitespace, otherwise hasProperty() returns false and release signing breaks # (see app/build.gradle signingConfigs.release). # IMPORTANT 2: prebuild emits gradle.properties WITHOUT a trailing newline (last line is # `expo.inlineModules.watchedDirectories=[]`). A naive `>>` append concatenates the first # new key onto that last line, corrupting MYAPP_UPLOAD_STORE_FILE. We unconditionally # prepend a newline before the appended block to guarantee a clean line break. # NB: this block runs with `set -e`; if any echo fails the step aborts. { printf '\n' 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. # printf '\n' below is defensive — even though the signing block above already # ensured a trailing newline, both blocks should be independently safe. { printf '\n' 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 all 4 signing keys are at column 0. # Each grep -c MUST print 1; 0 means the key was concatenated onto a previous line # (typically because prebuild's gradle.properties had no trailing newline — see fix above). echo "=== gradle.properties (final) ===" cat gradle.properties echo "=== signing keys column-0 check (each must be 1) ===" for k in MYAPP_UPLOAD_STORE_FILE MYAPP_UPLOAD_STORE_PASSWORD MYAPP_UPLOAD_KEY_ALIAS MYAPP_UPLOAD_KEY_PASSWORD; do count=$(grep -c "^${k}=" gradle.properties || true) echo "${k}=${count}" if [ "${count}" != "1" ]; then echo "FATAL: ${k} not found at column 0 — release signing will fail." exit 1 fi done - 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 -Dkotlin.daemon.jvm.options="-Xmx2g,XX:MaxMetaspaceSize=1g" - 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.config.js').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"