[feat] support standalone iOS and Android releases

This commit is contained in:
dijunkun
2026-10-02 02:38:45 +08:00
parent 3b5cbf0735
commit 49e90adb60
9 changed files with 236 additions and 9 deletions
+17 -1
View File
@@ -3,6 +3,12 @@ name: Build Android
on: on:
workflow_dispatch: workflow_dispatch:
workflow_call: workflow_call:
inputs:
source_tag:
description: "Existing source tag for a standalone Android release."
required: false
type: string
default: ""
outputs: outputs:
version: version:
description: "Independent Android version and build date." description: "Independent Android version and build date."
@@ -11,6 +17,7 @@ on:
paths: paths:
- ".github/workflows/build-android.yml" - ".github/workflows/build-android.yml"
- ".github/workflows/build.yml" - ".github/workflows/build.yml"
- ".github/workflows/release-mobile.yml"
- ".gitmodules" - ".gitmodules"
- "LICENSE" - "LICENSE"
- "apps/android/**" - "apps/android/**"
@@ -32,11 +39,14 @@ jobs:
version: ${{ steps.package.outputs.version }} version: ${{ steps.package.outputs.version }}
env: env:
XMAKE_VERSION: "3.1.1" XMAKE_VERSION: "3.1.1"
CROSSDESK_SOURCE_TAG: ${{ inputs.source_tag || '' }}
CROSSDESK_RELEASE_BUILD: ${{ inputs.source_tag && 'YES' || 'NO' }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ inputs.source_tag || github.ref }}
submodules: recursive submodules: recursive
persist-credentials: false persist-credentials: false
@@ -120,13 +130,19 @@ jobs:
raise ValueError(f"Invalid Android version: {version!r}") raise ValueError(f"Invalid Android version: {version!r}")
build_date = datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d") build_date = datetime.datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
# Match the dated release tags used by the desktop and iOS jobs. # 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"] 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) match = re.fullmatch(r"v?[0-9]+(?:\.[0-9]+){1,3}-(?:[0-9]+-)?([0-9]{8})(?:-[0-9]+)?", tag)
if match: if match:
build_date = match[1] build_date = match[1]
datetime.datetime.strptime(build_date, "%Y%m%d") datetime.datetime.strptime(build_date, "%Y%m%d")
version = f"v{version}-{build_date}" 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" output = Path(os.environ["RUNNER_TEMP"]) / "crossdesk-android"
for variant, source in ( for variant, source in (
("unsigned", "release/app-release-unsigned.apk"), ("unsigned", "release/app-release-unsigned.apk"),
+17 -2
View File
@@ -10,6 +10,11 @@ on:
default: "0" default: "0"
workflow_call: workflow_call:
inputs: inputs:
source_tag:
description: "Existing source tag for a standalone iOS release."
required: false
type: string
default: ""
patch: patch:
description: "Optional patch number from the main build workflow." description: "Optional patch number from the main build workflow."
required: false required: false
@@ -23,6 +28,7 @@ on:
paths: paths:
- ".github/workflows/build-ios.yml" - ".github/workflows/build-ios.yml"
- ".github/workflows/build.yml" - ".github/workflows/build.yml"
- ".github/workflows/release-mobile.yml"
- ".gitmodules" - ".gitmodules"
- "LICENSE" - "LICENSE"
- "apps/ios/**" - "apps/ios/**"
@@ -42,6 +48,7 @@ jobs:
version: ${{ steps.ios_version.outputs.version }} version: ${{ steps.ios_version.outputs.version }}
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
CROSSDESK_SOURCE_TAG: ${{ inputs.source_tag || '' }}
IOS_DEPLOYMENT_TARGET: "16.0" IOS_DEPLOYMENT_TARGET: "16.0"
EXPECTED_IOS_SDK: "26.5" EXPECTED_IOS_SDK: "26.5"
XMAKE_VERSION: "3.1.1" XMAKE_VERSION: "3.1.1"
@@ -53,7 +60,8 @@ jobs:
env: env:
PATCH_NUMBER: ${{ inputs.patch || '0' }} PATCH_NUMBER: ${{ inputs.patch || '0' }}
run: | run: |
VERSION_REF="${GITHUB_REF##*/}" VERSION_REF="${CROSSDESK_SOURCE_TAG:-${GITHUB_REF##*/}}"
VERSION_REF="${VERSION_REF#ios-}"
VERSION_BASE="${VERSION_REF#v}" VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d) BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
@@ -101,11 +109,14 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ inputs.source_tag || github.ref }}
- name: Select corresponding source tag - name: Select corresponding source tag
shell: bash shell: bash
run: | 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_SOURCE_TAG=${GITHUB_REF_NAME}" >> "${GITHUB_ENV}"
echo "CROSSDESK_RELEASE_BUILD=YES" >> "${GITHUB_ENV}" echo "CROSSDESK_RELEASE_BUILD=YES" >> "${GITHUB_ENV}"
fi fi
@@ -126,6 +137,10 @@ jobs:
exit 1 exit 1
fi fi
VERSION_NUM="v${IOS_VERSION_BASE}-${VERSION_NUM##*-}" 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 "IOS_VERSION_BASE=${IOS_VERSION_BASE}" >> "${GITHUB_ENV}"
echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}" echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}"
echo "version=${VERSION_NUM}" >> "${GITHUB_OUTPUT}" echo "version=${VERSION_NUM}" >> "${GITHUB_OUTPUT}"
+2
View File
@@ -7,6 +7,8 @@ on:
- "!ci/linux-build-image" - "!ci/linux-build-image"
tags: tags:
- "*" - "*"
- "!ios-v*"
- "!android-v*"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
patch: patch:
+149
View File
@@ -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<app-version>-YYYYMMDD or android-v<app-version>-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}"
@@ -10,6 +10,7 @@ permissions:
jobs: jobs:
update-version-json: update-version-json:
name: Update version.json with release information 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 runs-on: ubuntu-latest
steps: steps:
+11
View File
@@ -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 无法完成检出。 [`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<versionName>-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 的配置和目标文件。 工作流使用 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 报告在构建失败时也会保留。 CI 构建 Debug、Release 和设备测试 APK,执行 Debug / Release Lint、本机 C++ 协议与画面队列测试,并校验 APK 的 arm64 ABI、16 KB ELF / ZIP 对齐、完整许可证及 Debug 签名。Ubuntu x64 构建机上不运行 arm64 设备测试;使用下面的设备验证流程在 arm64 手机或模拟器上执行。Lint 报告在构建失败时也会保留。
+13 -3
View File
@@ -2,6 +2,7 @@
"""Package existing reviewed notices for the Android runtime dependency set.""" """Package existing reviewed notices for the Android runtime dependency set."""
import json import json
import hashlib import hashlib
import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
from urllib.parse import quote from urllib.parse import quote
@@ -74,10 +75,19 @@ def write_pages(output, catalog, additional, revision, minirtc):
component['documents'] = documents component['documents'] = documents
(output / 'ThirdPartyLicenses.json').write_text(json.dumps( (output / 'ThirdPartyLicenses.json').write_text(json.dumps(
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') {'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 = { metadata = {
'schemaVersion': 1, 'revision': revision, 'tag': tags[0] if tags else None, 'schemaVersion': 1, 'revision': revision, 'tag': tag,
'isModified': bool(git('status', '--porcelain', '--untracked-files=normal', '--ignore-submodules=none')), 'isModified': dirty,
'sourceURL': source, 'sourceURL': source,
'buildInstructionsURL': f'https://github.com/kunkundi/crossdesk/blob/{quote(revision, safe="")}/apps/android/README.md', 'buildInstructionsURL': f'https://github.com/kunkundi/crossdesk/blob/{quote(revision, safe="")}/apps/android/README.md',
'buildDocuments': build_documents, 'miniRTCRevision': minirtc, 'buildDocuments': build_documents, 'miniRTCRevision': minirtc,
+24 -1
View File
@@ -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 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. 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 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<marketing-version>-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 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 it on a device. Push any referenced MiniRTC commit before the parent repository
+2 -2
View File
@@ -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") raise ValueError(f"Source tag {tag!r} does not point to the build commit")
else: else:
# 'latest' is a moving alias, never a corresponding-source identifier. # '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 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") 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") mini_revision = git("rev-parse", "HEAD", root=REPO / "deps/submodules/minirtc")
lock_path = REPO / "apps/ios/licenses/sources.json" lock_path = REPO / "apps/ios/licenses/sources.json"