Add newline prepending before appending properties to prevent concatenation with the last line of gradle.properties if it lacks a trailing newline. Replace single key check with a loop that validates all signing keys are correctly placed at column 0.
301 lines
12 KiB
YAML
301 lines
12 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 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.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"
|