Skip to content

Android Release

Android Release #45

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