Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 23 additions & 19 deletions .github/workflows/desktop-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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: |
Expand Down
26 changes: 14 additions & 12 deletions deploy/openbitfun-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。先备份,确认没有其它任务再装。
Expand Down Expand Up @@ -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`
Expand Down
8 changes: 5 additions & 3 deletions docs/development/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 37 additions & 8 deletions scripts/generate-tauri-latest-json.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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`,
};
}

Expand Down
21 changes: 13 additions & 8 deletions scripts/linux-binaries-manifest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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'
);
});

Expand All @@ -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', () => {
Expand Down
103 changes: 64 additions & 39 deletions scripts/openbitfun-release-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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")
Expand All @@ -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"

Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading