Android Release #45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Android Release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_tag: | |
| description: "Release tag, blank = v<versionName>-yyyyMMddHHmm or v<versionName>-beta-yyyyMMddHHmm" | |
| required: false | |
| type: string | |
| release_channel: | |
| description: "Release channel, auto uses beta on fongmi-sync" | |
| required: false | |
| default: auto | |
| type: choice | |
| options: | |
| - auto | |
| - stable | |
| - beta | |
| release_notes: | |
| description: "Release notes, use \\n for Markdown newlines" | |
| required: false | |
| type: string | |
| prerelease: | |
| description: "Mark as pre-release" | |
| required: false | |
| default: false | |
| type: boolean | |
| sync_cnb: | |
| description: "Sync APK and JSON manifests to CNB" | |
| required: false | |
| default: true | |
| type: boolean | |
| permissions: | |
| contents: write | |
| env: | |
| TZ: Asia/Shanghai | |
| CNB_REPO_URL: ${{ vars.CNB_REPO_URL || 'https://cnb.cool/fish2018/webhtv.git' }} | |
| jobs: | |
| release: | |
| name: Build and publish | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 90 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up JDK 21 | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: "21" | |
| cache: gradle | |
| - name: Set up Python 3.10 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.10" | |
| - name: Set up Android SDK | |
| uses: android-actions/setup-android@v3 | |
| - name: Install Android packages | |
| run: | | |
| sdkmanager "platform-tools" "platforms;android-28" "build-tools;36.0.0" | |
| curl -fsSL -o /tmp/platform-37.zip https://dl.google.com/android/repository/platform-37.0_r01.zip | |
| rm -rf /tmp/platform-37 "$ANDROID_HOME/platforms/android-37" | |
| mkdir -p /tmp/platform-37 "$ANDROID_HOME/platforms" | |
| unzip -q /tmp/platform-37.zip -d /tmp/platform-37 | |
| platform_dir="$(dirname "$(find /tmp/platform-37 -name android.jar -print -quit)")" | |
| test -n "$platform_dir" | |
| mv "$platform_dir" "$ANDROID_HOME/platforms/android-37" | |
| - name: Resolve release tag | |
| id: meta | |
| run: | | |
| VERSION_NAME="$(sed -n 's/^[[:space:]]*versionName[[:space:]]*"\([^"]*\)".*/\1/p' app/build.gradle | head -1)" | |
| VERSION_CODE="$(sed -n 's/^[[:space:]]*versionCode[[:space:]]*\([0-9][0-9]*\).*/\1/p' app/build.gradle | head -1)" | |
| CHANNEL="${{ inputs.release_channel }}" | |
| if [ "$CHANNEL" = "auto" ] || [ -z "$CHANNEL" ]; then | |
| if [ "${{ github.ref_name }}" = "fongmi-sync" ]; then | |
| CHANNEL="beta" | |
| else | |
| CHANNEL="stable" | |
| fi | |
| fi | |
| if [ "$CHANNEL" = "beta" ]; then | |
| APK_SUFFIX="-beta" | |
| TAG_PATTERN="^v${VERSION_NAME}-beta-[0-9]{12}$" | |
| TAG_DEFAULT="v${VERSION_NAME}-beta-$(date +%Y%m%d%H%M)" | |
| TAG_EXPECT="v${VERSION_NAME}-beta-yyyyMMddHHmm" | |
| else | |
| APK_SUFFIX="" | |
| TAG_PATTERN="^v${VERSION_NAME}-[0-9]{12}$" | |
| TAG_DEFAULT="v${VERSION_NAME}-$(date +%Y%m%d%H%M)" | |
| TAG_EXPECT="v${VERSION_NAME}-yyyyMMddHHmm" | |
| fi | |
| TAG="${{ inputs.release_tag }}" | |
| if [ -z "$TAG" ]; then | |
| TAG="$TAG_DEFAULT" | |
| fi | |
| if ! printf '%s' "$TAG" | grep -Eq "$TAG_PATTERN"; then | |
| echo "::error title=Invalid release tag::Expected $TAG_EXPECT, got: $TAG" | |
| exit 1 | |
| fi | |
| if git rev-parse "$TAG" >/dev/null 2>&1; then | |
| echo "::error title=Release tag exists::$TAG already exists" | |
| exit 1 | |
| fi | |
| { | |
| echo "tag=$TAG" | |
| echo "title=${TAG#v}" | |
| echo "version_name=$VERSION_NAME" | |
| echo "version_code=$VERSION_CODE" | |
| echo "short_sha=$(git rev-parse --short=7 HEAD)" | |
| echo "channel=$CHANNEL" | |
| echo "apk_suffix=$APK_SUFFIX" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Configure release signing | |
| env: | |
| RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} | |
| RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} | |
| RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} | |
| RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} | |
| KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} | |
| KEY_ALIAS: ${{ secrets.KEY_ALIAS }} | |
| KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} | |
| run: | | |
| KS_BASE64="${RELEASE_KEYSTORE_BASE64:-$KEYSTORE_BASE64}" | |
| STORE_PASSWORD="${RELEASE_STORE_PASSWORD:-$KEY_PASSWORD}" | |
| ALIAS="${RELEASE_KEY_ALIAS:-$KEY_ALIAS}" | |
| KEY_PASS="${RELEASE_KEY_PASSWORD:-$STORE_PASSWORD}" | |
| if [ -z "$KS_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$ALIAS" ]; then | |
| echo "::error title=Release signing not configured::Configure RELEASE_KEYSTORE_BASE64, RELEASE_KEY_ALIAS and RELEASE_STORE_PASSWORD in repository secrets." | |
| exit 1 | |
| fi | |
| mkdir -p app/signing | |
| printf '%s' "$KS_BASE64" | base64 --decode > app/signing/release.jks | |
| { | |
| echo "storeFile=$GITHUB_WORKSPACE/app/signing/release.jks" | |
| echo "keyAlias=$ALIAS" | |
| echo "storePassword=$STORE_PASSWORD" | |
| echo "keyPassword=$KEY_PASS" | |
| } > local.properties | |
| echo "Release signing configured" | |
| - name: Build four release APKs | |
| env: | |
| WEBHTV_RELEASE_TAG: ${{ steps.meta.outputs.tag }} | |
| WEBHTV_APK_SUFFIX: ${{ steps.meta.outputs.apk_suffix }} | |
| run: | | |
| chmod +x ./gradlew | |
| rm -rf Release/apk | |
| ./gradlew \ | |
| :app:assembleMobileArm64_v8aRelease \ | |
| :app:assembleMobileArmeabi_v7aRelease \ | |
| :app:assembleLeanbackArm64_v8aRelease \ | |
| :app:assembleLeanbackArmeabi_v7aRelease \ | |
| --no-daemon | |
| - name: Collect artifacts | |
| run: | | |
| rm -rf dist | |
| mkdir -p dist | |
| cp Release/apk/*.apk dist/ | |
| ls -lh dist/ | |
| - name: Prepare release notes input | |
| id: release_notes | |
| env: | |
| RELEASE_NOTES_RAW: ${{ inputs.release_notes }} | |
| run: | | |
| python3 - <<'PY' | |
| import os | |
| raw = os.environ.get("RELEASE_NOTES_RAW", "").strip() | |
| notes = ( | |
| raw | |
| .replace("\\r\\n", "\n") | |
| .replace("\\n", "\n") | |
| .replace("\\t", "\t") | |
| .replace('\\"', '"') | |
| .replace("\\'", "'") | |
| ) | |
| delimiter = "RELEASE_NOTES_EOF" | |
| while delimiter in notes: | |
| delimiter += "_" | |
| with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: | |
| fh.write(f"has_notes={'true' if notes else 'false'}\n") | |
| fh.write(f"notes<<{delimiter}\n") | |
| fh.write(notes) | |
| if notes and not notes.endswith("\n"): | |
| fh.write("\n") | |
| fh.write(f"{delimiter}\n") | |
| PY | |
| - name: Generate update manifests | |
| env: | |
| RELEASE_NOTES: ${{ steps.release_notes.outputs.notes }} | |
| run: | | |
| python3 - <<'PY' | |
| import glob | |
| import hashlib | |
| import json | |
| import os | |
| tag = "${{ steps.meta.outputs.tag }}" | |
| notes = os.environ.get("RELEASE_NOTES", "").strip() | |
| for apk in glob.glob("dist/*.apk"): | |
| base = os.path.splitext(os.path.basename(apk))[0] | |
| with open(apk, "rb") as fh: | |
| content = fh.read() | |
| data = { | |
| "name": tag, | |
| "desc": f"{tag} - ${{ steps.meta.outputs.short_sha }}", | |
| "channel": "${{ steps.meta.outputs.channel }}", | |
| "code": int("${{ steps.meta.outputs.version_code }}"), | |
| "apk": f"{base}.apk", | |
| "size": len(content), | |
| "sha256": hashlib.sha256(content).hexdigest(), | |
| } | |
| if notes: | |
| data["notes"] = notes | |
| with open(f"dist/{base}.json", "w", encoding="utf-8") as fh: | |
| json.dump(data, fh, ensure_ascii=False, indent=2) | |
| fh.write("\n") | |
| PY | |
| ls -lh dist/ | |
| - name: Create GitHub Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_NOTES: ${{ steps.release_notes.outputs.notes }} | |
| run: | | |
| PRERELEASE_FLAG="" | |
| if [ "${{ inputs.prerelease }}" = "true" ] || [ "${{ steps.meta.outputs.channel }}" = "beta" ]; then | |
| PRERELEASE_FLAG="--prerelease" | |
| fi | |
| NOTES="${RELEASE_NOTES:-手动触发 GitHub Actions 构建发布。}" | |
| gh release create "${{ steps.meta.outputs.tag }}" \ | |
| --target "$GITHUB_SHA" \ | |
| --title "${{ steps.meta.outputs.title }}" \ | |
| --notes "$NOTES" \ | |
| $PRERELEASE_FLAG \ | |
| dist/*.apk dist/*.json | |
| - name: Sync to CNB | |
| if: ${{ inputs.sync_cnb }} | |
| continue-on-error: true | |
| env: | |
| CNB_TOKEN: ${{ secrets.CNB_TOKEN }} | |
| run: | | |
| if [ -z "$CNB_TOKEN" ]; then | |
| echo "::warning title=CNB sync skipped::CNB_TOKEN secret is not configured." | |
| exit 0 | |
| fi | |
| AUTH_REPO="$(printf '%s' "$CNB_REPO_URL" | sed "s#^https://#https://cnb:${CNB_TOKEN}@#")" | |
| clone_cnb() { | |
| local attempt max_attempts delay | |
| max_attempts=4 | |
| delay=15 | |
| for attempt in $(seq 1 "$max_attempts"); do | |
| echo "CNB clone attempt ${attempt}/${max_attempts}" | |
| rm -rf cnb-mirror | |
| if timeout 180s git -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=60 clone --depth 1 "$AUTH_REPO" cnb-mirror; then | |
| return 0 | |
| fi | |
| if [ "$attempt" -lt "$max_attempts" ]; then | |
| echo "::warning title=CNB clone retry::Clone failed, retrying in ${delay}s." | |
| sleep "$delay" | |
| delay=$((delay * 2)) | |
| fi | |
| done | |
| echo "::error title=CNB clone failed::Cannot connect to CNB from this GitHub runner after ${max_attempts} attempts. If the CNB repository is reachable in a browser, this is likely runner-to-CNB network blocking or timeout." | |
| return 1 | |
| } | |
| clone_cnb | |
| mkdir -p cnb-mirror/apk | |
| cp dist/*.apk dist/*.json cnb-mirror/apk/ | |
| cd cnb-mirror | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add apk/ | |
| git diff --cached --quiet && exit 0 | |
| git commit -m "${{ steps.meta.outputs.tag }}" | |
| git push origin HEAD:main | |
| - name: Upload workflow artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: webhtv-${{ steps.meta.outputs.tag }} | |
| path: dist/* | |
| if-no-files-found: error | |
| retention-days: 30 |