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..cc1e84d8b2 100644 --- a/deploy/openbitfun-host/README.md +++ b/deploy/openbitfun-host/README.md @@ -212,17 +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)。 -Windows 网页安装包文件名以 GitHub `latest-v1.json` 的 `manual_installers` 为准 -(现在是 `OpenBitFun_${version}_windows-x86_64-installer.exe`)。不要再写死 -`openbitfun-installer.exe`。 +网页安装包文件名以 GitHub `latest-v1.json` 的 `manual_installers` 为准 +(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。先备份,确认没有其它任务再装。 @@ -267,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/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/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 c02576718b..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 URL uses -# latest-v1.json's manual_installers entry while the updater keeps the versioned -# Tauri setup.exe URL. +# 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: @@ -82,10 +77,7 @@ WINDOWS_INSTALLER_FILENAME="openbitfun-installer.exe" WINDOWS_INSTALLER_URL="" WINDOWS_INSTALLER_SIGNATURE_URL="" 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,9 +172,8 @@ if entry: } # 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. +# 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" @@ -199,6 +190,7 @@ 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 +201,13 @@ 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}" +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") + +if "signatureUrl" not in windows: windows["url"] = f"{version_base}/{windows_installer}" windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig" @@ -541,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 " @@ -620,8 +626,24 @@ 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 declared installers separately; the updater URLs stay in platforms. mirror_windows_installer + 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" @@ -653,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 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');