diff --git a/.github/scripts/test_update_version_manifest.py b/.github/scripts/test_update_version_manifest.py
new file mode 100644
index 00000000..fc65dd03
--- /dev/null
+++ b/.github/scripts/test_update_version_manifest.py
@@ -0,0 +1,114 @@
+"""Regression coverage for independent and combined release metadata."""
+
+import copy
+import json
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+
+import update_version_manifest as manifest
+
+
+class ManifestTest(unittest.TestCase):
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory(prefix="crossdesk-manifest-")
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ self.artifacts = self.root / "release"
+ self.artifacts.mkdir()
+ self.current = {
+ "version": "1.6.0-20260930", "latest_version": "1.6.0-20260930",
+ "releaseDate": "2026-09-30", "releaseNotes": "Desktop notes", "patch": 0,
+ "downloads": {"windows-x64": {"url": "https://downloads.crossdesk.cn/existing.exe"}},
+ "custom": {"keep": True},
+ }
+
+ def files(self, *names):
+ for path in self.artifacts.iterdir():
+ path.unlink()
+ for name in names:
+ (self.artifacts / name).write_bytes(b"release fixture")
+ return manifest.artifact_downloads(self.artifacts)
+
+ def merge(self, tag, downloads, current=None):
+ return manifest.merge_release(current or self.current, tag, tag, "Release notes", downloads)
+
+ def test_mobile_preserves_desktop_metadata_and_downloads(self):
+ downloads = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
+ original = copy.deepcopy(self.current)
+ result = self.merge("android-v1.6.0-20261002", downloads)
+ for key, value in original.items():
+ if key != "downloads":
+ self.assertEqual(result[key], value)
+ self.assertEqual(result["downloads"]["windows-x64"], original["downloads"]["windows-x64"])
+ self.assertEqual(result["downloads"]["android-arm64"]["url"],
+ "https://downloads.crossdesk.cn/crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
+ self.assertEqual(result["platforms"]["android"]["latest_version"], "1.6.0-20261002")
+ self.assertEqual(self.current, original)
+
+ def test_sequential_platform_releases_retain_each_other(self):
+ for order in (("ios", "android"), ("android", "ios")):
+ result = copy.deepcopy(self.current)
+ for platform in order:
+ version, extension = ("0.0.2", "zip") if platform == "ios" else ("1.6.0", "apk")
+ downloads = self.files(f"crossdesk-{platform}-arm64-unsigned-v{version}-20261002.{extension}")
+ result = self.merge(f"{platform}-v{version}-20261002", downloads, result)
+ self.assertEqual(set(result["platforms"]), {"ios", "android"})
+ self.assertEqual(result["version"], self.current["version"])
+ self.assertTrue({"windows-x64", "ios-arm64", "android-arm64"} <= result["downloads"].keys())
+
+ def test_combined_release_records_independent_mobile_versions(self):
+ downloads = self.files("crossdesk-win-x64-v1.6.1-20261002.exe",
+ "crossdesk-win-x64-portable-v1.6.1-20261002.zip",
+ "crossdesk-ios-arm64-unsigned-v0.0.2-20261002.zip",
+ "crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
+ result = self.merge("v1.6.1-20261002", downloads)
+ self.assertEqual(result["version"], "1.6.1-20261002")
+ self.assertEqual(result["platforms"]["ios"]["version"], "0.0.2-20261002")
+ self.assertEqual(result["platforms"]["android"]["version"], "1.6.0-20261002")
+
+ def test_mobile_tag_must_match_artifact_and_platform(self):
+ downloads = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
+ for tag in ("android-v1.7.0-20261002", "ios-v1.6.0-20261002"):
+ with self.assertRaises(ValueError):
+ self.merge(tag, downloads)
+
+ def test_invalid_current_manifest_is_rejected(self):
+ downloads = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
+ for current in ([], {}, {"downloads": []}, {"downloads": {}, "platforms": []}):
+ with self.assertRaises(ValueError):
+ manifest.merge_release(current, "android-v1.6.0-20261002", "Android", "", downloads)
+
+ def test_missing_duplicate_and_invalid_artifacts_are_rejected(self):
+ for names in ((), ("crossdesk-android-arm64-unsigned-v1.6.0-20260230.apk",),
+ ("crossdesk-android-arm64-unsigned-v1.6.0-20261001.apk",
+ "crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")):
+ with self.assertRaises(ValueError):
+ self.files(*names)
+
+ def test_hotfix_version_and_date_formats(self):
+ for value in ("v1.6.0-2-20261002", "v1.6.0-20261002-2"):
+ info = manifest.version_info(value)
+ self.assertEqual(info["patch"], 2)
+ self.assertEqual(info["releaseDate"], "2026-10-02")
+
+ def test_cli_preserves_literal_notes_and_release_metadata(self):
+ self.files("crossdesk-ios-arm64-unsigned-v0.0.2-20261002.zip")
+ notes = '中文 release notes\n"quoted" $(not-a-command) $HOME `literal`'
+ release = {"tagName": "ios-v0.0.2-20261002", "name": "iOS release", "body": notes}
+ (self.root / "current.json").write_text(json.dumps(self.current))
+ (self.root / "release.json").write_text(json.dumps(release))
+ output = self.root / "version.json"
+ subprocess.run([sys.executable, str(Path(manifest.__file__)),
+ "--current", str(self.root / "current.json"), "--output", str(output),
+ "--artifacts", str(self.artifacts), "--release-json", str(self.root / "release.json")],
+ check=True, capture_output=True, text=True)
+ result = json.loads(output.read_text())
+ self.assertEqual(result["platforms"]["ios"]["releaseNotes"], notes)
+ self.assertEqual(result["releaseNotes"], "Desktop notes")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/update_version_manifest.py b/.github/scripts/update_version_manifest.py
new file mode 100644
index 00000000..5450e7cb
--- /dev/null
+++ b/.github/scripts/update_version_manifest.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""Merge a release's server downloads into the shared version.json."""
+
+import argparse
+import datetime
+import json
+from pathlib import Path
+import re
+
+
+DOWNLOAD_BASE = "https://downloads.crossdesk.cn/"
+ARTIFACTS = {
+ "windows-x64": ("win-x64", "exe"),
+ "windows-x64-portable": ("win-x64-portable", "zip"),
+ "macos-x64": ("macos-x64", "pkg"),
+ "macos-arm64": ("macos-arm64", "pkg"),
+ "linux-amd64": ("linux-amd64", "deb"),
+ "linux-arm64": ("linux-arm64", "deb"),
+ "ios-arm64": ("ios-arm64-unsigned", "zip"),
+ "android-arm64": ("android-arm64-unsigned", "apk"),
+}
+
+
+def version_info(version, fallback_date=None):
+ version = version.removeprefix("v")
+ base = r"[0-9]+(?:\.[0-9]+){1,3}"
+ for pattern in (
+ rf"(?P{base})-(?P[0-9]+)-(?P[0-9]{{8}})",
+ rf"(?P{base})-(?P[0-9]{{8}})(?:-(?P[0-9]+))?",
+ rf"(?P{base})(?:-(?P[0-9]+))?",
+ ):
+ match = re.fullmatch(pattern, version)
+ if match:
+ fields = match.groupdict()
+ date = fields.get("date")
+ release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
+ if date else fallback_date)
+ if not release_date:
+ raise ValueError(f"Release date is missing for {version}")
+ datetime.date.fromisoformat(release_date)
+ return {"latest_version": version, "version": version,
+ "releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
+ raise ValueError(f"Invalid release version: {version}")
+
+
+def artifact_downloads(directory):
+ downloads = {}
+ for path in sorted(Path(directory).iterdir()):
+ if not path.is_file():
+ continue
+ for key, (prefix, extension) in ARTIFACTS.items():
+ match = re.fullmatch(rf"crossdesk-{prefix}-v([0-9][0-9.\-]*)\.{extension}", path.name)
+ if not match:
+ continue
+ if key in downloads:
+ raise ValueError(f"Multiple release artifacts for {key}")
+ metadata = version_info(match[1])
+ downloads[key] = {"url": DOWNLOAD_BASE + path.name, "filename": path.name,
+ "version": metadata["version"], "releaseDate": metadata["releaseDate"]}
+ break
+ if not downloads:
+ raise ValueError("No supported release artifacts found")
+ return downloads
+
+
+def merge_release(current, tag, name, notes, downloads, published_date=None):
+ if not isinstance(current, dict) or not isinstance(current.get("downloads"), dict):
+ raise ValueError("Current version.json must contain a downloads object")
+ if not isinstance(current.get("platforms", {}), dict):
+ raise ValueError("Current version.json platforms must be an object")
+ mobile = re.fullmatch(r"(ios|android)-(v.+)", tag)
+ if mobile:
+ platform, version = mobile.groups()
+ expected_key = f"{platform}-arm64"
+ if set(downloads) != {expected_key}:
+ raise ValueError(f"{tag} must contain only the {expected_key} release artifact")
+ metadata = version_info(version, published_date)
+ if downloads[expected_key]["version"] != metadata["version"]:
+ raise ValueError("Mobile source tag does not match the artifact version")
+ else:
+ if not tag.startswith("v"):
+ raise ValueError(f"Expected a version tag, got {tag!r}")
+ metadata = version_info(tag, published_date)
+
+ result = dict(current)
+ result["downloads"] = {**current["downloads"], **downloads}
+ result["platforms"] = dict(current.get("platforms", {}))
+ release = {**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag}
+ if not mobile:
+ result.update(release)
+ for platform in ("ios", "android"):
+ download = downloads.get(f"{platform}-arm64")
+ if download:
+ info = version_info(download["version"])
+ result["platforms"][platform] = {
+ **info, "releaseName": name, "releaseNotes": notes, "tagName": tag,
+ }
+ return result
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--current", required=True, type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("--artifacts", required=True, type=Path)
+ parser.add_argument("--tag")
+ parser.add_argument("--name")
+ parser.add_argument("--notes", type=Path)
+ parser.add_argument("--release-json", type=Path)
+ args = parser.parse_args()
+ if args.release_json:
+ release = json.loads(args.release_json.read_text())
+ tag = release["tagName"]
+ name = release.get("name") or tag
+ notes = release.get("body") or ""
+ published_date = (release.get("publishedAt") or "").split("T")[0] or None
+ else:
+ if not args.tag or not args.name or not args.notes:
+ parser.error("Supply --release-json or --tag, --name and --notes")
+ tag, name, notes = args.tag, args.name, args.notes.read_text()
+ published_date = None
+ current = json.loads(args.current.read_text())
+ downloads = artifact_downloads(args.artifacts)
+ result = merge_release(current, tag, name, notes, downloads, published_date)
+ args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
+ print(f"Updated {tag}: {', '.join(downloads)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1ad88556..d679951c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -561,6 +561,9 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-linux, build-macos, build-ios, build-android, build-windows-x64]
runs-on: ubuntu-latest
+ concurrency:
+ group: publish-release-metadata
+ cancel-in-progress: false
steps:
- name: Checkout repository
@@ -571,6 +574,9 @@ jobs:
with:
path: artifacts
+ - name: Verify release manifest updates
+ run: python3 -B -m unittest discover -s .github/scripts -p 'test_*.py' -v
+
- name: Extract version number
id: version
shell: bash
@@ -681,63 +687,32 @@ jobs:
- name: Upload artifacts to server
uses: burnett01/rsync-deployments@5.2
with:
- switches: -avzr --progress --delete
+ 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: Generate version.json
+ - name: Download current version.json
run: |
- cat > version.json << EOF
- {
- "latest_version": "${{ steps.version.outputs.VERSION_NUM }}",
- "version": "${{ steps.version.outputs.VERSION_NUM }}",
- "releaseDate": "${{ steps.version.outputs.BUILD_DATE_ISO }}",
- "patch": ${{ steps.version.outputs.PATCH_NUMBER }},
- "releaseName": "",
- "releaseNotes": "",
- "tagName": "${{ steps.version.outputs.VERSION_WITH_V }}",
- "downloads": {
- "windows-x64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe",
- "filename": "crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe"
- },
- "windows-x64-portable": {
- "url": "https://downloads.crossdesk.cn/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip",
- "filename": "crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip"
- },
- "macos-x64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg",
- "filename": "crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg"
- },
- "macos-arm64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg",
- "filename": "crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg"
- },
- "linux-amd64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}.deb",
- "filename": "crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}.deb"
- },
- "linux-arm64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.deb",
- "filename": "crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.deb"
- }
- }
- }
- EOF
- jq --arg release_name "Release ${{ steps.version.outputs.VERSION_WITH_V }}" \
- --rawfile release_notes "$RUNNER_TEMP/release-notes.md" \
- '.releaseName = $release_name | .releaseNotes = $release_notes' \
- version.json > version-with-notes.json
- mv version-with-notes.json version.json
- cat version.json
+ curl --fail --silent --show-error --retry 3 --max-time 30 \
+ -H 'Cache-Control: no-cache' \
+ https://version.crossdesk.cn/version.json -o current-version.json
+
+ - name: Generate version.json for all platforms
+ env:
+ RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }}
+ run: |
+ python3 .github/scripts/update_version_manifest.py \
+ --current current-version.json --output version.json --artifacts release \
+ --tag "${RELEASE_TAG}" --name "Release ${RELEASE_TAG}" \
+ --notes "${RUNNER_TEMP}/release-notes.md"
- name: Upload version.json to server
uses: burnett01/rsync-deployments@5.2
with:
- switches: -avzr --delete
+ switches: -avzr
path: version.json
remote_path: /var/www/html/version/
remote_host: ${{ secrets.SERVER_HOST }}
diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml
index 58dd0f46..3c2cb395 100644
--- a/.github/workflows/release-mobile.yml
+++ b/.github/workflows/release-mobile.yml
@@ -76,7 +76,7 @@ jobs:
permissions:
contents: write
concurrency:
- group: mobile-release-${{ needs.prepare.outputs.tag }}
+ group: publish-release-metadata
cancel-in-progress: false
env:
PLATFORM: ${{ needs.prepare.outputs.platform }}
@@ -85,6 +85,12 @@ jobs:
VERSION: ${{ needs.prepare.outputs.version }}
BUILD_VERSION: ${{ needs.build-ios.outputs.version || needs.build-android.outputs.version }}
steps:
+ - name: Checkout publishing scripts
+ uses: actions/checkout@v5
+
+ - name: Verify release manifest updates
+ run: python3 -B -m unittest discover -s .github/scripts -p 'test_*.py' -v
+
- name: Verify release version
shell: bash
run: |
@@ -143,7 +149,34 @@ jobs:
remote_user: ${{ secrets.SERVER_USER }}
remote_key: ${{ secrets.SERVER_KEY }}
+ - name: Download current version.json
+ shell: bash
+ run: |
+ curl --fail --silent --show-error --retry 3 --max-time 30 \
+ -H 'Cache-Control: no-cache' \
+ https://version.crossdesk.cn/version.json -o current-version.json
+
+ - name: Update mobile version and download URL
+ shell: bash
+ env:
+ RELEASE_NAME: ${{ env.PLATFORM == 'ios' && 'iOS' || 'Android' }} ${{ env.VERSION }}
+ run: |
+ python3 .github/scripts/update_version_manifest.py \
+ --current current-version.json --output version.json --artifacts release \
+ --tag "${SOURCE_TAG}" --name "${RELEASE_NAME}" \
+ --notes "${RUNNER_TEMP}/mobile-release-notes.md"
+
+ - name: Upload version.json to server
+ uses: burnett01/rsync-deployments@5.2
+ with:
+ switches: -avzr
+ path: version.json
+ remote_path: /var/www/html/version/
+ 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}"
+ echo "Published [${SOURCE_TAG}](https://github.com/${GITHUB_REPOSITORY}/releases/tag/${SOURCE_TAG}), uploaded its unsigned ${PLATFORM} artifact to the download server, and updated version.json." >> "${GITHUB_STEP_SUMMARY}"
diff --git a/.github/workflows/update-version-json.yml b/.github/workflows/update-version-json.yml
index c557b5a7..bac5809b 100644
--- a/.github/workflows/update-version-json.yml
+++ b/.github/workflows/update-version-json.yml
@@ -3,161 +3,85 @@ name: Update version.json from Release
on:
release:
types: [published, edited]
+ workflow_dispatch:
+ inputs:
+ source_tag:
+ description: "Existing version tag whose assets and metadata should be uploaded to the server."
+ required: true
+ type: string
permissions:
- contents: write
+ contents: read
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
-
+ name: Upload release downloads and update version.json
+ if: github.event_name == 'workflow_dispatch' || (github.event.release.tag_name != 'latest' && !github.event.release.prerelease)
+ runs-on: ubuntu-24.04
+ concurrency:
+ group: publish-release-metadata
+ cancel-in-progress: false
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ inputs.source_tag || github.event.release.tag_name }}
steps:
- - name: Checkout repository
- uses: actions/checkout@v4
+ - name: Checkout publishing scripts
+ uses: actions/checkout@v5
with:
- token: ${{ secrets.GITHUB_TOKEN }}
+ ref: ${{ github.event.repository.default_branch }}
- - name: Extract version from tag
- id: version
+ - name: Verify release manifest updates
+ run: python3 -B -m unittest discover -s .github/scripts -p 'test_*.py' -v
+
+ - name: Download release information and assets
shell: bash
run: |
- TAG_NAME="${{ github.event.release.tag_name }}"
- TAG_VERSION="${TAG_NAME#v}"
- VERSION_FULL="${TAG_VERSION}"
- VERSION_BASE="${TAG_VERSION}"
- PATCH_NUMBER=0
+ python3 - <<'PY'
+ import os
+ import re
+ if not re.fullmatch(r'(?:ios-|android-)?v[0-9]+(?:\.[0-9]+){1,3}(?:-[0-9]+)*', os.environ['RELEASE_TAG']):
+ raise ValueError('Use a desktop, iOS or Android version tag')
+ PY
+ gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
+ --json tagName,name,body,publishedAt,isDraft,isPrerelease > release.json
+ python3 - <<'PY'
+ import json
+ from pathlib import Path
+ release = json.loads(Path('release.json').read_text())
+ if release['isDraft'] or release['isPrerelease']:
+ raise ValueError('Only published stable releases update version.json')
+ PY
+ gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
+ --pattern 'crossdesk-*' --dir release
- # Extract date and patch from tags such as v1.2.3-1-20251113.
- if [[ "${TAG_VERSION}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
- VERSION_BASE="${BASH_REMATCH[1]}"
- PATCH_NUMBER="${BASH_REMATCH[3]}"
- DATE_STR="${BASH_REMATCH[4]}"
- BUILD_DATE_ISO="${DATE_STR:0:4}-${DATE_STR:4:2}-${DATE_STR:6:2}"
- elif [[ "${TAG_VERSION}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
- VERSION_BASE="${BASH_REMATCH[1]}"
- DATE_STR="${BASH_REMATCH[3]}"
- PATCH_NUMBER="${BASH_REMATCH[4]}"
- BUILD_DATE_ISO="${DATE_STR:0:4}-${DATE_STR:4:2}-${DATE_STR:6:2}"
- elif [[ "${TAG_VERSION}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
- VERSION_BASE="${BASH_REMATCH[1]}"
- DATE_STR="${BASH_REMATCH[3]}"
- BUILD_DATE_ISO="${DATE_STR:0:4}-${DATE_STR:4:2}-${DATE_STR:6:2}"
- else
- # Use release published date
- BUILD_DATE_ISO=$(echo "${{ github.event.release.published_at }}" | cut -d'T' -f1)
- fi
+ - name: Upload release assets 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 }}
- echo "TAG_NAME=${TAG_NAME}" >> $GITHUB_OUTPUT
- echo "VERSION_FULL=${VERSION_FULL}" >> $GITHUB_OUTPUT
- echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_OUTPUT
- echo "BUILD_DATE_ISO=${BUILD_DATE_ISO}" >> $GITHUB_OUTPUT
- echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
-
- - name: Install jq
- run: sudo apt-get update && sudo apt-get install -y jq
-
- - name: Get release information
- id: release_info
+ - name: Download current version.json
+ shell: bash
run: |
- # Use jq to properly escape JSON
- RELEASE_BODY="${{ github.event.release.body }}"
- RELEASE_NAME="${{ github.event.release.name }}"
+ curl --fail --silent --show-error --retry 3 --max-time 30 \
+ -H 'Cache-Control: no-cache' \
+ https://version.crossdesk.cn/version.json -o current-version.json
- # Handle empty values
- if [ -z "$RELEASE_BODY" ]; then
- RELEASE_BODY=""
- fi
- if [ -z "$RELEASE_NAME" ]; then
- RELEASE_NAME=""
- fi
-
- # Save to temporary files for proper handling
- echo -n "$RELEASE_BODY" > /tmp/release_body.txt
- echo -n "$RELEASE_NAME" > /tmp/release_name.txt
-
- # Use jq to escape JSON strings
- RELEASE_BODY_JSON=$(jq -Rs . < /tmp/release_body.txt)
- RELEASE_NAME_JSON=$(jq -Rs . < /tmp/release_name.txt)
-
- echo "RELEASE_BODY=${RELEASE_BODY_JSON}" >> $GITHUB_OUTPUT
- echo "RELEASE_NAME=${RELEASE_NAME_JSON}" >> $GITHUB_OUTPUT
-
- - name: Download current version.json from server
- id: download_version
- continue-on-error: true
+ - name: Merge release version and download URLs
+ shell: bash
run: |
- # Try to download current version.json from server
- curl -f -s "https://version.crossdesk.cn/version.json" -o version.json || echo "Failed to download, will create new one"
-
- - name: Generate or update version.json
- run: |
- # If version.json exists, try to preserve downloads section
- if [ -f version.json ] && jq -e '.downloads' version.json > /dev/null 2>&1; then
- EXISTING_DOWNLOADS=$(jq -c '.downloads' version.json)
- if [ "$EXISTING_DOWNLOADS" != "null" ] && [ "$EXISTING_DOWNLOADS" != "{}" ]; then
- DOWNLOADS_JSON="$EXISTING_DOWNLOADS"
- else
- DOWNLOADS_JSON=""
- fi
- else
- DOWNLOADS_JSON=""
- fi
-
- # If downloads is empty, use default structure
- if [ -z "$DOWNLOADS_JSON" ]; then
- DOWNLOADS_JSON=$(cat << DOWNLOADS_EOF
- {
- "windows-x64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-win-x64-${{ steps.version.outputs.TAG_NAME }}.exe",
- "filename": "crossdesk-win-x64-${{ steps.version.outputs.TAG_NAME }}.exe"
- },
- "windows-x64-portable": {
- "url": "https://downloads.crossdesk.cn/crossdesk-win-x64-portable-${{ steps.version.outputs.TAG_NAME }}.zip",
- "filename": "crossdesk-win-x64-portable-${{ steps.version.outputs.TAG_NAME }}.zip"
- },
- "macos-x64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-macos-x64-${{ steps.version.outputs.TAG_NAME }}.pkg",
- "filename": "crossdesk-macos-x64-${{ steps.version.outputs.TAG_NAME }}.pkg"
- },
- "macos-arm64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-macos-arm64-${{ steps.version.outputs.TAG_NAME }}.pkg",
- "filename": "crossdesk-macos-arm64-${{ steps.version.outputs.TAG_NAME }}.pkg"
- },
- "linux-amd64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-linux-amd64-${{ steps.version.outputs.TAG_NAME }}.deb",
- "filename": "crossdesk-linux-amd64-${{ steps.version.outputs.TAG_NAME }}.deb"
- },
- "linux-arm64": {
- "url": "https://downloads.crossdesk.cn/crossdesk-linux-arm64-${{ steps.version.outputs.TAG_NAME }}.deb",
- "filename": "crossdesk-linux-arm64-${{ steps.version.outputs.TAG_NAME }}.deb"
- }
- }
- DOWNLOADS_EOF
- )
- fi
-
- # Generate version.json using cat and heredoc
- cat > version.json << EOF
- {
- "latest_version": "${{ steps.version.outputs.VERSION_FULL }}",
- "version": "${{ steps.version.outputs.VERSION_FULL }}",
- "releaseDate": "${{ steps.version.outputs.BUILD_DATE_ISO }}",
- "patch": ${{ steps.version.outputs.PATCH_NUMBER }},
- "releaseName": ${{ steps.release_info.outputs.RELEASE_NAME }},
- "releaseNotes": ${{ steps.release_info.outputs.RELEASE_BODY }},
- "tagName": "${{ steps.version.outputs.TAG_NAME }}",
- "downloads": ${DOWNLOADS_JSON}
- }
- EOF
-
- cat version.json
+ python3 .github/scripts/update_version_manifest.py \
+ --current current-version.json --output version.json --artifacts release \
+ --release-json release.json
- name: Upload version.json to server
uses: burnett01/rsync-deployments@5.2
with:
- switches: -avzr --delete
+ switches: -avzr
path: version.json
remote_path: /var/www/html/version/
remote_host: ${{ secrets.SERVER_HOST }}
diff --git a/apps/android/README.md b/apps/android/README.md
index 14ee8439..cbae5fe5 100644
--- a/apps/android/README.md
+++ b/apps/android/README.md
@@ -84,7 +84,9 @@ git push origin android-v1.6.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 构建产物。
+移动端独立发布保留桌面 `latest` 标签和 GitHub 的最新 Release 选择。APK 上传完成后,将服务器下载地址合并到共享 `https://version.crossdesk.cn/version.json` 的 `downloads.android-arm64`,将安卓版本、发布日期和说明写入 `platforms.android`;保留顶层桌面版本信息和其他平台的下载项。下载服务器上传保留其他平台文件,复用仓库已有的 `SERVER_HOST`、`SERVER_USER`、`SERVER_KEY` Secrets。普通桌面版本标签仍通过 `Build and Release` 发布全部平台并更新各平台下载项;所有发布任务更新版本文件时使用同一队列。单独运行 `Build Android` 仍只上传 Actions 构建产物。
+
+已有 Release 需要补传服务器或刷新下载信息时,可手动运行 `Update version.json from Release` 并填写已有 `source_tag`,无需重新构建。
工作流使用 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 的配置和目标文件。
diff --git a/apps/ios/README.md b/apps/ios/README.md
index eb864f57..b6222f86 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -112,11 +112,17 @@ 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`.
+Mobile releases keep the desktop `latest` tag and GitHub's latest release
+selection unchanged. After uploading the ZIP, the workflow merges its server
+URL into `downloads.ios-arm64` and its version, date and release notes into
+`platforms.ios` in the shared `https://version.crossdesk.cn/version.json`.
+Top-level desktop version information and other platforms' entries are retained.
+Uploads preserve other platforms' files and use the existing `SERVER_HOST`,
+`SERVER_USER` and `SERVER_KEY` repository secrets. Regular desktop version tags
+still publish all platforms and update their download entries through
+`Build and Release`. All publishing jobs share one queue when updating the manifest.
+To upload an existing release and refresh its download metadata without rebuilding,
+run `Update version.json from Release` with its existing `source_tag`.
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