From 3b8296c1c99f8377f7a06400a876309a9f5b9b49 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 18 Sep 2026 20:17:08 +0800 Subject: [PATCH 1/2] Offer the macOS .dmg on the website instead of the updater payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The website handed macOS visitors OpenBitFun__darwin-*.app.tar.gz. That file is the Tauri updater payload: no installer UI, and a browser unpacks it into a bare .app wherever downloads happen to land. The signed .dmg was published with every release but nothing downstream could reach it, because latest-v1.json only declared manual_installers for windows-x86_64 and downloads.json was written to substitute that one platform. Declare the macOS installers in the release manifest. The generator now takes --installer-assets-dir and requires each OpenBitFun__{aarch64,x64}.dmg to have its detached signature, so an unsigned or missing disk image fails the release instead of publishing a signature URL that 404s. Generating the manifest moves after the installer signing step, which is what creates those signatures. Teach the mirror to serve them. write_website_download_manifest applies every declared manual_installers entry rather than special-casing Windows, and mirror_macos_dmg_installers probes the deterministic .dmg names so releases published before this change — 1.0.1 included — get the same treatment without being rebuilt. Both paths keep the updater URLs untouched: manual_installers remains a website extension, and the updater must keep consuming .app.tar.gz. Verified on the production mirror: /release/downloads.json now serves .dmg for both macOS architectures, the mirrored disk images match their GitHub sha256, and latest-v1.json still points the updater at the .app.tar.gz packages. --- .github/workflows/desktop-package.yml | 42 ++++---- deploy/openbitfun-host/README.md | 9 +- scripts/generate-tauri-latest-json.mjs | 45 +++++++-- scripts/openbitfun-release-sync.sh | 125 +++++++++++++++++++++--- scripts/tauri-release-manifest.test.mjs | 79 +++++++++++++++ 5 files changed, 256 insertions(+), 44 deletions(-) diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 90d90b9a41..02acca5a66 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -680,25 +680,6 @@ jobs: --out-dir release-updater-assets \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" - - name: Generate updater manifest - run: | - node scripts/generate-tauri-latest-json.mjs \ - --assets-dir release-updater-assets \ - --manual-assets-dir release-manual-assets \ - --version "${{ needs.prepare.outputs.version }}" \ - --tag "${{ needs.prepare.outputs.release_tag }}" \ - --repo "${{ github.repository }}" \ - --out release-updater-assets/latest-v1.json \ - --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" - - - name: Verify updater manifest - run: | - node scripts/verify-tauri-latest-json.mjs \ - --manifest release-updater-assets/latest-v1.json \ - --version "${{ needs.prepare.outputs.version }}" \ - --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ - --required-manual-platforms "windows-x86_64" - - name: Generate Linux binaries manifest run: | node scripts/generate-linux-binaries-manifest.mjs \ @@ -738,6 +719,29 @@ jobs: node scripts/write-minisign-public-key.mjs \ --out release-assets/minisign.pub + # Runs after the signing step because the manifest declares the macOS + # .dmg installers, and a manual installer is only declarable once its + # detached signature exists next to it. + - name: Generate updater manifest + run: | + node scripts/generate-tauri-latest-json.mjs \ + --assets-dir release-updater-assets \ + --manual-assets-dir release-manual-assets \ + --installer-assets-dir release-assets \ + --version "${{ needs.prepare.outputs.version }}" \ + --tag "${{ needs.prepare.outputs.release_tag }}" \ + --repo "${{ github.repository }}" \ + --out release-updater-assets/latest-v1.json \ + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" + + - name: Verify updater manifest + run: | + node scripts/verify-tauri-latest-json.mjs \ + --manifest release-updater-assets/latest-v1.json \ + --version "${{ needs.prepare.outputs.version }}" \ + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ + --required-manual-platforms "windows-x86_64,darwin-aarch64,darwin-x86_64" + - name: Stage release assets shell: bash run: | diff --git a/deploy/openbitfun-host/README.md b/deploy/openbitfun-host/README.md index 3e84f36ff6..71eeef7034 100644 --- a/deploy/openbitfun-host/README.md +++ b/deploy/openbitfun-host/README.md @@ -220,10 +220,15 @@ test "$code" = "404"' 下载目录,避免给旧客户端推送 1.X 或破坏旧下载。GitHub Latest 中的旧清单由 发布工作流从 v0.2.19 原样保留。完整规则见 [发布指南](../../docs/development/releasing.md)。 -Windows 网页安装包文件名以 GitHub `latest-v1.json` 的 `manual_installers` 为准 -(现在是 `OpenBitFun_${version}_windows-x86_64-installer.exe`)。不要再写死 +网页安装包文件名以 GitHub `latest-v1.json` 的 `manual_installers` 为准 +(Windows 现在是 `OpenBitFun_${version}_windows-x86_64-installer.exe`)。不要再写死 `openbitfun-installer.exe`。 +macOS 的 `downloads.json` 必须指向 `.dmg`,不是 `platforms` 里的 +`.app.tar.gz`——后者是 updater 的升级载荷,浏览器只会把它解包成一个裸 `.app`。 +1.0.2 起 `manual_installers` 自带 `darwin-aarch64` / `darwin-x86_64`;更早的版本没有, +同步脚本按 `OpenBitFun_${version}_{aarch64,x64}.dmg` 探测并镜像,两种来源都要保留。 + `release-sync.cron` **就是这台机器的整份 root crontab**。`crontab` 该文件会 替换所有 root cron。先备份,确认没有其它任务再装。 diff --git a/scripts/generate-tauri-latest-json.mjs b/scripts/generate-tauri-latest-json.mjs index db48b89a3f..12c33d253c 100644 --- a/scripts/generate-tauri-latest-json.mjs +++ b/scripts/generate-tauri-latest-json.mjs @@ -10,6 +10,7 @@ const repo = requireArg(args, 'repo'); const out = requireArg(args, 'out'); const requiredPlatforms = parseListArg(args['required-platforms'] || ''); const manualAssetsDir = args['manual-assets-dir']; +const installerAssetsDir = args['installer-assets-dir']; if (!existsSync(assetsDir)) { fail(`Assets directory does not exist: ${assetsDir}`); @@ -56,19 +57,47 @@ const manifest = { platforms, }; +const manualInstallers = {}; + if (manualAssetsDir) { - const installerName = `OpenBitFun_${version}_windows-x86_64-installer.exe`; - const installerPath = join(manualAssetsDir, installerName); + addManualInstaller('windows-x86_64', join(manualAssetsDir, `OpenBitFun_${version}_windows-x86_64-installer.exe`)); +} + +// The macOS updater artifact is a .app.tar.gz: an update payload with no +// installer UI, which a browser unpacks into a bare .app wherever downloads +// land. The signed .dmg published beside it is the thing a person installs. +// Declaring it here is what lets the website and the mirror offer it instead of +// deriving the filename themselves and silently missing a rename. +if (installerAssetsDir) { + const dmgPaths = new Map( + walkFiles(installerAssetsDir) + .filter((file) => file.endsWith('.dmg')) + .map((file) => [basename(file), file]) + ); + for (const [platform, arch] of [['darwin-aarch64', 'aarch64'], ['darwin-x86_64', 'x64']]) { + const installerName = `OpenBitFun_${version}_${arch}.dmg`; + const installerPath = dmgPaths.get(installerName); + if (!installerPath) { + fail(`Missing macOS installer ${installerName} under ${installerAssetsDir}`); + } + addManualInstaller(platform, installerPath); + } +} + +if (Object.keys(manualInstallers).length > 0) { + manifest.manual_installers = manualInstallers; +} + +function addManualInstaller(platform, installerPath) { const signaturePath = `${installerPath}.sig`; if (!existsSync(installerPath) || !existsSync(signaturePath)) { fail(`Missing signed manual installer pair: ${installerPath} and ${signaturePath}`); } - const assetUrl = `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(installerName)}`; - manifest.manual_installers = { - 'windows-x86_64': { - url: assetUrl, - signature_url: `${assetUrl}.sig`, - }, + const assetName = basename(installerPath); + const assetUrl = `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`; + manualInstallers[platform] = { + url: assetUrl, + signature_url: `${assetUrl}.sig`, }; } diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index c02576718b..b379319156 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -16,9 +16,9 @@ # stable and beta Tauri updater fallback endpoints. # When GitHub is unreachable, the desktop client automatically falls through # to https://openbitfun.com/release/latest-v1.json and downloads from this mirror. -# The published release/downloads.json is for the website. Its Windows URL uses -# latest-v1.json's manual_installers entry while the updater keeps the versioned -# Tauri setup.exe URL. +# The published release/downloads.json is for the website. Its Windows and macOS +# URLs point at real installers — the manual_installers entry, or for macOS the +# mirrored .dmg — while the updater keeps the versioned Tauri package URLs. # # Cron (every 10 minutes). Run the in-repo script from the OpenBitFun checkout so a # new host only needs this repository, not a detached AutoUpdate copy: @@ -81,6 +81,7 @@ LOCK_FILE="${OPENBITFUN_RELEASE_SYNC_LOCK:-/var/lock/openbitfun-release-sync.loc WINDOWS_INSTALLER_FILENAME="openbitfun-installer.exe" WINDOWS_INSTALLER_URL="" WINDOWS_INSTALLER_SIGNATURE_URL="" +MACOS_DMG_INSTALLERS="" WEBSITE_DOWNLOADS_MANIFEST="downloads.json" # Keep enough releases that the mirror still serves a Desktop build a few # versions behind and SSH Dispatch can finish an already-confirmed install even @@ -179,10 +180,90 @@ if entry: "${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}.sig" || exit 1 } +# Mirror the signed macOS .dmg installers. +# +# The macOS updater package is a .app.tar.gz: an update payload with no +# installer UI, which a browser unpacks into a bare .app next to whatever else +# is in Downloads. It is what the updater consumes, not what a person installs. +# Releases before 1.0.2 declare manual_installers for windows-x86_64 only, so +# the website had nothing else to offer macOS visitors and handed them that +# archive. The .dmg files are published with every Desktop release under +# deterministic names, so resolve them here and let the website manifest prefer +# them. +# +# Probing rather than assuming: the .dmg and its signature are uploaded by a +# separate job, so an early cron run can legitimately see neither. A partial set +# is removed so the next run re-fetches instead of publishing a URL that 404s. +mirror_macos_dmg_installers() { + local platform arch declared filename base_url suffix ready failed + MACOS_DMG_INSTALLERS="" + for platform in darwin-aarch64 darwin-x86_64; do + case "$platform" in + darwin-aarch64) arch="aarch64" ;; + *) arch="x64" ;; + esac + + # A release that declares its own macOS installer wins over the naming + # convention, so a future rename does not silently keep mirroring the old + # file. + declared="$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " +import json, sys +entry = json.load(sys.stdin).get('manual_installers', {}).get('${platform}') +if entry: + print(entry['url']) +")" + if [ -n "$declared" ]; then + if [ "${declared%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then + log "ERROR: ${platform} manual installer does not belong to release $VERSION" + return 1 + fi + filename="${declared##*/}" + else + filename="OpenBitFun_${VERSION}_${arch}.dmg" + fi + base_url="${RELEASE_ASSET_BASE_URL}/${filename}" + + ready=1 + for suffix in "" .sig; do + if ! curl -fsSIL \ + --connect-timeout "$CONNECT_TIMEOUT" \ + --max-time "$MAX_TIME" \ + "${base_url}${suffix}" >/dev/null; then + ready=0 + break + fi + done + if [ "$ready" -ne 1 ]; then + log " macOS installer set is not complete yet: $filename" + continue + fi + + failed=0 + for suffix in "" .sig; do + log " Mirroring macOS installer: ${filename}${suffix}" + if ! download_asset \ + "${base_url}${suffix}" \ + "${VERSION_DIR}/${filename}${suffix}"; then + failed=1 + break + fi + done + if [ "$failed" -ne 0 ]; then + rm -f "${VERSION_DIR}/${filename}" "${VERSION_DIR}/${filename}.sig" + log "WARN: incomplete macOS installer set removed; retrying next sync." + continue + fi + + MACOS_DMG_INSTALLERS="${MACOS_DMG_INSTALLERS}${platform}=${filename}"$'\n' + done + return 0 +} + # Build a website-only manifest from the already rewritten updater manifest. -# All non-Windows targets continue to use their mirrored updater packages. The -# Windows target alone is replaced with the custom installer URL. The updater -# URL remains untouched; manual_installers is a mirror/website extension only. +# Targets that have a real installer — every declared manual_installers entry, +# plus the macOS .dmg files resolved above — are replaced with it; the rest keep +# their mirrored updater package. The updater URLs remain untouched; +# manual_installers is a mirror/website extension only. write_website_download_manifest() { local output="${VERSION_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}" local output_tmp="${output}.part" @@ -191,14 +272,16 @@ write_website_download_manifest() { "${VERSION_DIR}/latest-v1.json" \ "$output_tmp" \ "$OPENBITFUN_BASE_URL" \ - "$WINDOWS_INSTALLER_FILENAME" <<'PY' + "$WINDOWS_INSTALLER_FILENAME" \ + "$MACOS_DMG_INSTALLERS" <<'PY' import json, sys -source, dest, base, windows_installer = sys.argv[1:] +source, dest, base, windows_installer, macos_installers = sys.argv[1:] with open(source, encoding="utf-8") as f: updater = json.load(f) version = updater["version"] +version_base = f"{base}/{version}" platforms = {} for target, entry in updater.get("platforms", {}).items(): url = entry.get("url") @@ -209,12 +292,23 @@ windows = platforms.get("windows-x86_64") if windows is None: raise SystemExit("latest-v1.json is missing windows-x86_64") -manual = updater.get("manual_installers", {}).get("windows-x86_64") -if manual: - windows["url"] = manual["url"] - windows["signatureUrl"] = manual.get("signature_url", manual["url"] + ".sig") -else: - version_base = f"{base}/{version}" +# These URLs were rewritten to the mirror together with the updater ones. +for target, manual in updater.get("manual_installers", {}).items(): + if target not in platforms: + continue + platforms[target]["url"] = manual["url"] + platforms[target]["signatureUrl"] = manual.get("signature_url", manual["url"] + ".sig") + +for line in macos_installers.splitlines(): + if not line: + continue + target, filename = line.split("=", 1) + if target not in platforms or "signatureUrl" in platforms[target]: + continue + platforms[target]["url"] = f"{version_base}/{filename}" + platforms[target]["signatureUrl"] = f"{version_base}/{filename}.sig" + +if "signatureUrl" not in windows: windows["url"] = f"{version_base}/{windows_installer}" windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" @@ -620,8 +714,9 @@ for p, info in data.get('platforms', {}).items(): download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 done <<< "$ASSET_LIST" - # Mirror the manual installer separately while preserving the updater URL. + # Mirror the manual installers separately while preserving the updater URLs. mirror_windows_installer + mirror_macos_dmg_installers # 6. Rewrite URLs in latest-v1.json to point at openbitfun.com LATEST_MANIFEST_TMP="${VERSION_DIR}/latest-v1.json.part" diff --git a/scripts/tauri-release-manifest.test.mjs b/scripts/tauri-release-manifest.test.mjs index 20bee39226..07574ea97c 100644 --- a/scripts/tauri-release-manifest.test.mjs +++ b/scripts/tauri-release-manifest.test.mjs @@ -75,6 +75,85 @@ test('1.0.0-beta manifest keeps the updater URL separate from the manual install assert.equal(verified.status, 0, verified.stderr); }); +test('manifest declares the signed macOS .dmg installers next to the .app.tar.gz updater packages', (t) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-latest-dmg-')); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const updater = path.join(temp, 'updater'); + const manual = path.join(temp, 'manual'); + // The signing step leaves the .dmg files in the per-runner subdirectories the + // build artifacts were downloaded into, so the generator has to find them by + // name rather than at a fixed path. + const installers = path.join(temp, 'release-assets', 'macos-arm64'); + const out = path.join(temp, 'latest-v1.json'); + fs.mkdirSync(updater, { recursive: true }); + fs.mkdirSync(manual, { recursive: true }); + fs.mkdirSync(installers, { recursive: true }); + + for (const arch of ['aarch64', 'x86_64']) { + const updaterName = `OpenBitFun_1.2.3_darwin-${arch}.app.tar.gz`; + fs.writeFileSync(path.join(updater, updaterName), 'updater payload'); + fs.writeFileSync(path.join(updater, `${updaterName}.sig`), 'inline-updater-signature'); + } + const windowsUpdaterName = 'OpenBitFun_1.2.3_windows-x86_64-setup.exe'; + fs.writeFileSync(path.join(updater, windowsUpdaterName), 'setup'); + fs.writeFileSync(path.join(updater, `${windowsUpdaterName}.sig`), 'inline-updater-signature'); + const windowsInstallerName = 'OpenBitFun_1.2.3_windows-x86_64-installer.exe'; + fs.writeFileSync(path.join(manual, windowsInstallerName), 'installer'); + fs.writeFileSync(path.join(manual, `${windowsInstallerName}.sig`), 'detached-signature'); + for (const arch of ['aarch64', 'x64']) { + fs.writeFileSync(path.join(installers, `OpenBitFun_1.2.3_${arch}.dmg`), 'disk image'); + fs.writeFileSync(path.join(installers, `OpenBitFun_1.2.3_${arch}.dmg.sig`), 'detached-signature'); + } + + const generatorArgs = [ + '--assets-dir', updater, + '--manual-assets-dir', manual, + '--installer-assets-dir', path.join(temp, 'release-assets'), + '--version', '1.2.3', + '--tag', 'v1.2.3', + '--repo', 'GCWing/OpenBitFun', + '--out', out, + '--required-platforms', 'darwin-aarch64,darwin-x86_64,windows-x86_64', + ]; + const generated = run('scripts/generate-tauri-latest-json.mjs', generatorArgs); + assert.equal(generated.status, 0, generated.stderr); + + const manifest = JSON.parse(fs.readFileSync(out, 'utf8')); + // The updater must keep consuming the .app.tar.gz; manual_installers is an + // addition for humans, not a replacement. + assert.match(manifest.platforms['darwin-aarch64'].url, /_darwin-aarch64\.app\.tar\.gz$/); + assert.match(manifest.platforms['darwin-x86_64'].url, /_darwin-x86_64\.app\.tar\.gz$/); + assert.equal( + manifest.manual_installers['darwin-aarch64'].url, + 'https://github.com/GCWing/OpenBitFun/releases/download/v1.2.3/OpenBitFun_1.2.3_aarch64.dmg' + ); + assert.equal( + manifest.manual_installers['darwin-x86_64'].url, + 'https://github.com/GCWing/OpenBitFun/releases/download/v1.2.3/OpenBitFun_1.2.3_x64.dmg' + ); + assert.match(manifest.manual_installers['windows-x86_64'].url, /-installer\.exe$/); + + const verified = run('scripts/verify-tauri-latest-json.mjs', [ + '--manifest', out, + '--version', '1.2.3', + '--required-platforms', 'darwin-aarch64,darwin-x86_64,windows-x86_64', + '--required-manual-platforms', 'windows-x86_64,darwin-aarch64,darwin-x86_64', + ]); + assert.equal(verified.status, 0, verified.stderr); + + // An unsigned .dmg must fail the release rather than publish a manifest whose + // signature URL 404s. + fs.unlinkSync(path.join(installers, 'OpenBitFun_1.2.3_x64.dmg.sig')); + const unsigned = run('scripts/generate-tauri-latest-json.mjs', generatorArgs); + assert.notEqual(unsigned.status, 0); + assert.match(unsigned.stderr, /Missing signed manual installer pair/); + + fs.unlinkSync(path.join(installers, 'OpenBitFun_1.2.3_x64.dmg')); + const absent = run('scripts/generate-tauri-latest-json.mjs', generatorArgs); + assert.notEqual(absent.status, 0); + assert.match(absent.stderr, /Missing macOS installer OpenBitFun_1\.2\.3_x64\.dmg/); +}); + test('stages GitHub release assets in a flat directory', () => { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-release-assets-')); const first = path.join(temp, 'updater', 'latest.json'); From c9bf12459af815d4e48af51ba376f432d6e73342 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 18 Sep 2026 20:31:17 +0800 Subject: [PATCH 2/2] Keep two mirrored releases and skip the cron run when Latest is unchanged The website and the updater both read a floating latest-v1.json that always names the current version, so retaining six releases plus every 0.2.x tree did not help auto-update. Probe-and-rewrite logic for macOS .dmg names was the same class of extra work: once the release manifest declares the disk image, the sync only has to download what that file lists. If GitHub Latest matches the published mirror, the script now prunes to the two newest version directories and exits. A new version still pulls Desktop updater packages, declared manual installers, Linux CLI/Relay archives, and the Relay image descriptor. downloads.json continues to substitute every manual_installers entry so the website gets the .dmg / Windows installer while latest-v1.json keeps the updater payloads. --- deploy/openbitfun-host/README.md | 29 ++-- docs/development/releasing.md | 8 +- scripts/linux-binaries-manifest.test.mjs | 21 ++- scripts/openbitfun-release-sync.sh | 186 +++++++---------------- 4 files changed, 89 insertions(+), 155 deletions(-) diff --git a/deploy/openbitfun-host/README.md b/deploy/openbitfun-host/README.md index 71eeef7034..cc1e84d8b2 100644 --- a/deploy/openbitfun-host/README.md +++ b/deploy/openbitfun-host/README.md @@ -212,22 +212,18 @@ test "$code" = "404"' ### 4. Release 镜像 -先用旧机 rsync 过来的目录(保留 0.2.14 起的多版本,供旧 Desktop / Dispatch), -再跑一次 in-repo 同步补最新版。不要对空目录只 sync 一次就当完成。 +先用旧机 rsync 过来的目录,再跑一次 in-repo 同步补最新版。不要对空目录只 +sync 一次就当完成。 1.X 发布前必须更新仓库内同步脚本。新桌面端清单是 `latest-v1.json`,CLI 清单是 -`linux-binaries-v1.json`;保留旧 `latest.json`、`linux-binaries.json` 和 `0.2.*` -下载目录,避免给旧客户端推送 1.X 或破坏旧下载。GitHub Latest 中的旧清单由 -发布工作流从 v0.2.19 原样保留。完整规则见 [发布指南](../../docs/development/releasing.md)。 +`linux-binaries-v1.json`。GitHub Latest 仍携带从 v0.2.19 原样保留的 +`latest.json` / `linux-binaries.json`,给还在跑 0.2.x 的客户端;镜像不再保留 +0.2.x 目录。完整规则见 [发布指南](../../docs/development/releasing.md)。 网页安装包文件名以 GitHub `latest-v1.json` 的 `manual_installers` 为准 -(Windows 现在是 `OpenBitFun_${version}_windows-x86_64-installer.exe`)。不要再写死 -`openbitfun-installer.exe`。 - -macOS 的 `downloads.json` 必须指向 `.dmg`,不是 `platforms` 里的 -`.app.tar.gz`——后者是 updater 的升级载荷,浏览器只会把它解包成一个裸 `.app`。 -1.0.2 起 `manual_installers` 自带 `darwin-aarch64` / `darwin-x86_64`;更早的版本没有, -同步脚本按 `OpenBitFun_${version}_{aarch64,x64}.dmg` 探测并镜像,两种来源都要保留。 +(Windows 是 `OpenBitFun_${version}_windows-x86_64-installer.exe`,macOS 是 +`OpenBitFun_${version}_{aarch64,x64}.dmg`)。不要再写死 `openbitfun-installer.exe`。 +镜像脚本每 10 分钟看一次 Latest:版本没变就什么都不拉,只清到最近 2 个版本目录。 `release-sync.cron` **就是这台机器的整份 root crontab**。`crontab` 该文件会 替换所有 root cron。先备份,确认没有其它任务再装。 @@ -272,10 +268,11 @@ OpenBitFun checkout 不会修改已有 crontab。 `/srv/bitfun-release` 的历史文件,以 `/srv/openbitfun-release` 软链接指向它; Nginx 仍使用原 alias。迁移到新机时复制真实文件到新的标准目录,不能只复制 指向旧路径的软链接。 -- 手动执行一次脚本,等日志出现 `sync complete`,再检查公网下载页、 - `downloads.json`、`latest-v1.json`、`linux-binaries-v1.json` 及所有平台下载链接。 - 比较切换前后的旧清单 SHA-256,确认 `latest.json`、`linux-binaries.json` - 保持不变,并验证旧版本下载链接仍可用。 +- 手动执行一次脚本,等日志出现 `sync complete` 或 `nothing to fetch`,再检查 + 公网下载页、`downloads.json`、`latest-v1.json`、`linux-binaries-v1.json` + 及当前版本的平台下载链接。镜像只保留最近两个版本目录。 + `latest.json` 和 `linux-binaries.json` 是 0.2.x 客户端的 GitHub 兼容清单, + 脚本不得改写它们。 锁文件默认是 `/var/lock/openbitfun-release-sync.lock`,不要再指向 `/root/repos/OpenBitFun-AutoUpdate/sync.lock`,也不要放进 `/srv/openbitfun-release` diff --git a/docs/development/releasing.md b/docs/development/releasing.md index de4fae3ece..fb7bee7948 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -31,9 +31,11 @@ legacy release. Publication rejects legacy feeds whose version is not 0.2.19. Thus installed 0.2.x clients continue to see only 0.2.x, even after GitHub Latest moves to 1.x. Do not rename the 1.x manifests back to the legacy filenames. -The mirror writes only the versioned feeds and retains existing legacy feeds -and 0.2.x artifact directories. Deploy the updated mirror script before the -release and verify both old feed versions and new feed versions afterwards. +The mirror writes only the versioned 1.x feeds and keeps the two newest +version directories. It does not retain 0.2.x artifact trees; 0.2.x clients +keep reading the unchanged `latest.json` / `linux-binaries.json` assets on +GitHub Latest. Deploy the updated mirror script before the release and verify +the new feed versions afterwards. The old `channel-beta/latest.json` pointer is not modified; new prerelease builds use `channel-v1-beta/latest-v1.json`. The final `1.0.0` version sorts above both `1.0.0-beta` and numbered `1.0.0-beta.N` versions. Stable publication diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs index bbe219b473..bf66fc342f 100644 --- a/scripts/linux-binaries-manifest.test.mjs +++ b/scripts/linux-binaries-manifest.test.mjs @@ -362,6 +362,10 @@ test('website download manifest uses installer while updater manifest keeps setu url: 'https://openbitfun.test/release/1.2.3/OpenBitFun_1.2.3_windows-x86_64-installer.exe', signature_url: 'https://openbitfun.test/release/1.2.3/OpenBitFun_1.2.3_windows-x86_64-installer.exe.sig', }, + 'darwin-aarch64': { + url: 'https://openbitfun.test/release/1.2.3/OpenBitFun_1.2.3_aarch64.dmg', + signature_url: 'https://openbitfun.test/release/1.2.3/OpenBitFun_1.2.3_aarch64.dmg.sig', + }, }, }; fs.writeFileSync(updaterPath, `${JSON.stringify(updater, null, 2)}\n`); @@ -407,7 +411,11 @@ test('website download manifest uses installer while updater manifest keeps setu ); assert.equal( website.platforms['darwin-aarch64'].url, - updater.platforms['darwin-aarch64'].url + 'https://openbitfun.test/release/1.2.3/OpenBitFun_1.2.3_aarch64.dmg' + ); + assert.equal( + website.platforms['darwin-aarch64'].signatureUrl, + 'https://openbitfun.test/release/1.2.3/OpenBitFun_1.2.3_aarch64.dmg.sig' ); }); @@ -431,19 +439,16 @@ test('stable Linux archives are mirrored before the much larger Desktop packages ); }); -test('the mirror retains enough releases for older Desktop builds', () => { +test('the mirror keeps only the two newest versions and skips an unchanged latest', () => { const syncScript = fs.readFileSync( path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), 'utf8' ); const keep = /^KEEP_VERSIONS=(\d+)$/m.exec(syncScript); assert.ok(keep, 'KEEP_VERSIONS must be set'); - // Dispatch confirms an exact release before installation; keep that version - // available long enough for a later click or retry to finish safely. - assert.ok( - Number(keep[1]) >= 4, - `KEEP_VERSIONS must retain several releases, got ${keep[1]}` - ); + assert.equal(Number(keep[1]), 2); + assert.match(syncScript, /Already mirroring \$VERSION; nothing to fetch/); + assert.doesNotMatch(syncScript, /! -name '0\.2\.\*'/); }); test('openbitfun sync lock and cron use the in-repo script, not a server-only copy', () => { diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index b379319156..8f1866268c 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -6,19 +6,14 @@ # 1. Fetch the selected channel's latest-v1.json from GitHub # 2. Mirror the signed Relay image descriptor and Linux binary manifest FIRST # (small trust metadata must not queue behind ~700 MB of Desktop packages) -# 3. Download every Desktop updater package plus the standalone Windows -# installer into release/{version}/ -# 4. Rewrite updater URLs and generate a separate website download manifest -# 5. Atomically publish versioned and root manifests -# 6. Remove old version dirs, keeping only the most recent KEEP_VERSIONS +# 3. If that version is already published, prune to KEEP_VERSIONS and stop +# 4. Otherwise download the new version, rewrite manifests, then prune # -# The published release/latest-v1.json and release/beta/latest-v1.json files are the -# stable and beta Tauri updater fallback endpoints. -# When GitHub is unreachable, the desktop client automatically falls through -# to https://openbitfun.com/release/latest-v1.json and downloads from this mirror. -# The published release/downloads.json is for the website. Its Windows and macOS -# URLs point at real installers — the manual_installers entry, or for macOS the -# mirrored .dmg — while the updater keeps the versioned Tauri package URLs. +# Desktop / CLI auto-update reads latest-v1.json and linux-binaries-v1.json. +# Those files always name the current version, so the updater never needs a +# retained older directory. downloads.json is website-only and substitutes +# each manual_installers entry (Windows installer, macOS .dmg) for the +# corresponding updater package. # # Cron (every 10 minutes). Run the in-repo script from the OpenBitFun checkout so a # new host only needs this repository, not a detached AutoUpdate copy: @@ -81,12 +76,8 @@ LOCK_FILE="${OPENBITFUN_RELEASE_SYNC_LOCK:-/var/lock/openbitfun-release-sync.loc WINDOWS_INSTALLER_FILENAME="openbitfun-installer.exe" WINDOWS_INSTALLER_URL="" WINDOWS_INSTALLER_SIGNATURE_URL="" -MACOS_DMG_INSTALLERS="" WEBSITE_DOWNLOADS_MANIFEST="downloads.json" -# Keep enough releases that the mirror still serves a Desktop build a few -# versions behind and SSH Dispatch can finish an already-confirmed install even -# after a newer release becomes current. -KEEP_VERSIONS=6 +KEEP_VERSIONS=2 CONNECT_TIMEOUT=30 MAX_TIME=1800 # per-request ceiling (30 min; installer packages can be large) MAX_RETRIES=3 @@ -180,90 +171,9 @@ if entry: "${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}.sig" || exit 1 } -# Mirror the signed macOS .dmg installers. -# -# The macOS updater package is a .app.tar.gz: an update payload with no -# installer UI, which a browser unpacks into a bare .app next to whatever else -# is in Downloads. It is what the updater consumes, not what a person installs. -# Releases before 1.0.2 declare manual_installers for windows-x86_64 only, so -# the website had nothing else to offer macOS visitors and handed them that -# archive. The .dmg files are published with every Desktop release under -# deterministic names, so resolve them here and let the website manifest prefer -# them. -# -# Probing rather than assuming: the .dmg and its signature are uploaded by a -# separate job, so an early cron run can legitimately see neither. A partial set -# is removed so the next run re-fetches instead of publishing a URL that 404s. -mirror_macos_dmg_installers() { - local platform arch declared filename base_url suffix ready failed - MACOS_DMG_INSTALLERS="" - for platform in darwin-aarch64 darwin-x86_64; do - case "$platform" in - darwin-aarch64) arch="aarch64" ;; - *) arch="x64" ;; - esac - - # A release that declares its own macOS installer wins over the naming - # convention, so a future rename does not silently keep mirroring the old - # file. - declared="$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " -import json, sys -entry = json.load(sys.stdin).get('manual_installers', {}).get('${platform}') -if entry: - print(entry['url']) -")" - if [ -n "$declared" ]; then - if [ "${declared%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then - log "ERROR: ${platform} manual installer does not belong to release $VERSION" - return 1 - fi - filename="${declared##*/}" - else - filename="OpenBitFun_${VERSION}_${arch}.dmg" - fi - base_url="${RELEASE_ASSET_BASE_URL}/${filename}" - - ready=1 - for suffix in "" .sig; do - if ! curl -fsSIL \ - --connect-timeout "$CONNECT_TIMEOUT" \ - --max-time "$MAX_TIME" \ - "${base_url}${suffix}" >/dev/null; then - ready=0 - break - fi - done - if [ "$ready" -ne 1 ]; then - log " macOS installer set is not complete yet: $filename" - continue - fi - - failed=0 - for suffix in "" .sig; do - log " Mirroring macOS installer: ${filename}${suffix}" - if ! download_asset \ - "${base_url}${suffix}" \ - "${VERSION_DIR}/${filename}${suffix}"; then - failed=1 - break - fi - done - if [ "$failed" -ne 0 ]; then - rm -f "${VERSION_DIR}/${filename}" "${VERSION_DIR}/${filename}.sig" - log "WARN: incomplete macOS installer set removed; retrying next sync." - continue - fi - - MACOS_DMG_INSTALLERS="${MACOS_DMG_INSTALLERS}${platform}=${filename}"$'\n' - done - return 0 -} - # Build a website-only manifest from the already rewritten updater manifest. -# Targets that have a real installer — every declared manual_installers entry, -# plus the macOS .dmg files resolved above — are replaced with it; the rest keep -# their mirrored updater package. The updater URLs remain untouched; -# manual_installers is a mirror/website extension only. +# Every declared manual_installers entry replaces that platform's updater +# package; the rest keep the updater URL. The updater manifest is untouched. write_website_download_manifest() { local output="${VERSION_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}" local output_tmp="${output}.part" @@ -272,11 +182,10 @@ write_website_download_manifest() { "${VERSION_DIR}/latest-v1.json" \ "$output_tmp" \ "$OPENBITFUN_BASE_URL" \ - "$WINDOWS_INSTALLER_FILENAME" \ - "$MACOS_DMG_INSTALLERS" <<'PY' + "$WINDOWS_INSTALLER_FILENAME" <<'PY' import json, sys -source, dest, base, windows_installer, macos_installers = sys.argv[1:] +source, dest, base, windows_installer = sys.argv[1:] with open(source, encoding="utf-8") as f: updater = json.load(f) @@ -292,22 +201,12 @@ windows = platforms.get("windows-x86_64") if windows is None: raise SystemExit("latest-v1.json is missing windows-x86_64") -# These URLs were rewritten to the mirror together with the updater ones. for target, manual in updater.get("manual_installers", {}).items(): if target not in platforms: continue platforms[target]["url"] = manual["url"] platforms[target]["signatureUrl"] = manual.get("signature_url", manual["url"] + ".sig") -for line in macos_installers.splitlines(): - if not line: - continue - target, filename = line.split("=", 1) - if target not in platforms or "signatureUrl" in platforms[target]: - continue - platforms[target]["url"] = f"{version_base}/{filename}" - platforms[target]["signatureUrl"] = f"{version_base}/{filename}.sig" - if "signatureUrl" not in windows: windows["url"] = f"{version_base}/{windows_installer}" windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" @@ -635,6 +534,19 @@ main() { } log "Latest version: $VERSION" + PUBLISHED_VERSION="" + if [ -f "${WEBSITE_RELEASE_DIR}/latest-v1.json" ]; then + PUBLISHED_VERSION=$("$PYTHON" -c \ + "import json,sys;print(json.load(open(sys.argv[1], encoding='utf-8'))['version'])" \ + "${WEBSITE_RELEASE_DIR}/latest-v1.json") || PUBLISHED_VERSION="" + fi + if [ -n "$PUBLISHED_VERSION" ] && [ "$PUBLISHED_VERSION" = "$VERSION" ]; then + log "Already mirroring $VERSION; nothing to fetch" + prune_old_versions + log "=== ${RELEASE_CHANNEL} sync complete: version $VERSION (unchanged) ===" + exit 0 + fi + # Resolve one immutable release directory for every artifact. Independent # latest/download requests can cross versions while a release is published. RELEASE_ASSET_BASE_URL=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " @@ -714,9 +626,24 @@ for p, info in data.get('platforms', {}).items(): download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 done <<< "$ASSET_LIST" - # Mirror the manual installers separately while preserving the updater URLs. + # Mirror declared installers separately; the updater URLs stay in platforms. mirror_windows_installer - mirror_macos_dmg_installers + EXTRA_INSTALLERS=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c " +import json, sys +data = json.load(sys.stdin) +for target, entry in data.get('manual_installers', {}).items(): + if target == 'windows-x86_64': + continue + url = entry.get('url') + if url: + print(url) + print(entry.get('signature_url', url + '.sig')) +") + while IFS= read -r url; do + [ -z "$url" ] && continue + log " Mirroring installer: ${url##*/}" + download_asset "$url" "${VERSION_DIR}/${url##*/}" || exit 1 + done <<< "$EXTRA_INSTALLERS" # 6. Rewrite URLs in latest-v1.json to point at openbitfun.com LATEST_MANIFEST_TMP="${VERSION_DIR}/latest-v1.json.part" @@ -748,21 +675,24 @@ print(json.dumps(data, indent=2)) "${WEBSITE_RELEASE_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}" log "Updated ${WEBSITE_RELEASE_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}" - # 8. Clean up old versions — keep only the latest KEEP_VERSIONS dirs - ALL_DIRS=() + prune_old_versions + log "=== ${RELEASE_CHANNEL} sync complete: version $VERSION ===" +} + +prune_old_versions() { + local dirs=() total remove_count i while IFS= read -r d; do - ALL_DIRS+=("$d") - done < <(find "$WEBSITE_RELEASE_DIR" -mindepth 1 -maxdepth 1 -type d ! -name '0.2.*' | sort -V) - TOTAL=${#ALL_DIRS[@]} - if [ "$TOTAL" -gt "$KEEP_VERSIONS" ]; then - REMOVE_COUNT=$((TOTAL - KEEP_VERSIONS)) - for ((i = 0; i < REMOVE_COUNT; i++)); do - log "Removing old version: $(basename "${ALL_DIRS[$i]}")" - rm -rf "${ALL_DIRS[$i]}" - done + dirs+=("$d") + done < <(find "$WEBSITE_RELEASE_DIR" -mindepth 1 -maxdepth 1 -type d | sort -V) + total=${#dirs[@]} + if [ "$total" -le "$KEEP_VERSIONS" ]; then + return 0 fi - - log "=== ${RELEASE_CHANNEL} sync complete: version $VERSION ===" + remove_count=$((total - KEEP_VERSIONS)) + for ((i = 0; i < remove_count; i++)); do + log "Removing old version: $(basename "${dirs[$i]}")" + rm -rf "${dirs[$i]}" + done } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then