From 6da213d7db5c24d11586607b8bbb60764e1f30e0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 6 Aug 2026 19:14:54 +0800 Subject: [PATCH 1/5] feat(ci): publish desktop updates to Cloudflare R2 --- .github/workflows/build-desktop-tauri.yml | 136 ++++++++ .gitignore | 1 + docs/development.md | 6 +- docs/environment-variables.md | 15 + .../ci/build-desktop-tauri-workflow.test.mjs | 55 +++ scripts/ci/generate_tauri_latest_json.py | 43 ++- scripts/ci/publish_r2_release.py | 316 ++++++++++++++++++ scripts/ci/test_generate_tauri_latest_json.py | 59 ++++ scripts/ci/test_publish_r2_release.py | 290 ++++++++++++++++ 9 files changed, 918 insertions(+), 3 deletions(-) create mode 100644 scripts/ci/publish_r2_release.py create mode 100644 scripts/ci/test_publish_r2_release.py diff --git a/.github/workflows/build-desktop-tauri.yml b/.github/workflows/build-desktop-tauri.yml index 446a45e4..d8b70b63 100644 --- a/.github/workflows/build-desktop-tauri.yml +++ b/.github/workflows/build-desktop-tauri.yml @@ -44,6 +44,9 @@ env: ASTRBOT_NIGHTLY_SCHEDULE_CRON: ${{ vars.ASTRBOT_NIGHTLY_SCHEDULE_CRON || '7 3 * * *' }} ASTRBOT_NIGHTLY_UTC_HOUR: ${{ vars.ASTRBOT_NIGHTLY_UTC_HOUR || '3' }} ASTRBOT_DESKTOP_UPDATER_PUBLIC_KEY: ${{ vars.ASTRBOT_DESKTOP_UPDATER_PUBLIC_KEY || '' }} + R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID || '' }} + R2_BUCKET: ${{ vars.R2_BUCKET || 'astrbot-desktop-releases' }} + R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL || 'https://releases.astrbot.app' }} jobs: resolve_build_context: @@ -644,11 +647,14 @@ jobs: python3 scripts/ci/validate-release-artifacts.py release-artifacts - name: Generate Tauri updater manifest + id: updater_manifest if: ${{ needs.resolve_build_context.outputs.build_mode != 'custom' }} env: RELEASE_TAG: ${{ needs.resolve_build_context.outputs.release_tag }} RELEASE_VERSION: ${{ needs.resolve_build_context.outputs.astrbot_version }} BUILD_MODE: ${{ needs.resolve_build_context.outputs.build_mode }} + R2_PUBLIC_BASE_URL: ${{ env.R2_PUBLIC_BASE_URL }} + R2_RELEASE_ID: ${{ github.run_id }}-${{ github.run_attempt }} shell: bash run: | set -euo pipefail @@ -666,8 +672,94 @@ jobs: --tag "${RELEASE_TAG}" \ --version "${RELEASE_VERSION}" \ --channel "${manifest_channel}" \ + --asset-base-url "${R2_PUBLIC_BASE_URL%/}/desktop/releases/${RELEASE_VERSION}/${R2_RELEASE_ID}" \ --output "${manifest_output}" + printf 'channel=%s\n' "${manifest_channel}" >> "${GITHUB_OUTPUT}" + printf 'path=%s\n' "${manifest_output}" >> "${GITHUB_OUTPUT}" + + - name: Upload immutable release objects to Cloudflare R2 + if: ${{ needs.resolve_build_context.outputs.build_mode != 'custom' }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + RELEASE_VERSION: ${{ needs.resolve_build_context.outputs.astrbot_version }} + R2_RELEASE_ID: ${{ github.run_id }}-${{ github.run_attempt }} + R2_ACCOUNT_ID: ${{ env.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ env.R2_BUCKET }} + UPDATER_CHANNEL: ${{ steps.updater_manifest.outputs.channel }} + UPDATER_MANIFEST: ${{ steps.updater_manifest.outputs.path }} + shell: bash + run: | + set -euo pipefail + + if [ -z "${AWS_ACCESS_KEY_ID}" ] || [ -z "${AWS_SECRET_ACCESS_KEY}" ]; then + echo "::error::R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY repository secrets are required." + exit 1 + fi + if [ -z "${R2_ACCOUNT_ID}" ]; then + echo "::error::R2_ACCOUNT_ID repository variable is required." + exit 1 + fi + + aws --version + python3 -m scripts.ci.publish_r2_release \ + --artifacts-root release-artifacts \ + --manifest "${UPDATER_MANIFEST}" \ + --version "${RELEASE_VERSION}" \ + --release-id "${R2_RELEASE_ID}" \ + --channel "${UPDATER_CHANNEL}" \ + --phase artifacts \ + --bucket "${R2_BUCKET}" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + + - name: Verify immutable R2 updater objects on public origin + if: ${{ needs.resolve_build_context.outputs.build_mode != 'custom' }} + env: + R2_PUBLIC_BASE_URL: ${{ env.R2_PUBLIC_BASE_URL }} + UPDATER_MANIFEST: ${{ steps.updater_manifest.outputs.path }} + shell: bash + run: | + set -euo pipefail + + updater_urls_file="${RUNNER_TEMP}/r2-updater-urls.txt" + python3 - "${UPDATER_MANIFEST}" "${R2_PUBLIC_BASE_URL%/}" > "${updater_urls_file}" <<'PY' + import json + import sys + from urllib.parse import urlsplit + + manifest_path, expected_origin = sys.argv[1:] + with open(manifest_path, encoding="utf-8") as file: + manifest = json.load(file) + + expected = urlsplit(expected_origin) + if expected.scheme != "https" or not expected.netloc: + raise SystemExit(f"Invalid R2 public origin: {expected_origin!r}") + + urls = sorted( + platform.get("url", "") + for platform in manifest.get("platforms", {}).values() + ) + if not urls or any(not url for url in urls): + raise SystemExit("Updater manifest has no complete platform URLs") + + for url in urls: + parsed = urlsplit(url) + if (parsed.scheme, parsed.netloc) != (expected.scheme, expected.netloc): + raise SystemExit( + f"Updater artifact URL is outside the configured R2 origin: {url!r}" + ) + print(url) + PY + + while IFS= read -r updater_url; do + echo "Waiting for public R2 object: ${updater_url}" + curl --fail --silent --show-error --location --head \ + --retry 20 --retry-delay 10 --retry-max-time 300 --retry-all-errors \ + "${updater_url}" >/dev/null + done < "${updater_urls_file}" + - name: Remove existing assets from target release env: GH_TOKEN: ${{ github.token }} @@ -694,6 +786,50 @@ jobs: files: release-artifacts/**/* fail_on_unmatched_files: true + - name: Promote Cloudflare R2 updater channel + if: ${{ needs.resolve_build_context.outputs.build_mode != 'custom' }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + RELEASE_VERSION: ${{ needs.resolve_build_context.outputs.astrbot_version }} + R2_RELEASE_ID: ${{ github.run_id }}-${{ github.run_attempt }} + R2_ACCOUNT_ID: ${{ env.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ env.R2_BUCKET }} + R2_PUBLIC_BASE_URL: ${{ env.R2_PUBLIC_BASE_URL }} + UPDATER_CHANNEL: ${{ steps.updater_manifest.outputs.channel }} + UPDATER_MANIFEST: ${{ steps.updater_manifest.outputs.path }} + shell: bash + run: | + set -euo pipefail + + python3 -m scripts.ci.publish_r2_release \ + --artifacts-root release-artifacts \ + --manifest "${UPDATER_MANIFEST}" \ + --version "${RELEASE_VERSION}" \ + --release-id "${R2_RELEASE_ID}" \ + --channel "${UPDATER_CHANNEL}" \ + --phase channel \ + --bucket "${R2_BUCKET}" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + + public_manifest_url="${R2_PUBLIC_BASE_URL%/}/desktop/channels/${UPDATER_CHANNEL}/latest.json" + curl --fail --silent --show-error \ + --retry 20 --retry-delay 10 --retry-max-time 300 --retry-all-errors \ + "${public_manifest_url}?version=${RELEASE_VERSION}" \ + --output published-latest.json + python3 - "${RELEASE_VERSION}" <<'PY' + import json + import sys + + with open("published-latest.json", encoding="utf-8") as file: + published = json.load(file) + if published.get("version") != sys.argv[1]: + raise SystemExit( + f"Published updater version mismatch: {published.get('version')!r}" + ) + PY + - name: Demote previous prerelease marker if: ${{ needs.resolve_build_context.outputs.release_prerelease == 'true' && needs.resolve_build_context.outputs.build_mode == 'nightly' }} env: diff --git a/.gitignore b/.gitignore index 0b1cf6bc..f971382b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .claude/ .pnpm-store/ +.wrangler/ # dependencies/cache node_modules/ diff --git a/docs/development.md b/docs/development.md index 15d6b086..1dff8fff 100644 --- a/docs/development.md +++ b/docs/development.md @@ -128,7 +128,11 @@ beforeBuildCommand = pnpm run prepare:resources - 定时构建(`schedule`)检测到上游新 tag 时,会先自动同步版本文件并提交,再继续构建。 - 手动触发(`workflow_dispatch`)默认只构建,不自动回写版本文件。 -- 发布与 updater 相关行为依赖 `src-tauri/tauri.conf.json`、GitHub Actions workflow 以及资源准备脚本共同完成。 +- Desktop 使用独立 updater manifest,不复用 AstrBot Core 的 `api.soulter.top/releases` 更新索引。 +- stable/nightly 的更新入口分别为 `https://releases.astrbot.app/desktop/channels/stable/latest.json` 和 `https://releases.astrbot.app/desktop/channels/nightly/latest.json`。 +- GitHub Actions 会把完整安装包继续发布到 GitHub Releases,同时将 updater 产物上传到 Cloudflare R2:先上传并校验不可变的版本目录,再更新 GitHub Release,最后原子提升通道 manifest。 +- R2 bucket、repository variables/secrets 和对象目录约定见 [`docs/environment-variables.md`](./environment-variables.md#4-发布ci-github-actions)。 +- 发布与 updater 相关行为依赖 `src-tauri/tauri.conf.json`、GitHub Actions workflow 以及 `scripts/ci/generate_tauri_latest_json.py`、`scripts/ci/publish_r2_release.py` 共同完成。 ## 8. 相关文档 diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 4493bbe1..87b4f26c 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -86,6 +86,21 @@ Windows 对应路径通常为 `C:\Users\<用户名>\.astrbot\data\cmd_config.jso | `ASTRBOT_DESKTOP_UPDATER_PUBLIC_KEY` | updater 公钥透传到构建步骤 | 默认空;当前由 `.github/workflows/build-desktop-tauri.yml` 传递,Rust 运行时不直接解析 | | `ASTRBOT_DESKTOP_TARGET_ARCH` | 透传矩阵目标架构给资源准备脚本 | 默认空;Windows workflow 当前会传 `matrix.arch`,避免在 WOA 上误用仿真层 Node 的 `process.arch` | | `ASTRBOT_DESKTOP_WINDOWS_ARM_BACKEND_ARCH` | 透传 Windows ARM64 backend runtime 架构覆盖配置到构建步骤 | 默认空;具体取值与默认行为见第 2 节 | +| `R2_ACCOUNT_ID` | Cloudflare 账户 ID,用于拼接 R2 S3 endpoint | GitHub Actions repository variable;发布 stable/nightly 时必填 | +| `R2_BUCKET` | Desktop 发布对象所在的 R2 bucket | GitHub Actions repository variable;默认 `astrbot-desktop-releases` | +| `R2_PUBLIC_BASE_URL` | manifest 和 updater 产物的公网基地址 | GitHub Actions repository variable;默认 `https://releases.astrbot.app` | +| `R2_ACCESS_KEY_ID` | R2 S3 API Access Key ID | GitHub Actions repository secret;仅授予发布 bucket 的 Object Read & Write | +| `R2_SECRET_ACCESS_KEY` | R2 S3 API Secret Access Key | GitHub Actions repository secret;禁止写入仓库、日志或构建产物 | + +R2 发布目录约定: + +```text +desktop/releases//-/ +desktop/releases//-/latest-.json +desktop/channels//latest.json +``` + +`desktop/releases/` 下只保存原生 updater 使用的安装包、签名与版本 manifest,对象不可变并使用长期缓存;每次 workflow attempt 使用独立目录,安全支持失败重试。`desktop/channels/` 下仅保留通道指针并使用 `no-store`。CI 先上传并校验不可变对象,再发布 GitHub Release,最后替换通道 manifest,避免客户端观察到尚未上传完整的版本。 ## 5. 维护约定 diff --git a/scripts/ci/build-desktop-tauri-workflow.test.mjs b/scripts/ci/build-desktop-tauri-workflow.test.mjs index c08d1282..796b693a 100644 --- a/scripts/ci/build-desktop-tauri-workflow.test.mjs +++ b/scripts/ci/build-desktop-tauri-workflow.test.mjs @@ -84,3 +84,58 @@ test('release workflow disables generated release notes for nightly builds', asy "${{ needs.resolve_build_context.outputs.build_mode != 'nightly' }}", ); }); + +test('release workflow publishes immutable R2 objects before promoting the channel manifest', async () => { + const workflowObject = await readWorkflowObject(WORKFLOW_FILE); + const steps = extractWorkflowJobSteps(workflowObject, RELEASE_JOB); + const immutableUploadIndex = findStepIndex( + steps, + (step) => step.name === 'Upload immutable release objects to Cloudflare R2', + 'immutable R2 upload step', + ); + const publicObjectVerificationIndex = findStepIndex( + steps, + (step) => step.name === 'Verify immutable R2 updater objects on public origin', + 'public R2 object verification step', + ); + const githubReleaseIndex = findStepIndex( + steps, + (step) => step.name === 'Create or update release', + 'GitHub release step', + ); + const channelPromotionIndex = findStepIndex( + steps, + (step) => step.name === 'Promote Cloudflare R2 updater channel', + 'R2 channel promotion step', + ); + + assert.ok(immutableUploadIndex < publicObjectVerificationIndex); + assert.ok(publicObjectVerificationIndex < githubReleaseIndex); + assert.ok(githubReleaseIndex < channelPromotionIndex); + assert.match(steps[immutableUploadIndex].run, /--phase artifacts/); + assert.match(steps[publicObjectVerificationIndex].run, /urlsplit\(url\)/); + assert.match(steps[publicObjectVerificationIndex].run, /--retry-max-time 300/); + assert.match(steps[channelPromotionIndex].run, /--phase channel/); + assert.match(steps[channelPromotionIndex].run, /--retry-max-time 300/); + assert.match( + steps[channelPromotionIndex].run, + /desktop\/channels\/\$\{UPDATER_CHANNEL\}\/latest\.json/, + ); +}); + +test('updater manifest generation points release artifacts at the R2 public origin', async () => { + const workflowObject = await readWorkflowObject(WORKFLOW_FILE); + const steps = extractWorkflowJobSteps(workflowObject, RELEASE_JOB); + const manifestStep = findStep( + steps, + 'Generate Tauri updater manifest', + (step) => step.name === 'Generate Tauri updater manifest', + ); + + assert.equal(manifestStep.id, 'updater_manifest'); + assert.match( + manifestStep.run, + /--asset-base-url "\$\{R2_PUBLIC_BASE_URL%\/\}\/desktop\/releases\/\$\{RELEASE_VERSION\}\/\$\{R2_RELEASE_ID\}"/, + ); + assert.equal(manifestStep.env?.R2_RELEASE_ID, '${{ github.run_id }}-${{ github.run_attempt }}'); +}); diff --git a/scripts/ci/generate_tauri_latest_json.py b/scripts/ci/generate_tauri_latest_json.py index d6c64700..a5b6711f 100644 --- a/scripts/ci/generate_tauri_latest_json.py +++ b/scripts/ci/generate_tauri_latest_json.py @@ -6,6 +6,7 @@ import json import re from pathlib import Path +from urllib.parse import quote, urlsplit from scripts.ci.lib.artifact_arch import normalize_arch_alias from scripts.ci.lib.nightly_version import NIGHTLY_CANONICAL_FORMAT, NIGHTLY_VERSION_RE @@ -32,7 +33,33 @@ def read_signature(path: Path) -> str: return path.read_text(encoding="utf-8").strip() -def asset_url(repo: str, tag: str, filename: str) -> str: +def normalize_asset_base_url(asset_base_url: str | None) -> str | None: + if asset_base_url is None: + return None + + normalized = asset_base_url.strip().rstrip("/") + if not normalized: + return None + + parsed = urlsplit(normalized) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError( + "Asset base URL must be an absolute HTTPS URL, " + f"got {asset_base_url!r}" + ) + if parsed.query or parsed.fragment: + raise ValueError("Asset base URL must not contain a query or fragment") + return normalized + + +def asset_url( + repo: str, + tag: str, + filename: str, + asset_base_url: str | None = None, +) -> str: + if normalized_base_url := normalize_asset_base_url(asset_base_url): + return f"{normalized_base_url}/{quote(filename)}" return f"https://github.com/{repo}/releases/download/{tag}/{filename}" @@ -137,6 +164,7 @@ def add_platform( signature_path: Path, repo: str, tag: str, + asset_base_url: str | None = None, ) -> None: if platform_key in platforms: raise ValueError( @@ -146,7 +174,7 @@ def add_platform( platforms[platform_key] = { "signature": read_signature(signature_path), - "url": asset_url(repo, tag, artifact_name), + "url": asset_url(repo, tag, artifact_name, asset_base_url), } @@ -162,6 +190,7 @@ def collect_platforms( *, version: str, channel: str, + asset_base_url: str | None = None, ) -> dict[str, dict[str, str]]: platforms: dict[str, dict[str, str]] = {} # Fail fast on any unknown signature file so release packaging problems are @@ -187,6 +216,7 @@ def collect_platforms( sig_path, repo, tag, + asset_base_url, ) continue @@ -207,6 +237,7 @@ def collect_platforms( sig_path, repo, tag, + asset_base_url, ) continue @@ -230,6 +261,13 @@ def main() -> int: parser.add_argument("--channel", choices=["stable", "nightly"]) parser.add_argument("--output", required=True) parser.add_argument("--notes", default="") + parser.add_argument( + "--asset-base-url", + help=( + "Optional HTTPS directory containing the normalized updater artifacts. " + "Defaults to the GitHub release download directory." + ), + ) args = parser.parse_args() root = Path(args.artifacts_root) @@ -244,6 +282,7 @@ def main() -> int: args.tag, version=args.version, channel=channel, + asset_base_url=args.asset_base_url, ) if not platforms: raise ValueError("No updater signatures found under artifacts root") diff --git a/scripts/ci/publish_r2_release.py b/scripts/ci/publish_r2_release.py new file mode 100644 index 00000000..d49cd7dd --- /dev/null +++ b/scripts/ci/publish_r2_release.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import unquote, urlsplit + +IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable" +CHANNEL_CACHE_CONTROL = "no-store" +VERSION_SEGMENT_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+_-]*$") + + +@dataclass(frozen=True) +class UploadObject: + source: Path + key: str + cache_control: str + content_type: str + mutable: bool = False + + +def content_type_for(path: Path) -> str: + lower_name = path.name.lower() + if lower_name.endswith(".json"): + return "application/json" + if lower_name.endswith(".sig"): + return "text/plain; charset=utf-8" + if lower_name.endswith(".zip"): + return "application/zip" + if lower_name.endswith((".tar.gz", ".gz")): + return "application/gzip" + if lower_name.endswith(".exe"): + return "application/vnd.microsoft.portable-executable" + if lower_name.endswith(".deb"): + return "application/vnd.debian.binary-package" + if lower_name.endswith(".rpm"): + return "application/x-rpm" + if lower_name.endswith(".dmg"): + return "application/x-apple-diskimage" + return "application/octet-stream" + + +def build_upload_plan( + artifacts_root: Path, + manifest_path: Path, + version: str, + release_id: str, + channel: str, + phase: str, +) -> list[UploadObject]: + if not VERSION_SEGMENT_RE.fullmatch(version): + raise ValueError(f"Invalid release version path segment: {version!r}") + if not VERSION_SEGMENT_RE.fullmatch(release_id): + raise ValueError(f"Invalid release ID path segment: {release_id!r}") + if channel not in {"stable", "nightly"}: + raise ValueError(f"Unsupported release channel: {channel!r}") + if phase not in {"artifacts", "channel", "all"}: + raise ValueError(f"Unsupported publish phase: {phase!r}") + + root = artifacts_root.resolve() + manifest = manifest_path.resolve() + if not root.is_dir(): + raise ValueError(f"Artifacts root is not a directory: {root}") + if not manifest.is_file(): + raise ValueError(f"Updater manifest does not exist: {manifest}") + if not manifest.is_relative_to(root): + raise ValueError("Updater manifest must be inside the artifacts root") + + artifacts = sorted( + path.resolve() + for path in root.rglob("*") + if path.is_file() and path.resolve() != manifest + ) + by_name: dict[str, list[Path]] = {} + for path in artifacts: + by_name.setdefault(path.name, []).append(path) + duplicates = { + name: paths for name, paths in by_name.items() if len(paths) > 1 + } + if duplicates: + details = "; ".join( + f"{name}: {', '.join(str(path) for path in paths)}" + for name, paths in sorted(duplicates.items()) + ) + raise ValueError(f"Duplicate artifact filenames cannot be flattened: {details}") + + try: + manifest_payload = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Invalid updater manifest {manifest}: {exc}") from exc + if manifest_payload.get("version") != version: + raise ValueError( + "Updater manifest version does not match publish version: " + f"{manifest_payload.get('version')!r} != {version!r}" + ) + if manifest_payload.get("channel") != channel: + raise ValueError( + "Updater manifest channel does not match publish channel: " + f"{manifest_payload.get('channel')!r} != {channel!r}" + ) + + platforms = manifest_payload.get("platforms") + if not isinstance(platforms, dict) or not platforms: + raise ValueError("Updater manifest must contain at least one platform") + referenced_artifacts: set[str] = set() + for platform_name, platform in platforms.items(): + if not isinstance(platform, dict): + raise ValueError(f"Invalid updater platform entry: {platform_name!r}") + artifact_url = platform.get("url") + parsed_url = urlsplit(artifact_url) if isinstance(artifact_url, str) else None + if parsed_url is None or parsed_url.scheme != "https" or not parsed_url.netloc: + raise ValueError( + f"Updater platform {platform_name!r} must reference an HTTPS URL" + ) + artifact_name = Path(unquote(parsed_url.path)).name + if not artifact_name: + raise ValueError( + f"Updater platform {platform_name!r} URL has no artifact filename" + ) + referenced_artifacts.add(artifact_name) + + required_artifacts = referenced_artifacts | { + f"{artifact_name}.sig" for artifact_name in referenced_artifacts + } + missing_artifacts = sorted(required_artifacts - by_name.keys()) + if missing_artifacts: + raise ValueError( + "Updater manifest requires artifacts missing from the upload set: " + + ", ".join(missing_artifacts) + ) + + updater_artifacts = [by_name[name][0] for name in sorted(required_artifacts)] + release_prefix = f"desktop/releases/{version}/{release_id}" + plan: list[UploadObject] = [] + if phase in {"artifacts", "all"}: + plan.extend( + UploadObject( + source=path, + key=f"{release_prefix}/{path.name}", + cache_control=IMMUTABLE_CACHE_CONTROL, + content_type=content_type_for(path), + ) + for path in updater_artifacts + ) + plan.append( + UploadObject( + source=manifest, + key=f"{release_prefix}/{manifest.name}", + cache_control=IMMUTABLE_CACHE_CONTROL, + content_type="application/json", + ) + ) + + if phase in {"channel", "all"}: + plan.append( + UploadObject( + source=manifest, + key=f"desktop/channels/{channel}/latest.json", + cache_control=CHANNEL_CACHE_CONTROL, + content_type="application/json", + mutable=True, + ) + ) + return plan + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run_aws( + aws_command: str, + endpoint_url: str, + args: list[str], + *, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + aws_command, + "--endpoint-url", + endpoint_url, + "--no-cli-pager", + *args, + ], + check=check, + capture_output=True, + text=True, + ) + + +def head_object( + aws_command: str, + endpoint_url: str, + bucket: str, + key: str, +) -> dict | None: + result = run_aws( + aws_command, + endpoint_url, + ["s3api", "head-object", "--bucket", bucket, "--key", key], + check=False, + ) + if result.returncode == 0: + return json.loads(result.stdout) + + error = result.stderr.strip() + if any(marker in error for marker in ("404", "Not Found", "NoSuchKey")): + return None + raise RuntimeError(f"Failed to inspect s3://{bucket}/{key}: {error}") + + +def publish_object( + upload: UploadObject, + *, + aws_command: str, + endpoint_url: str, + bucket: str, +) -> None: + digest = sha256_file(upload.source) + size = upload.source.stat().st_size + existing = head_object(aws_command, endpoint_url, bucket, upload.key) + + if existing is not None and not upload.mutable: + existing_digest = str(existing.get("Metadata", {}).get("sha256") or "") + existing_size = int(existing.get("ContentLength") or -1) + if existing_digest == digest and existing_size == size: + print(f"[publish-r2] immutable object already present: {upload.key}") + return + raise RuntimeError( + "Refusing to overwrite immutable release object with different content: " + f"s3://{bucket}/{upload.key}" + ) + + result = run_aws( + aws_command, + endpoint_url, + [ + "s3", + "cp", + str(upload.source), + f"s3://{bucket}/{upload.key}", + "--cache-control", + upload.cache_control, + "--content-type", + upload.content_type, + "--metadata", + f"sha256={digest}", + "--no-progress", + "--only-show-errors", + ], + ) + if result.stdout.strip(): + print(result.stdout.strip()) + + uploaded = head_object(aws_command, endpoint_url, bucket, upload.key) + if uploaded is None: + raise RuntimeError(f"Uploaded object is missing: s3://{bucket}/{upload.key}") + uploaded_digest = str(uploaded.get("Metadata", {}).get("sha256") or "") + uploaded_size = int(uploaded.get("ContentLength") or -1) + if uploaded_digest != digest or uploaded_size != size: + raise RuntimeError( + f"Uploaded object verification failed: s3://{bucket}/{upload.key}" + ) + print(f"[publish-r2] uploaded and verified: {upload.key}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Publish normalized AstrBot Desktop release assets to Cloudflare R2." + ) + parser.add_argument("--artifacts-root", required=True) + parser.add_argument("--manifest", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--release-id", required=True) + parser.add_argument("--channel", required=True, choices=["stable", "nightly"]) + parser.add_argument( + "--phase", + default="all", + choices=["artifacts", "channel", "all"], + ) + parser.add_argument("--bucket", required=True) + parser.add_argument("--endpoint-url", required=True) + parser.add_argument("--aws-command", default="aws") + args = parser.parse_args() + + plan = build_upload_plan( + Path(args.artifacts_root), + Path(args.manifest), + args.version, + args.release_id, + args.channel, + args.phase, + ) + for upload in plan: + publish_object( + upload, + aws_command=args.aws_command, + endpoint_url=args.endpoint_url, + bucket=args.bucket, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_generate_tauri_latest_json.py b/scripts/ci/test_generate_tauri_latest_json.py index fb2930ba..2480eed1 100644 --- a/scripts/ci/test_generate_tauri_latest_json.py +++ b/scripts/ci/test_generate_tauri_latest_json.py @@ -43,6 +43,27 @@ def test_platform_key_for_macos_unsupported_arch(self): with self.assertRaisesRegex(ValueError, r"Unsupported macOS arch: ppc64le"): MODULE.platform_key_for_macos("ppc64le") + def test_asset_url_uses_normalized_https_base_url(self): + self.assertEqual( + MODULE.asset_url( + "AstrBotDevs/AstrBot-desktop", + "v4.29.0", + "AstrBot 4.29.0.exe", + "https://releases.astrbot.app/desktop/releases/4.29.0/", + ), + "https://releases.astrbot.app/desktop/releases/4.29.0/" + "AstrBot%204.29.0.exe", + ) + + def test_asset_url_rejects_non_https_base_url(self): + with self.assertRaisesRegex(ValueError, "absolute HTTPS URL"): + MODULE.asset_url( + "AstrBotDevs/AstrBot-desktop", + "v4.29.0", + "AstrBot.exe", + "http://releases.astrbot.app/desktop/releases/4.29.0", + ) + def test_derive_release_metadata_validates_and_returns_expected_values(self): self.assertEqual( MODULE.derive_release_metadata("4.29.0", None), @@ -164,6 +185,44 @@ def test_main_writes_expected_manifest_json(self): "sig-win", ) + def test_main_writes_r2_asset_urls_when_base_url_is_set(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + output = root / "latest-stable.json" + (root / "AstrBot_4.29.0_windows_amd64_setup.exe.sig").write_text( + "sig-win" + ) + + argv = [ + str(SCRIPT_PATH), + "--artifacts-root", + str(root), + "--repo", + "AstrBotDevs/AstrBot-desktop", + "--tag", + "v4.29.0", + "--version", + "4.29.0", + "--channel", + "stable", + "--asset-base-url", + "https://releases.astrbot.app/desktop/releases/4.29.0", + "--output", + str(output), + ] + + with mock.patch("sys.argv", argv): + exit_code = MODULE.main() + + payload = json.loads(output.read_text()) + + self.assertEqual(exit_code, 0) + self.assertEqual( + payload["platforms"]["windows-x86_64"]["url"], + "https://releases.astrbot.app/desktop/releases/4.29.0/" + "AstrBot_4.29.0_windows_amd64_setup.exe", + ) + def test_main_fails_when_no_signatures_found(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/scripts/ci/test_publish_r2_release.py b/scripts/ci/test_publish_r2_release.py new file mode 100644 index 00000000..526d1a7d --- /dev/null +++ b/scripts/ci/test_publish_r2_release.py @@ -0,0 +1,290 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scripts.ci import publish_r2_release as MODULE + + +class PublishR2ReleaseTests(unittest.TestCase): + @staticmethod + def write_manifest( + path: Path, + *, + version: str, + channel: str, + artifact_name: str, + ) -> None: + path.write_text( + json.dumps( + { + "version": version, + "channel": channel, + "platforms": { + "windows-x86_64": { + "signature": "signature", + "url": ( + "https://releases.astrbot.app/desktop/releases/" + f"{version}/{artifact_name}" + ), + } + }, + } + ), + encoding="utf-8", + ) + + def test_build_upload_plan_flattens_artifacts_and_promotes_channel_last(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + nested = root / "nested" + nested.mkdir() + artifact = nested / "AstrBot_4.29.0_windows_amd64_setup.exe" + artifact.write_bytes(b"installer") + signature = nested / "AstrBot_4.29.0_windows_amd64_setup.exe.sig" + signature.write_text("signature") + manifest = root / "latest-stable.json" + self.write_manifest( + manifest, + version="4.29.0", + channel="stable", + artifact_name=artifact.name, + ) + + plan = MODULE.build_upload_plan( + root, + manifest, + "4.29.0", + "12345-1", + "stable", + "all", + ) + + self.assertEqual( + [upload.key for upload in plan], + [ + "desktop/releases/4.29.0/12345-1/" + "AstrBot_4.29.0_windows_amd64_setup.exe", + "desktop/releases/4.29.0/12345-1/" + "AstrBot_4.29.0_windows_amd64_setup.exe.sig", + "desktop/releases/4.29.0/12345-1/latest-stable.json", + "desktop/channels/stable/latest.json", + ], + ) + self.assertFalse(plan[-2].mutable) + self.assertEqual(plan[-2].cache_control, MODULE.IMMUTABLE_CACHE_CONTROL) + self.assertTrue(plan[-1].mutable) + self.assertEqual(plan[-1].cache_control, MODULE.CHANNEL_CACHE_CONTROL) + + def test_build_upload_plan_rejects_duplicate_flattened_names(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "a").mkdir() + (root / "b").mkdir() + (root / "a" / "AstrBot.exe").write_bytes(b"a") + (root / "b" / "AstrBot.exe").write_bytes(b"b") + manifest = root / "latest-stable.json" + self.write_manifest( + manifest, + version="4.29.0", + channel="stable", + artifact_name="AstrBot.exe", + ) + + with self.assertRaisesRegex(ValueError, "Duplicate artifact filenames"): + MODULE.build_upload_plan( + root, + manifest, + "4.29.0", + "12345-1", + "stable", + "artifacts", + ) + + def test_channel_phase_only_contains_mutable_manifest_pointer(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + artifact = root / "AstrBot.exe" + artifact.write_bytes(b"installer") + (root / "AstrBot.exe.sig").write_text("signature") + manifest = root / "latest-nightly.json" + self.write_manifest( + manifest, + version="4.29.0-nightly.20260307.abcd1234", + channel="nightly", + artifact_name=artifact.name, + ) + + plan = MODULE.build_upload_plan( + root, + manifest, + "4.29.0-nightly.20260307.abcd1234", + "12345-1", + "nightly", + "channel", + ) + + self.assertEqual(len(plan), 1) + self.assertEqual(plan[0].key, "desktop/channels/nightly/latest.json") + self.assertTrue(plan[0].mutable) + + def test_build_upload_plan_rejects_manifest_metadata_mismatch(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + artifact = root / "AstrBot.exe" + artifact.write_bytes(b"installer") + (root / "AstrBot.exe.sig").write_text("signature") + manifest = root / "latest-stable.json" + self.write_manifest( + manifest, + version="4.28.0", + channel="nightly", + artifact_name=artifact.name, + ) + + with self.assertRaisesRegex(ValueError, "manifest version"): + MODULE.build_upload_plan( + root, + manifest, + "4.29.0", + "12345-1", + "stable", + "artifacts", + ) + + def test_build_upload_plan_rejects_missing_manifest_artifact(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "other.exe").write_bytes(b"installer") + manifest = root / "latest-stable.json" + self.write_manifest( + manifest, + version="4.29.0", + channel="stable", + artifact_name="AstrBot.exe", + ) + + with self.assertRaisesRegex(ValueError, "missing from the upload set"): + MODULE.build_upload_plan( + root, + manifest, + "4.29.0", + "12345-1", + "stable", + "artifacts", + ) + + def test_build_upload_plan_ignores_non_updater_release_assets(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + artifact = root / "AstrBot.exe" + artifact.write_bytes(b"installer") + (root / "AstrBot.exe.sig").write_text("signature") + (root / "AstrBot_portable.zip").write_bytes(b"manual download") + (root / "AstrBot.deb").write_bytes(b"system package") + manifest = root / "latest-stable.json" + self.write_manifest( + manifest, + version="4.29.0", + channel="stable", + artifact_name=artifact.name, + ) + + plan = MODULE.build_upload_plan( + root, + manifest, + "4.29.0", + "12345-1", + "stable", + "artifacts", + ) + + self.assertEqual( + [upload.source.name for upload in plan], + ["AstrBot.exe", "AstrBot.exe.sig", "latest-stable.json"], + ) + + def test_build_upload_plan_requires_detached_signature(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + artifact = root / "AstrBot.exe" + artifact.write_bytes(b"installer") + manifest = root / "latest-stable.json" + self.write_manifest( + manifest, + version="4.29.0", + channel="stable", + artifact_name=artifact.name, + ) + + with self.assertRaisesRegex(ValueError, r"AstrBot\.exe\.sig"): + MODULE.build_upload_plan( + root, + manifest, + "4.29.0", + "12345-1", + "stable", + "artifacts", + ) + + def test_publish_object_skips_identical_immutable_object(self): + with tempfile.TemporaryDirectory() as tmpdir: + source = Path(tmpdir) / "AstrBot.exe" + source.write_bytes(b"installer") + upload = MODULE.UploadObject( + source=source, + key="desktop/releases/4.29.0/12345-1/AstrBot.exe", + cache_control=MODULE.IMMUTABLE_CACHE_CONTROL, + content_type="application/vnd.microsoft.portable-executable", + ) + existing = { + "ContentLength": source.stat().st_size, + "Metadata": {"sha256": MODULE.sha256_file(source)}, + } + + with ( + mock.patch.object(MODULE, "head_object", return_value=existing), + mock.patch.object(MODULE, "run_aws") as run_aws, + ): + MODULE.publish_object( + upload, + aws_command="aws", + endpoint_url="https://example.r2.cloudflarestorage.com", + bucket="bucket", + ) + + run_aws.assert_not_called() + + def test_publish_object_refuses_changed_immutable_object(self): + with tempfile.TemporaryDirectory() as tmpdir: + source = Path(tmpdir) / "AstrBot.exe" + source.write_bytes(b"new installer") + upload = MODULE.UploadObject( + source=source, + key="desktop/releases/4.29.0/12345-1/AstrBot.exe", + cache_control=MODULE.IMMUTABLE_CACHE_CONTROL, + content_type="application/vnd.microsoft.portable-executable", + ) + existing = { + "ContentLength": len(b"old installer"), + "Metadata": {"sha256": "old-digest"}, + } + + with ( + mock.patch.object(MODULE, "head_object", return_value=existing), + mock.patch.object(MODULE, "run_aws") as run_aws, + self.assertRaisesRegex(RuntimeError, "Refusing to overwrite"), + ): + MODULE.publish_object( + upload, + aws_command="aws", + endpoint_url="https://example.r2.cloudflarestorage.com", + bucket="bucket", + ) + + run_aws.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From cf011f3ba3323f755c0b88d47b8dec1fb4125a94 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 6 Aug 2026 20:44:53 +0800 Subject: [PATCH 2/5] feat(updater): use dedicated R2 release channels --- scripts/prepare-resources/tauri-config.test.mjs | 13 +++++++++++++ src-tauri/tauri.conf.json | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/prepare-resources/tauri-config.test.mjs b/scripts/prepare-resources/tauri-config.test.mjs index 908d6914..7b4a7d19 100644 --- a/scripts/prepare-resources/tauri-config.test.mjs +++ b/scripts/prepare-resources/tauri-config.test.mjs @@ -35,3 +35,16 @@ test('main Tauri window starts hidden to avoid silent-launch flash', async () => 'expected the main window to stay hidden until startup settings are applied', ); }); + +test('desktop updater channels use the dedicated R2 manifest origin', async () => { + const tauriConfig = JSON.parse(await readFile(tauriConfigPath, 'utf8')); + const updater = tauriConfig?.plugins?.updater; + const stableEndpoint = 'https://releases.astrbot.app/desktop/channels/stable/latest.json'; + const nightlyEndpoint = 'https://releases.astrbot.app/desktop/channels/nightly/latest.json'; + + assert.deepEqual(updater?.endpoints, [stableEndpoint]); + assert.deepEqual(updater?.channelEndpoints, { + stable: stableEndpoint, + nightly: nightlyEndpoint, + }); +}); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 29eda5de..b13c3e51 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -67,11 +67,11 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlFOEY1REYyNTZEQ0VBMDkKUldRSjZ0eFc4bDJQbnNqTDVIUmNudXEwcXVMUWVPb0RxZHRiNHR0Y3JuRlJVSDlLRzhDWE9ZSkMK", "endpoints": [ - "https://github.com/AstrBotDevs/AstrBot-desktop/releases/latest/download/latest-stable.json" + "https://releases.astrbot.app/desktop/channels/stable/latest.json" ], "channelEndpoints": { - "stable": "https://github.com/AstrBotDevs/AstrBot-desktop/releases/latest/download/latest-stable.json", - "nightly": "https://github.com/AstrBotDevs/AstrBot-desktop/releases/download/nightly/latest-nightly.json" + "stable": "https://releases.astrbot.app/desktop/channels/stable/latest.json", + "nightly": "https://releases.astrbot.app/desktop/channels/nightly/latest.json" }, "windows": { "installMode": "passive" From a5dfb3b7172083f63a063fdfc391badfd1d121a3 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 6 Aug 2026 21:37:45 +0800 Subject: [PATCH 3/5] feat(release): ship macOS DMG and Linux AppImage updates --- .github/workflows/build-desktop-tauri.yml | 63 +++++++++++++++++-- README.md | 2 + README_zh.md | 2 + .../ci/build-desktop-tauri-workflow.test.mjs | 53 ++++++++++++++++ scripts/ci/generate_tauri_latest_json.py | 55 ++++++++++++++++ scripts/ci/release-updater-artifacts.test.mjs | 45 +++++++------ scripts/ci/test_generate_tauri_latest_json.py | 60 ++++++++++++++++-- 7 files changed, 248 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build-desktop-tauri.yml b/.github/workflows/build-desktop-tauri.yml index d8b70b63..dd1857f5 100644 --- a/.github/workflows/build-desktop-tauri.yml +++ b/.github/workflows/build-desktop-tauri.yml @@ -216,8 +216,28 @@ jobs: shell: bash run: | set -euo pipefail - echo "Building Linux release bundles (deb and rpm only)." - cargo tauri build --bundles deb,rpm + echo "Building Linux release bundles (deb, rpm, and AppImage)." + cargo tauri build --bundles deb,rpm,appimage + + - name: Verify Linux AppImage updater artifacts + shell: bash + run: | + set -euo pipefail + appimage_dir="src-tauri/target/release/bundle/appimage" + shopt -s nullglob + appimages=("${appimage_dir}"/*.AppImage) + if [ "${#appimages[@]}" -ne 1 ]; then + echo "Expected exactly one AppImage in ${appimage_dir}, found ${#appimages[@]}." >&2 + ls -la "${appimage_dir}" || true + exit 1 + fi + updater_signature="${appimages[0]}.sig" + if [ ! -s "${updater_signature}" ]; then + echo "Expected a non-empty AppImage updater signature: ${updater_signature}" >&2 + ls -la "${appimage_dir}" || true + exit 1 + fi + echo "Verified ${appimages[0]} and ${updater_signature}" - name: Smoke test backend startup (Linux) shell: bash @@ -233,6 +253,8 @@ jobs: path: | src-tauri/target/release/bundle/**/*.deb src-tauri/target/release/bundle/**/*.rpm + src-tauri/target/release/bundle/appimage/*.AppImage + src-tauri/target/release/bundle/appimage/*.AppImage.sig build-macos: needs: @@ -390,14 +412,22 @@ jobs: retry_sleep_seconds="${ASTRBOT_MACOS_BUILD_RETRY_SLEEP_SECONDS}" # Resources are already prepared and, when available, pre-signed in earlier steps. tauri_config_override='{"build":{"beforeBuildCommand":""}}' - # Retry only for known transient cargo/crates network failures. + cleanup_script="scripts/ci/cleanup-dmg.sh" + if [ ! -f "${cleanup_script}" ]; then + echo "Missing DMG cleanup script: ${cleanup_script}" >&2 + exit 1 + fi + + # Retry known transient cargo/crates network and DMG detach failures. # Spurious retry hints emitted by cargo. transient_retry_spurious='spurious network error|network failure seems to have happened' # HTTP-layer transient fetch failures (rate limits and 5xx responses). transient_retry_http='failed to download from|failed to get successful HTTP response|received HTTP code (429|5[0-9][0-9])' # Transport-layer transient failures. transient_retry_transport='Operation timed out|Connection reset by peer|Connection refused|Temporary failure in name resolution' - retry_pattern="${transient_retry_spurious}|${transient_retry_http}|${transient_retry_transport}" + # Disk image detach failures observed on hosted macOS runners. + transient_retry_dmg='hdiutil: detach:.*(timeout|not detached)|DiskArbitration expired' + retry_pattern="${transient_retry_spurious}|${transient_retry_http}|${transient_retry_transport}|${transient_retry_dmg}" case "${max_attempts}" in ''|*[!0-9]*|0) max_attempts="${max_attempts_default}" ;; @@ -413,8 +443,10 @@ jobs: echo "macOS build retry config: max_attempts=${max_attempts}, retry_sleep_seconds=${retry_sleep_seconds}, max_attempts_upper_bound=${max_attempts_upper_bound}" for attempt in $(seq 1 "${max_attempts}"); do + echo "Cleaning stale DMG state before attempt ${attempt}/${max_attempts}..." + bash "${cleanup_script}" build_log="$(mktemp -t tauri-macos-build.XXXXXX.log)" - if cargo tauri build --verbose --target ${{ matrix.target }} --bundles app --config "${tauri_config_override}" 2>&1 | tee "${build_log}"; then + if cargo tauri build --verbose --target ${{ matrix.target }} --bundles app,dmg --config "${tauri_config_override}" 2>&1 | tee "${build_log}"; then rm -f "${build_log}" || true break fi @@ -442,7 +474,7 @@ jobs: set -euo pipefail node scripts/ci/backend-smoke-test.mjs --label "macos-${{ matrix.arch }}" - - name: Collect macOS updater artifacts + - name: Collect macOS release artifacts env: ASTRBOT_VERSION: ${{ needs.resolve_build_context.outputs.astrbot_version }} RESOLVED_APP_BUNDLE_NAME: ${{ steps.resolve_macos_app_bundle.outputs.app_bundle_name }} @@ -452,6 +484,7 @@ jobs: set -euo pipefail bundle_root="src-tauri/target/${{ matrix.target }}/release/bundle" bundle_dir="${bundle_root}/macos" + dmg_dir="${bundle_root}/dmg" release_dir="${bundle_root}/release-artifacts" app_bundle_name="${RESOLVED_APP_BUNDLE_NAME}" app_bundle_name_source="${RESOLVED_APP_BUNDLE_NAME_SOURCE}" @@ -498,6 +531,23 @@ jobs: cp "${updater_signature}" "${release_dir}/${release_base}.sig" echo "Collected ${release_dir}/${release_base}" + if [ ! -d "${dmg_dir}" ]; then + echo "Expected Tauri DMG bundle directory not found: ${dmg_dir}" >&2 + ls -la "${bundle_root}" || true + exit 1 + fi + shopt -s nullglob + dmg_files=("${dmg_dir}"/*.dmg) + if [ "${#dmg_files[@]}" -ne 1 ]; then + echo "Expected exactly one DMG in ${dmg_dir}, found ${#dmg_files[@]}." >&2 + ls -la "${dmg_dir}" || true + exit 1 + fi + hdiutil verify "${dmg_files[0]}" + dmg_release_name="AstrBot_${ASTRBOT_VERSION}_macos_${{ matrix.arch }}.dmg" + cp "${dmg_files[0]}" "${release_dir}/${dmg_release_name}" + echo "Collected ${release_dir}/${dmg_release_name}" + - name: Upload artifacts uses: actions/upload-artifact@v7.0.1 with: @@ -506,6 +556,7 @@ jobs: path: | src-tauri/target/${{ matrix.target }}/release/bundle/release-artifacts/*.app.tar.gz src-tauri/target/${{ matrix.target }}/release/bundle/release-artifacts/*.app.tar.gz.sig + src-tauri/target/${{ matrix.target }}/release/bundle/release-artifacts/*.dmg build-windows: needs: diff --git a/README.md b/README.md index 74afa285..6561be03 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ If you only want to use the app and do not need to build it locally, download th - [Stable](https://github.com/AstrBotDevs/AstrBot-desktop/releases/latest): recommended for most users. - [Nightly](https://github.com/AstrBotDevs/AstrBot-desktop/releases/tag/nightly): automatically built from newer upstream changes for early access to fixes and features. - Choose the package that matches your operating system and CPU architecture. +- On macOS, download the `.dmg`, open it, and drag AstrBot into Applications. +- On Linux, use the AppImage for in-app self-updates; `deb` and `rpm` remain system-package installs with manual update prompts. ## Data and Configuration Location diff --git a/README_zh.md b/README_zh.md index 16548147..401ee6e1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -56,6 +56,8 @@ AstrBot Desktop 是面向本地桌面使用的 AstrBot 打包发行版。它内 - [Stable](https://github.com/AstrBotDevs/AstrBot-desktop/releases/latest):适合大多数用户日常使用。 - [Nightly](https://github.com/AstrBotDevs/AstrBot-desktop/releases/tag/nightly):基于较新的上游改动自动构建,适合提前体验新功能或修复。 - 下载时请按操作系统与 CPU 架构选择对应安装包。 +- macOS 请下载 `.dmg`,打开后将 AstrBot 拖入 Applications(应用程序)目录。 +- Linux 如需应用内热更新请使用 AppImage;`deb` / `rpm` 仍由系统包方式安装,更新时会提示手动下载。 ## 数据与配置位置 diff --git a/scripts/ci/build-desktop-tauri-workflow.test.mjs b/scripts/ci/build-desktop-tauri-workflow.test.mjs index 796b693a..aa3d1a06 100644 --- a/scripts/ci/build-desktop-tauri-workflow.test.mjs +++ b/scripts/ci/build-desktop-tauri-workflow.test.mjs @@ -8,6 +8,7 @@ import { } from './workflow-test-utils.mjs'; const WORKFLOW_FILE = 'build-desktop-tauri.yml'; +const BUILD_LINUX_JOB = 'build-linux'; const BUILD_MACOS_JOB = 'build-macos'; const RELEASE_JOB = 'release'; const PREPARE_RESOURCES_RUN = /pnpm run prepare:resources/; @@ -70,6 +71,58 @@ test('macOS workflow prepares resources before optional pre-signing', async () = ); }); +test('Linux workflow publishes signed AppImage updater artifacts', async () => { + const workflowObject = await readWorkflowObject(WORKFLOW_FILE); + const steps = extractWorkflowJobSteps(workflowObject, BUILD_LINUX_JOB); + const buildStep = findStep( + steps, + 'Build desktop installers (Linux)', + (step) => step.name === 'Build desktop installers (Linux)', + ); + const verifyStep = findStep( + steps, + 'Verify Linux AppImage updater artifacts', + (step) => step.name === 'Verify Linux AppImage updater artifacts', + ); + const uploadStep = findStep( + steps, + 'Linux artifact upload', + (step) => step.name === 'Upload artifacts' && /^actions\/upload-artifact@/.test(step.uses ?? ''), + ); + + assert.match(buildStep.run, /--bundles deb,rpm,appimage/); + assert.match(verifyStep.run, /\.AppImage/); + assert.match(verifyStep.run, /updater_signature="\$\{appimages\[0\]\}\.sig"/); + assert.match(uploadStep.with?.path ?? '', /appimage\/\*\.AppImage/); + assert.match(uploadStep.with?.path ?? '', /appimage\/\*\.AppImage\.sig/); +}); + +test('macOS workflow builds a drag-to-Applications DMG alongside updater archives', async () => { + const workflowObject = await readWorkflowObject(WORKFLOW_FILE); + const steps = extractWorkflowJobSteps(workflowObject, BUILD_MACOS_JOB); + const buildStep = findStep( + steps, + 'Build desktop app bundle (macOS)', + (step) => step.name === 'Build desktop app bundle (macOS)', + ); + const collectStep = findStep( + steps, + 'Collect macOS release artifacts', + (step) => step.name === 'Collect macOS release artifacts', + ); + const uploadStep = findStep( + steps, + 'macOS artifact upload', + (step) => step.name === 'Upload artifacts' && /^actions\/upload-artifact@/.test(step.uses ?? ''), + ); + + assert.match(buildStep.run, /--bundles app,dmg/); + assert.match(buildStep.run, /cleanup-dmg\.sh/); + assert.match(collectStep.run, /hdiutil verify/); + assert.match(collectStep.run, /AstrBot_\$\{ASTRBOT_VERSION\}_macos_\$\{\{ matrix\.arch \}\}\.dmg/); + assert.match(uploadStep.with?.path ?? '', /release-artifacts\/\*\.dmg/); +}); + test('release workflow disables generated release notes for nightly builds', async () => { const workflowObject = await readWorkflowObject(WORKFLOW_FILE); const steps = extractWorkflowJobSteps(workflowObject, RELEASE_JOB); diff --git a/scripts/ci/generate_tauri_latest_json.py b/scripts/ci/generate_tauri_latest_json.py index a5b6711f..d3e73486 100644 --- a/scripts/ci/generate_tauri_latest_json.py +++ b/scripts/ci/generate_tauri_latest_json.py @@ -12,6 +12,7 @@ from scripts.ci.lib.nightly_version import NIGHTLY_CANONICAL_FORMAT, NIGHTLY_VERSION_RE from scripts.ci.lib.release_artifacts import ( ARTIFACT_EXTENSIONS, + LINUX_APPIMAGE_UPDATER_PATTERNS, MACOS_UPDATER_ARCHIVE_EXTENSION, MACOS_UPDATER_ARCHIVE_PATTERNS, MACOS_UPDATER_SIGNATURE_EXTENSION, @@ -85,6 +86,15 @@ def platform_key_for_macos(arch: str) -> str: raise ValueError(f"Unsupported macOS arch: {arch}") +def platform_key_for_linux_appimage(arch: str) -> str: + arch = normalize_arch(arch) + if arch == "amd64": + return "linux-x86_64-appimage" + if arch == "arm64": + return "linux-aarch64-appimage" + raise ValueError(f"Unsupported Linux AppImage arch: {arch}") + + def derive_release_metadata(version: str, channel: str | None) -> tuple[str, str, str]: inferred_channel = "nightly" if "nightly" in version.lower() else "stable" effective_channel = channel or inferred_channel @@ -128,6 +138,17 @@ def canonical_macos_filename( return f"{name}_{base_version}_macos_{arch}{nightly_suffix}{MACOS_UPDATER_ARCHIVE_EXTENSION}" +def canonical_linux_appimage_filename( + name: str, + arch: str, + version: str, + channel: str, +) -> str: + _, base_version, nightly_suffix = derive_release_metadata(version, channel) + arch = normalize_arch(arch) + return f"{name}_{base_version}_linux_{arch}{nightly_suffix}.AppImage" + + def parse_windows_artifact_name(source_name: str) -> re.Match[str]: match = match_any(source_name, WINDOWS_UPDATER_PATTERNS) if match: @@ -156,6 +177,19 @@ def parse_macos_artifact_name(source_name: str) -> re.Match[str]: return match +def parse_linux_appimage_artifact_name(source_name: str) -> re.Match[str]: + match = match_any(source_name, LINUX_APPIMAGE_UPDATER_PATTERNS) + if match: + return match + raise ValueError( + "Unexpected Linux AppImage artifact name: " + f"{source_name}. Expected format: " + "__linux_.AppImage or legacy " + "__.AppImage " + "(nightly builds may append _nightly_ before .AppImage)." + ) + + def add_platform( platforms: dict[str, dict[str, str]], platform_key: str, @@ -241,6 +275,27 @@ def collect_platforms( ) continue + if sig_name.endswith(".AppImage.sig"): + source_name = sig_name[:-4] + match = parse_linux_appimage_artifact_name(source_name) + artifact_name = canonical_linux_appimage_filename( + match.group("name"), + match.group("arch"), + version, + channel, + ) + add_platform( + platforms, + platform_key_for_linux_appimage(match.group("arch")), + "Linux AppImage", + artifact_name, + sig_path, + repo, + tag, + asset_base_url, + ) + continue + unsupported_signature_files.append(sig_name) if unsupported_signature_files: diff --git a/scripts/ci/release-updater-artifacts.test.mjs b/scripts/ci/release-updater-artifacts.test.mjs index 54cf9884..98959fbe 100644 --- a/scripts/ci/release-updater-artifacts.test.mjs +++ b/scripts/ci/release-updater-artifacts.test.mjs @@ -305,7 +305,7 @@ test('release artifact normalization keeps updater signatures aligned for latest } }); -test('release artifact normalization leaves linux AppImage assets unsupported for latest.json generation', async () => { +test('release artifact normalization canonicalizes linux AppImage assets for latest.json generation', async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), 'astrbot-release-artifacts-')); try { @@ -343,26 +343,31 @@ test('release artifact normalization leaves linux AppImage assets unsupported fo await access(normalizedLinux, fsConstants.F_OK); await access(normalizedLinuxSig, fsConstants.F_OK); - assert.throws( - () => - runPython( - generateModule, - [ - '--artifacts-root', - artifactsDir, - '--repo', - 'AstrBotDevs/AstrBot-desktop', - '--tag', - 'nightly', - '--version', - '4.19.2-nightly.20260306.7ac169c5', - '--output', - path.join(artifactsDir, 'latest.json'), - ], - projectRoot, - ), - /Unsupported updater signature files under artifacts root/, + const outputPath = path.join(artifactsDir, 'latest.json'); + runPython( + generateModule, + [ + '--artifacts-root', + artifactsDir, + '--repo', + 'AstrBotDevs/AstrBot-desktop', + '--tag', + 'nightly', + '--version', + '4.19.2-nightly.20260306.7ac169c5', + '--asset-base-url', + 'https://releases.astrbot.app/desktop/releases/4.19.2/run-1', + '--output', + outputPath, + ], + projectRoot, ); + + const payload = JSON.parse(await readFile(outputPath, 'utf8')); + assert.deepEqual(payload.platforms['linux-aarch64-appimage'], { + signature: 'linux-signature', + url: 'https://releases.astrbot.app/desktop/releases/4.19.2/run-1/AstrBot_4.19.2_linux_arm64_nightly_7ac169c5.AppImage', + }); } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/scripts/ci/test_generate_tauri_latest_json.py b/scripts/ci/test_generate_tauri_latest_json.py index 2480eed1..969fc94c 100644 --- a/scripts/ci/test_generate_tauri_latest_json.py +++ b/scripts/ci/test_generate_tauri_latest_json.py @@ -43,6 +43,12 @@ def test_platform_key_for_macos_unsupported_arch(self): with self.assertRaisesRegex(ValueError, r"Unsupported macOS arch: ppc64le"): MODULE.platform_key_for_macos("ppc64le") + def test_platform_key_for_linux_appimage_unsupported_arch(self): + with self.assertRaisesRegex( + ValueError, r"Unsupported Linux AppImage arch: ppc64le" + ): + MODULE.platform_key_for_linux_appimage("ppc64le") + def test_asset_url_uses_normalized_https_base_url(self): self.assertEqual( MODULE.asset_url( @@ -137,6 +143,23 @@ def test_canonical_macos_filename_outputs_expected_names(self): "AstrBot_4.29.0_macos_arm64_nightly_abcd1234.app.tar.gz", ) + def test_canonical_linux_appimage_filename_outputs_expected_names(self): + self.assertEqual( + MODULE.canonical_linux_appimage_filename( + "AstrBot", "x86_64", "4.29.0", "stable" + ), + "AstrBot_4.29.0_linux_amd64.AppImage", + ) + self.assertEqual( + MODULE.canonical_linux_appimage_filename( + "AstrBot", + "aarch64", + "4.29.0-nightly.20260307.abcd1234", + "nightly", + ), + "AstrBot_4.29.0_linux_arm64_nightly_abcd1234.AppImage", + ) + def test_main_writes_expected_manifest_json(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) @@ -519,23 +542,48 @@ def test_collect_platforms_ignores_non_artifact_sig_files(self): self.assertIn("darwin-aarch64", platforms) - def test_collect_platforms_rejects_linux_appimage_signature_files(self): + def test_collect_platforms_accepts_linux_appimage_signature_files(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) ( root / "AstrBot_4.29.0_linux_arm64_nightly_abcd1234.AppImage.sig" ).write_text("sig-linux") + platforms = MODULE.collect_platforms( + root, + "AstrBotDevs/AstrBot-desktop", + "nightly", + version="4.29.0-nightly.20260307.abcd1234", + channel="nightly", + asset_base_url="https://releases.astrbot.app/desktop/releases/4.29.0/run-1", + ) + + self.assertEqual( + platforms["linux-aarch64-appimage"], + { + "signature": "sig-linux", + "url": ( + "https://releases.astrbot.app/desktop/releases/4.29.0/run-1/" + "AstrBot_4.29.0_linux_arm64_nightly_abcd1234.AppImage" + ), + }, + ) + + def test_collect_platforms_rejects_duplicate_linux_appimage_artifacts(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "AstrBot_4.29.0_linux_amd64.AppImage.sig").write_text("sig-1") + (root / "AstrBot_4.29.0_x86_64.AppImage.sig").write_text("sig-2") + with self.assertRaisesRegex( - ValueError, - "Unsupported updater signature files under artifacts root", + ValueError, r"Duplicate Linux AppImage artifact.*linux-x86_64-appimage" ): MODULE.collect_platforms( root, "AstrBotDevs/AstrBot-desktop", - "nightly", - version="4.29.0-nightly.20260307.abcd1234", - channel="nightly", + "v4.29.0", + version="4.29.0", + channel="stable", ) def test_collect_platforms_invalid_windows_sig_raises(self): From 8e8355ef35b920306918fbc915bdbb2c6d7b9e6b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 6 Aug 2026 23:18:09 +0800 Subject: [PATCH 4/5] fix(ci): package macOS DMG without mounted mutation --- .github/workflows/build-desktop-tauri.yml | 49 +++++++++---------- .../ci/build-desktop-tauri-workflow.test.mjs | 10 ++-- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build-desktop-tauri.yml b/.github/workflows/build-desktop-tauri.yml index dd1857f5..6593797e 100644 --- a/.github/workflows/build-desktop-tauri.yml +++ b/.github/workflows/build-desktop-tauri.yml @@ -412,22 +412,14 @@ jobs: retry_sleep_seconds="${ASTRBOT_MACOS_BUILD_RETRY_SLEEP_SECONDS}" # Resources are already prepared and, when available, pre-signed in earlier steps. tauri_config_override='{"build":{"beforeBuildCommand":""}}' - cleanup_script="scripts/ci/cleanup-dmg.sh" - if [ ! -f "${cleanup_script}" ]; then - echo "Missing DMG cleanup script: ${cleanup_script}" >&2 - exit 1 - fi - - # Retry known transient cargo/crates network and DMG detach failures. + # Retry known transient cargo/crates network failures. # Spurious retry hints emitted by cargo. transient_retry_spurious='spurious network error|network failure seems to have happened' # HTTP-layer transient fetch failures (rate limits and 5xx responses). transient_retry_http='failed to download from|failed to get successful HTTP response|received HTTP code (429|5[0-9][0-9])' # Transport-layer transient failures. transient_retry_transport='Operation timed out|Connection reset by peer|Connection refused|Temporary failure in name resolution' - # Disk image detach failures observed on hosted macOS runners. - transient_retry_dmg='hdiutil: detach:.*(timeout|not detached)|DiskArbitration expired' - retry_pattern="${transient_retry_spurious}|${transient_retry_http}|${transient_retry_transport}|${transient_retry_dmg}" + retry_pattern="${transient_retry_spurious}|${transient_retry_http}|${transient_retry_transport}" case "${max_attempts}" in ''|*[!0-9]*|0) max_attempts="${max_attempts_default}" ;; @@ -443,10 +435,8 @@ jobs: echo "macOS build retry config: max_attempts=${max_attempts}, retry_sleep_seconds=${retry_sleep_seconds}, max_attempts_upper_bound=${max_attempts_upper_bound}" for attempt in $(seq 1 "${max_attempts}"); do - echo "Cleaning stale DMG state before attempt ${attempt}/${max_attempts}..." - bash "${cleanup_script}" build_log="$(mktemp -t tauri-macos-build.XXXXXX.log)" - if cargo tauri build --verbose --target ${{ matrix.target }} --bundles app,dmg --config "${tauri_config_override}" 2>&1 | tee "${build_log}"; then + if cargo tauri build --verbose --target ${{ matrix.target }} --bundles app --config "${tauri_config_override}" 2>&1 | tee "${build_log}"; then rm -f "${build_log}" || true break fi @@ -484,7 +474,6 @@ jobs: set -euo pipefail bundle_root="src-tauri/target/${{ matrix.target }}/release/bundle" bundle_dir="${bundle_root}/macos" - dmg_dir="${bundle_root}/dmg" release_dir="${bundle_root}/release-artifacts" app_bundle_name="${RESOLVED_APP_BUNDLE_NAME}" app_bundle_name_source="${RESOLVED_APP_BUNDLE_NAME_SOURCE}" @@ -531,21 +520,29 @@ jobs: cp "${updater_signature}" "${release_dir}/${release_base}.sig" echo "Collected ${release_dir}/${release_base}" - if [ ! -d "${dmg_dir}" ]; then - echo "Expected Tauri DMG bundle directory not found: ${dmg_dir}" >&2 - ls -la "${bundle_root}" || true - exit 1 - fi - shopt -s nullglob - dmg_files=("${dmg_dir}"/*.dmg) - if [ "${#dmg_files[@]}" -ne 1 ]; then - echo "Expected exactly one DMG in ${dmg_dir}, found ${#dmg_files[@]}." >&2 - ls -la "${dmg_dir}" || true + app_bundle="${bundle_dir}/${app_bundle_name}.app" + if [ ! -d "${app_bundle}" ]; then + echo "Expected signed macOS app bundle not found: ${app_bundle}" >&2 + ls -la "${bundle_dir}" || true exit 1 fi - hdiutil verify "${dmg_files[0]}" + + # create-dmg mounts and mutates a writable image, which is prone to + # DiskArbitration detach timeouts on hosted Intel runners. Building + # directly from a prepared source folder produces the same drag-to- + # Applications layout without an explicit attach/detach cycle. + dmg_staging_dir="$(mktemp -d "${RUNNER_TEMP}/astrbot-dmg-${{ matrix.arch }}.XXXXXX")" + ditto "${app_bundle}" "${dmg_staging_dir}/${app_bundle_name}.app" + ln -s /Applications "${dmg_staging_dir}/Applications" dmg_release_name="AstrBot_${ASTRBOT_VERSION}_macos_${{ matrix.arch }}.dmg" - cp "${dmg_files[0]}" "${release_dir}/${dmg_release_name}" + dmg_release_path="${release_dir}/${dmg_release_name}" + hdiutil create \ + -volname "${app_bundle_name}" \ + -srcfolder "${dmg_staging_dir}" \ + -ov \ + -format UDZO \ + "${dmg_release_path}" + hdiutil verify "${dmg_release_path}" echo "Collected ${release_dir}/${dmg_release_name}" - name: Upload artifacts diff --git a/scripts/ci/build-desktop-tauri-workflow.test.mjs b/scripts/ci/build-desktop-tauri-workflow.test.mjs index aa3d1a06..9c2d592d 100644 --- a/scripts/ci/build-desktop-tauri-workflow.test.mjs +++ b/scripts/ci/build-desktop-tauri-workflow.test.mjs @@ -97,7 +97,7 @@ test('Linux workflow publishes signed AppImage updater artifacts', async () => { assert.match(uploadStep.with?.path ?? '', /appimage\/\*\.AppImage\.sig/); }); -test('macOS workflow builds a drag-to-Applications DMG alongside updater archives', async () => { +test('macOS workflow packages a drag-to-Applications DMG alongside updater archives', async () => { const workflowObject = await readWorkflowObject(WORKFLOW_FILE); const steps = extractWorkflowJobSteps(workflowObject, BUILD_MACOS_JOB); const buildStep = findStep( @@ -116,8 +116,12 @@ test('macOS workflow builds a drag-to-Applications DMG alongside updater archive (step) => step.name === 'Upload artifacts' && /^actions\/upload-artifact@/.test(step.uses ?? ''), ); - assert.match(buildStep.run, /--bundles app,dmg/); - assert.match(buildStep.run, /cleanup-dmg\.sh/); + assert.match(buildStep.run, /--bundles app(?:\s|$)/); + assert.doesNotMatch(buildStep.run, /--bundles app,dmg/); + assert.match(collectStep.run, /ln -s \/Applications/); + assert.match(collectStep.run, /hdiutil create/); + assert.match(collectStep.run, /-srcfolder/); + assert.match(collectStep.run, /-format UDZO/); assert.match(collectStep.run, /hdiutil verify/); assert.match(collectStep.run, /AstrBot_\$\{ASTRBOT_VERSION\}_macos_\$\{\{ matrix\.arch \}\}\.dmg/); assert.match(uploadStep.with?.path ?? '', /release-artifacts\/\*\.dmg/); From 9f391f711cd5ecfda9474550057d847b6e630b78 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 7 Aug 2026 00:41:03 +0800 Subject: [PATCH 5/5] ci: use prebuilt Tauri CLI --- .../actions/setup-desktop-build/action.yml | 15 --- .github/workflows/build-desktop-tauri.yml | 4 +- package.json | 5 +- pnpm-lock.yaml | 126 ++++++++++++++++++ .../ci/build-desktop-tauri-workflow.test.mjs | 15 ++- scripts/ci/build-windows-installers.sh | 6 +- 6 files changed, 147 insertions(+), 24 deletions(-) diff --git a/.github/actions/setup-desktop-build/action.yml b/.github/actions/setup-desktop-build/action.yml index b6fcb24b..bc2f64f7 100644 --- a/.github/actions/setup-desktop-build/action.yml +++ b/.github/actions/setup-desktop-build/action.yml @@ -18,11 +18,6 @@ inputs: description: pnpm version. required: false default: 10.28.2 - tauri-cli-version: - description: tauri-cli version. - required: false - default: 2.10.0 - runs: using: composite steps: @@ -42,16 +37,6 @@ runs: with: toolchain: ${{ inputs.rust-toolchain }} - - name: Install Tauri CLI (non-Windows) - if: runner.os != 'Windows' - shell: bash - run: cargo install tauri-cli --version "${{ inputs.tauri-cli-version }}" --locked - - - name: Install Tauri CLI (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: cargo install tauri-cli --version "${{ inputs.tauri-cli-version }}" --locked - - name: Install dependencies (non-Windows) if: runner.os != 'Windows' shell: bash diff --git a/.github/workflows/build-desktop-tauri.yml b/.github/workflows/build-desktop-tauri.yml index 6593797e..e34e10bd 100644 --- a/.github/workflows/build-desktop-tauri.yml +++ b/.github/workflows/build-desktop-tauri.yml @@ -217,7 +217,7 @@ jobs: run: | set -euo pipefail echo "Building Linux release bundles (deb, rpm, and AppImage)." - cargo tauri build --bundles deb,rpm,appimage + pnpm exec tauri build --bundles deb,rpm,appimage - name: Verify Linux AppImage updater artifacts shell: bash @@ -436,7 +436,7 @@ jobs: for attempt in $(seq 1 "${max_attempts}"); do build_log="$(mktemp -t tauri-macos-build.XXXXXX.log)" - if cargo tauri build --verbose --target ${{ matrix.target }} --bundles app --config "${tauri_config_override}" 2>&1 | tee "${build_log}"; then + if pnpm exec tauri build --verbose --target ${{ matrix.target }} --bundles app --config "${tauri_config_override}" 2>&1 | tee "${build_log}"; then rm -f "${build_log}" || true break fi diff --git a/package.json b/package.json index f564c1f6..f463676f 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,11 @@ "prepare:webui": "node scripts/prepare-resources.mjs webui", "prepare:backend": "node scripts/prepare-resources.mjs backend", "prepare:resources": "pnpm run prepare:webui && pnpm run prepare:backend", - "dev": "cargo tauri dev", - "build": "cargo tauri build" + "dev": "tauri dev", + "build": "tauri build" }, "devDependencies": { + "@tauri-apps/cli": "2.10.0", "yaml": "^2.8.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c18f64d..9aeef4af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,91 @@ importers: .: devDependencies: + '@tauri-apps/cli': + specifier: 2.10.0 + version: 2.10.0 yaml: specifier: ^2.8.1 version: 2.8.3 packages: + '@tauri-apps/cli-darwin-arm64@2.10.0': + resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.10.0': + resolution: {integrity: sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': + resolution: {integrity: sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.10.0': + resolution: {integrity: sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.10.0': + resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': + resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.10.0': + resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.10.0': + resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.10.0': + resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.10.0': + resolution: {integrity: sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.10.0': + resolution: {integrity: sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.10.0': + resolution: {integrity: sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==} + engines: {node: '>= 10'} + hasBin: true + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -21,4 +100,51 @@ packages: snapshots: + '@tauri-apps/cli-darwin-arm64@2.10.0': + optional: true + + '@tauri-apps/cli-darwin-x64@2.10.0': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.10.0': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.10.0': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.10.0': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.10.0': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.10.0': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.10.0': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.10.0': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.10.0': + optional: true + + '@tauri-apps/cli@2.10.0': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.10.0 + '@tauri-apps/cli-darwin-x64': 2.10.0 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.0 + '@tauri-apps/cli-linux-arm64-gnu': 2.10.0 + '@tauri-apps/cli-linux-arm64-musl': 2.10.0 + '@tauri-apps/cli-linux-riscv64-gnu': 2.10.0 + '@tauri-apps/cli-linux-x64-gnu': 2.10.0 + '@tauri-apps/cli-linux-x64-musl': 2.10.0 + '@tauri-apps/cli-win32-arm64-msvc': 2.10.0 + '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 + '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + yaml@2.8.3: {} diff --git a/scripts/ci/build-desktop-tauri-workflow.test.mjs b/scripts/ci/build-desktop-tauri-workflow.test.mjs index 9c2d592d..90295ea4 100644 --- a/scripts/ci/build-desktop-tauri-workflow.test.mjs +++ b/scripts/ci/build-desktop-tauri-workflow.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import { extractWorkflowJobSteps, @@ -13,12 +14,22 @@ const BUILD_MACOS_JOB = 'build-macos'; const RELEASE_JOB = 'release'; const PREPARE_RESOURCES_RUN = /pnpm run prepare:resources/; const PRESIGN_BACKEND_RUN = /codesign-macos-nested\.sh\s+"resources\/backend"/; -const BUILD_APP_BUNDLE_RUN = /cargo tauri build --verbose --target/; +const BUILD_APP_BUNDLE_RUN = /pnpm exec tauri build --verbose --target/; + +test('desktop build setup uses the prebuilt package-manager Tauri CLI', async () => { + const [setupAction, packageJson] = await Promise.all([ + readFile('.github/actions/setup-desktop-build/action.yml', 'utf8'), + readFile('package.json', 'utf8').then(JSON.parse), + ]); + + assert.equal(packageJson.devDependencies['@tauri-apps/cli'], '2.10.0'); + assert.doesNotMatch(setupAction, /cargo install tauri-cli/); +}); test('findStep supports predicate and regex matching', () => { const steps = [ { name: 'Prepare desktop resources (macOS) [unsigned-compatible]', run: 'pnpm run prepare:resources' }, - { name: 'Build desktop app bundle (macOS) release artifacts', run: 'cargo tauri build --verbose --target x86_64-apple-darwin' }, + { name: 'Build desktop app bundle (macOS) release artifacts', run: 'pnpm exec tauri build --verbose --target x86_64-apple-darwin' }, ]; assert.equal(findStep(steps, 'prepare resources run', (step) => PREPARE_RESOURCES_RUN.test(step.run ?? '')), steps[0]); diff --git a/scripts/ci/build-windows-installers.sh b/scripts/ci/build-windows-installers.sh index 8e1ea80c..6d140092 100755 --- a/scripts/ci/build-windows-installers.sh +++ b/scripts/ci/build-windows-installers.sh @@ -12,9 +12,9 @@ fi if ! ( cd "${root_dir}" - cargo tauri -V >/dev/null 2>&1 + pnpm exec tauri -V >/dev/null 2>&1 ); then - echo "Tauri CLI is required to build Windows installers (expected: cargo tauri)." >&2 + echo "Tauri CLI is required to build Windows installers (expected: pnpm exec tauri)." >&2 exit 1 fi @@ -27,5 +27,5 @@ fi echo "Building Windows installers with bundles: ${bundles}" ( cd "${root_dir}" - cargo tauri build --bundles "${bundles}" + pnpm exec tauri build --bundles "${bundles}" )