From 49e90adb601f739e8bd291efdd22485d77ea7c73 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 2 Oct 2026 02:38:45 +0800 Subject: [PATCH] [feat] support standalone iOS and Android releases --- .github/workflows/build-android.yml | 18 ++- .github/workflows/build-ios.yml | 19 ++- .github/workflows/build.yml | 2 + .github/workflows/release-mobile.yml | 149 ++++++++++++++++++++ .github/workflows/update-version-json.yml | 1 + apps/android/README.md | 11 ++ apps/android/scripts/generate_notices.py | 16 ++- apps/ios/README.md | 25 +++- apps/ios/scripts/prepare_source_metadata.py | 4 +- 9 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/release-mobile.yml diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index d2e316da..b03d381a 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -3,6 +3,12 @@ name: Build Android on: workflow_dispatch: workflow_call: + inputs: + source_tag: + description: "Existing source tag for a standalone Android release." + required: false + type: string + default: "" outputs: version: description: "Independent Android version and build date." @@ -11,6 +17,7 @@ on: paths: - ".github/workflows/build-android.yml" - ".github/workflows/build.yml" + - ".github/workflows/release-mobile.yml" - ".gitmodules" - "LICENSE" - "apps/android/**" @@ -32,11 +39,14 @@ jobs: version: ${{ steps.package.outputs.version }} env: XMAKE_VERSION: "3.1.1" + CROSSDESK_SOURCE_TAG: ${{ inputs.source_tag || '' }} + CROSSDESK_RELEASE_BUILD: ${{ inputs.source_tag && 'YES' || 'NO' }} steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 + ref: ${{ inputs.source_tag || github.ref }} submodules: recursive persist-credentials: false @@ -120,13 +130,19 @@ jobs: raise ValueError(f"Invalid Android version: {version!r}") build_date = datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d") # Match the dated release tags used by the desktop and iOS jobs. - if os.environ["GITHUB_REF_TYPE"] == "tag": + tag = os.environ.get("CROSSDESK_SOURCE_TAG") + if not tag and os.environ["GITHUB_REF_TYPE"] == "tag": tag = os.environ["GITHUB_REF_NAME"] + if tag: + tag = tag.removeprefix("android-") match = re.fullmatch(r"v?[0-9]+(?:\.[0-9]+){1,3}-(?:[0-9]+-)?([0-9]{8})(?:-[0-9]+)?", tag) if match: build_date = match[1] datetime.datetime.strptime(build_date, "%Y%m%d") version = f"v{version}-{build_date}" + source_tag = os.environ.get("CROSSDESK_SOURCE_TAG", "") + if source_tag.startswith("android-v") and source_tag.removeprefix("android-") != version: + raise ValueError(f"Android source tag does not match the app version: {source_tag} vs {version}") output = Path(os.environ["RUNNER_TEMP"]) / "crossdesk-android" for variant, source in ( ("unsigned", "release/app-release-unsigned.apk"), diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 9061a337..299c5167 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -10,6 +10,11 @@ on: default: "0" workflow_call: inputs: + source_tag: + description: "Existing source tag for a standalone iOS release." + required: false + type: string + default: "" patch: description: "Optional patch number from the main build workflow." required: false @@ -23,6 +28,7 @@ on: paths: - ".github/workflows/build-ios.yml" - ".github/workflows/build.yml" + - ".github/workflows/release-mobile.yml" - ".gitmodules" - "LICENSE" - "apps/ios/**" @@ -42,6 +48,7 @@ jobs: version: ${{ steps.ios_version.outputs.version }} env: GITHUB_TOKEN: ${{ github.token }} + CROSSDESK_SOURCE_TAG: ${{ inputs.source_tag || '' }} IOS_DEPLOYMENT_TARGET: "16.0" EXPECTED_IOS_SDK: "26.5" XMAKE_VERSION: "3.1.1" @@ -53,7 +60,8 @@ jobs: env: PATCH_NUMBER: ${{ inputs.patch || '0' }} run: | - VERSION_REF="${GITHUB_REF##*/}" + VERSION_REF="${CROSSDESK_SOURCE_TAG:-${GITHUB_REF##*/}}" + VERSION_REF="${VERSION_REF#ios-}" VERSION_BASE="${VERSION_REF#v}" BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d) @@ -101,11 +109,14 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 0 + ref: ${{ inputs.source_tag || github.ref }} - name: Select corresponding source tag shell: bash run: | - if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + if [[ -n "${CROSSDESK_SOURCE_TAG}" ]]; then + echo "CROSSDESK_RELEASE_BUILD=YES" >> "${GITHUB_ENV}" + elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then echo "CROSSDESK_SOURCE_TAG=${GITHUB_REF_NAME}" >> "${GITHUB_ENV}" echo "CROSSDESK_RELEASE_BUILD=YES" >> "${GITHUB_ENV}" fi @@ -126,6 +137,10 @@ jobs: exit 1 fi VERSION_NUM="v${IOS_VERSION_BASE}-${VERSION_NUM##*-}" + if [[ "${CROSSDESK_SOURCE_TAG}" == ios-v* && "${CROSSDESK_SOURCE_TAG#ios-}" != "${VERSION_NUM}" ]]; then + echo "iOS source tag does not match the app version: ${CROSSDESK_SOURCE_TAG} vs ${VERSION_NUM}" >&2 + exit 1 + fi echo "IOS_VERSION_BASE=${IOS_VERSION_BASE}" >> "${GITHUB_ENV}" echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}" echo "version=${VERSION_NUM}" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eff22fdc..1ad88556 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,8 @@ on: - "!ci/linux-build-image" tags: - "*" + - "!ios-v*" + - "!android-v*" workflow_dispatch: inputs: patch: diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml new file mode 100644 index 00000000..58dd0f46 --- /dev/null +++ b/.github/workflows/release-mobile.yml @@ -0,0 +1,149 @@ +name: Release Mobile + +on: + push: + tags: + - "ios-v*" + - "android-v*" + workflow_dispatch: + inputs: + source_tag: + description: "Existing tag: ios-v0.0.1-20261002 or android-v0.1.0-20261002." + required: true + type: string + +permissions: + contents: read + +jobs: + prepare: + name: Validate mobile source tag + runs-on: ubuntu-24.04 + outputs: + platform: ${{ steps.source.outputs.platform }} + tag: ${{ steps.source.outputs.tag }} + version: ${{ steps.source.outputs.version }} + sha: ${{ steps.source.outputs.sha }} + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve source tag + id: source + shell: python + env: + SOURCE_TAG: ${{ inputs.source_tag || github.ref_name }} + run: | + import datetime + import os + import re + import subprocess + + tag = os.environ["SOURCE_TAG"] + match = re.fullmatch(r"(ios|android)-(v[0-9]+\.[0-9]+\.[0-9]+-([0-9]{8}))", tag) + if not match: + raise ValueError("Use an existing ios-v-YYYYMMDD or android-v-YYYYMMDD tag") + datetime.datetime.strptime(match[3], "%Y%m%d") + sha = subprocess.check_output( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], text=True).strip() + with open(os.environ["GITHUB_OUTPUT"], "a") as stream: + stream.write(f"platform={match[1]}\ntag={tag}\nversion={match[2]}\nsha={sha}\n") + + build-ios: + name: Build iOS release + needs: prepare + if: needs.prepare.outputs.platform == 'ios' + uses: ./.github/workflows/build-ios.yml + with: + source_tag: ${{ needs.prepare.outputs.tag }} + + build-android: + name: Build Android release + needs: prepare + if: needs.prepare.outputs.platform == 'android' + uses: ./.github/workflows/build-android.yml + with: + source_tag: ${{ needs.prepare.outputs.tag }} + + release: + name: Publish mobile release + needs: [prepare, build-ios, build-android] + if: ${{ !cancelled() && (needs.build-ios.result == 'success' || needs.build-android.result == 'success') }} + runs-on: ubuntu-24.04 + permissions: + contents: write + concurrency: + group: mobile-release-${{ needs.prepare.outputs.tag }} + cancel-in-progress: false + env: + PLATFORM: ${{ needs.prepare.outputs.platform }} + SOURCE_TAG: ${{ needs.prepare.outputs.tag }} + SOURCE_SHA: ${{ needs.prepare.outputs.sha }} + VERSION: ${{ needs.prepare.outputs.version }} + BUILD_VERSION: ${{ needs.build-ios.outputs.version || needs.build-android.outputs.version }} + steps: + - name: Verify release version + shell: bash + run: | + if [[ "${BUILD_VERSION}" != "${VERSION}" ]]; then + echo "Source tag version ${VERSION} does not match the app version ${BUILD_VERSION}" >&2 + exit 1 + fi + + - name: Download mobile release artifact + uses: actions/download-artifact@v8 + with: + name: crossdesk-${{ env.PLATFORM }}-arm64-unsigned-${{ env.VERSION }} + path: release + + - name: Prepare release notes + shell: bash + run: | + if [[ "${PLATFORM}" == "ios" ]]; then + PLATFORM_NAME="iOS" + SIGNING_NOTE="The arm64 ZIP contains an unsigned app, source metadata, third-party sources and any generated dSYM. Sign the app before device installation." + else + PLATFORM_NAME="Android" + SIGNING_NOTE="The arm64 Release APK is unsigned. Sign it with your own key before installation. It includes source metadata, license documents and native build recipes." + fi + cat > "${RUNNER_TEMP}/mobile-release-notes.md" << EOF + ${PLATFORM_NAME} ${VERSION} + + ${SIGNING_NOTE} + + Source: [${SOURCE_TAG}](https://github.com/${GITHUB_REPOSITORY}/tree/${SOURCE_SHA}). + Clone this tag with recursive submodules to obtain the corresponding source. + [Source and rebuild instructions](https://github.com/${GITHUB_REPOSITORY}/blob/${SOURCE_SHA}/apps/${PLATFORM}/README.md). + EOF + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.SOURCE_TAG }} + target_commitish: ${{ env.SOURCE_SHA }} + name: ${{ env.PLATFORM == 'ios' && 'iOS' || 'Android' }} ${{ env.VERSION }} + draft: false + prerelease: false + make_latest: "false" + files: release/* + fail_on_unmatched_files: true + generate_release_notes: false + body_path: ${{ runner.temp }}/mobile-release-notes.md + + - name: Upload mobile artifact to download server + uses: burnett01/rsync-deployments@5.2 + with: + switches: -avzr --progress + path: release/* + remote_path: /var/www/html/downloads/ + remote_host: ${{ secrets.SERVER_HOST }} + remote_user: ${{ secrets.SERVER_USER }} + remote_key: ${{ secrets.SERVER_KEY }} + + - name: Summarize release + shell: bash + run: | + echo "Published [${SOURCE_TAG}](https://github.com/${GITHUB_REPOSITORY}/releases/tag/${SOURCE_TAG}) and uploaded its unsigned ${PLATFORM} artifact to the download server." >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/update-version-json.yml b/.github/workflows/update-version-json.yml index d92bd950..c557b5a7 100644 --- a/.github/workflows/update-version-json.yml +++ b/.github/workflows/update-version-json.yml @@ -10,6 +10,7 @@ permissions: jobs: update-version-json: name: Update version.json with release information + if: ${{ !startsWith(github.event.release.tag_name, 'ios-v') && !startsWith(github.event.release.tag_name, 'android-v') }} runs-on: ubuntu-latest steps: diff --git a/apps/android/README.md b/apps/android/README.md index 7fc7eb87..a4cf70b2 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -75,6 +75,17 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP [`Build Android`](../../.github/workflows/build-android.yml) 可在 Actions 页面单独手动运行;修改 Android、MiniRTC、共享协议或许可证资料的 Pull Request 也会触发。仓库原有的 [`Build and Release`](../../.github/workflows/build.yml) 在分支推送、标签推送及手动运行时调用同一工作流,`v` 标签发布会等待安卓构建成功,并将未签名 Release APK 加入 GitHub Release 和现有下载服务器的产物集合。推送主仓库前,先确保它引用的 MiniRTC 提交已推送到子模块远程仓库,否则 CI 无法完成检出。 +单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先提交 `app/build.gradle` 中需要发布的 `versionName`,再为该提交创建 `android-v-YYYYMMDD` 标签,例如: + +```sh +git tag android-v0.1.0-20261002 +git push origin android-v0.1.0-20261002 +``` + +该标签只触发安卓构建,将未签名 Release APK 发布到独立的 GitHub Release 和现有下载服务器。标签版本必须与应用版本一致,日期必须有效。重试发布时,在 Actions 页面手动运行 `Release Mobile`,将已有标签填入 `source_tag`;即使从其他分支触发,也会检出该标签的源码。APK 的源码资料记录安卓标签及准确的应用、MiniRTC 提交。 + +移动端独立发布不会更新桌面 `latest` 标签、GitHub 的最新 Release 选择或桌面 `version.json`。下载服务器上传保留其他平台文件,复用仓库已有的 `SERVER_HOST`、`SERVER_USER`、`SERVER_KEY` Secrets。普通桌面版本标签仍通过 `Build and Release` 发布全部平台;单独运行 `Build Android` 仍只上传 Actions 构建产物。 + 工作流使用 Ubuntu 24.04、JDK 17、Python 3.13、SDK 36、Build Tools 36.0.0、NDK r28c 和 Xmake 3.1.1。Gradle 使用工程内的 Wrapper,原生构建器固定 Xmake 包仓库版本。Gradle 缓存与 Android 原生依赖缓存分别管理;原生缓存按宿主架构、构建脚本及 MiniRTC 配方区分,不复用桌面或 iOS 的配置和目标文件。 CI 构建 Debug、Release 和设备测试 APK,执行 Debug / Release Lint、本机 C++ 协议与画面队列测试,并校验 APK 的 arm64 ABI、16 KB ELF / ZIP 对齐、完整许可证及 Debug 签名。Ubuntu x64 构建机上不运行 arm64 设备测试;使用下面的设备验证流程在 arm64 手机或模拟器上执行。Lint 报告在构建失败时也会保留。 diff --git a/apps/android/scripts/generate_notices.py b/apps/android/scripts/generate_notices.py index 3f84d97c..f819d26e 100644 --- a/apps/android/scripts/generate_notices.py +++ b/apps/android/scripts/generate_notices.py @@ -2,6 +2,7 @@ """Package existing reviewed notices for the Android runtime dependency set.""" import json import hashlib +import os from pathlib import Path import subprocess from urllib.parse import quote @@ -74,10 +75,19 @@ def write_pages(output, catalog, additional, revision, minirtc): component['documents'] = documents (output / 'ThirdPartyLicenses.json').write_text(json.dumps( {'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') - tags = sorted(tag for tag in git('tag', '--points-at', 'HEAD').splitlines() if tag.startswith('v')) + tags = git('tag', '--points-at', 'HEAD').splitlines() + tag = os.environ.get('CROSSDESK_SOURCE_TAG') + if tag and tag not in tags: + raise ValueError(f'Source tag {tag!r} does not point to the build commit') + if not tag: + versions = sorted(value for value in tags if value.startswith(('v', 'android-v'))) + tag = versions[0] if versions else None + dirty = bool(git('status', '--porcelain', '--untracked-files=normal', '--ignore-submodules=none')) + if os.environ.get('CROSSDESK_RELEASE_BUILD') == 'YES' and (dirty or not tag or not tag.startswith(('v', 'android-v'))): + raise ValueError('Release source must be a clean checkout of a version tag, with matching submodules') metadata = { - 'schemaVersion': 1, 'revision': revision, 'tag': tags[0] if tags else None, - 'isModified': bool(git('status', '--porcelain', '--untracked-files=normal', '--ignore-submodules=none')), + 'schemaVersion': 1, 'revision': revision, 'tag': tag, + 'isModified': dirty, 'sourceURL': source, 'buildInstructionsURL': f'https://github.com/kunkundi/crossdesk/blob/{quote(revision, safe="")}/apps/android/README.md', 'buildDocuments': build_documents, 'miniRTCRevision': minirtc, diff --git a/apps/ios/README.md b/apps/ios/README.md index ceca374e..eb864f57 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -93,7 +93,30 @@ including the app, source metadata, third-party source catalog, rights notice and any generated dSYM. The version comes from the iOS target; dated repository tags provide the build date, otherwise the date uses the Asia/Shanghai timezone. The main workflow collects this same ZIP for GitHub Releases and the download -server. Standalone runs only upload the build artifact. +server. Standalone `Build iOS` runs only upload the build artifact. + +For an independent release, use the +[`Release Mobile`](../../.github/workflows/release-mobile.yml) workflow. Commit +the intended `MARKETING_VERSION` in the iOS target, then tag that commit with +`ios-v-YYYYMMDD`, for example: + +```sh +git tag ios-v0.0.1-20261002 +git push origin ios-v0.0.1-20261002 +``` + +The tag starts only the iOS build and publishes its unsigned ZIP to a dedicated +GitHub Release and the existing download server. The tag version must match the +iOS target, and its date must be valid. To retry a release, run `Release Mobile` +manually and enter the existing tag in `source_tag`; it builds that tag's source +even when the workflow is dispatched from a different branch. Source metadata +records the iOS tag and exact application/MiniRTC commits. + +Mobile releases keep the desktop `latest` tag, GitHub's latest release selection +and desktop `version.json` unchanged. Uploads preserve other platforms' files +on the download server and use the existing `SERVER_HOST`, `SERVER_USER` and +`SERVER_KEY` repository secrets. Regular desktop version tags still publish all +platforms through `Build and Release`. No Apple signing secrets are needed. Sign the app yourself before installing it on a device. Push any referenced MiniRTC commit before the parent repository diff --git a/apps/ios/scripts/prepare_source_metadata.py b/apps/ios/scripts/prepare_source_metadata.py index a80d4c45..a806a81a 100644 --- a/apps/ios/scripts/prepare_source_metadata.py +++ b/apps/ios/scripts/prepare_source_metadata.py @@ -46,9 +46,9 @@ def source_metadata(require_release=False, tag=None): raise ValueError(f"Source tag {tag!r} does not point to the build commit") else: # 'latest' is a moving alias, never a corresponding-source identifier. - versions = sorted(t for t in tags if t.startswith("v")) + versions = sorted(t for t in tags if t.startswith(("v", "ios-v"))) tag = versions[0] if versions else None - if require_release and (dirty or not tag or not tag.startswith("v")): + if require_release and (dirty or not tag or not tag.startswith(("v", "ios-v"))): raise ValueError("Release source must be a clean checkout of a version tag, with matching submodules") mini_revision = git("rev-parse", "HEAD", root=REPO / "deps/submodules/minirtc") lock_path = REPO / "apps/ios/licenses/sources.json"