#!/usr/bin/env bash
set -euo pipefail
RELEASE_CHANNEL="${BITFUN_RELEASE_CHANNEL:-stable}"
case "$RELEASE_CHANNEL" in
stable)
CHANNEL_PATH=""
GITHUB_RELEASE_ROOT="https://github.com/GCWing/BitFun/releases/latest/download"
;;
beta)
CHANNEL_PATH="/beta"
GITHUB_RELEASE_ROOT="https://github.com/GCWing/BitFun/releases/download/channel-beta"
;;
*)
echo "Unsupported BITFUN_RELEASE_CHANNEL: $RELEASE_CHANNEL" >&2
exit 1
;;
esac
GITHUB_LATEST_JSON_URL="${GITHUB_RELEASE_ROOT}/latest.json"
GITHUB_LINUX_BINARIES_URL="${GITHUB_RELEASE_ROOT}/linux-binaries.json"
GITHUB_RELAY_IMAGE_URL="${GITHUB_RELEASE_ROOT}/relay-image.json"
OPENBITFUN_BASE_URL="https://openbitfun.com/release${CHANNEL_PATH}"
WEBSITE_RELEASE_ROOT="${WEBSITE_RELEASE_DIR:-/srv/bitfun-release}"
WEBSITE_RELEASE_DIR="${WEBSITE_RELEASE_ROOT}${CHANNEL_PATH}"
LOCK_FILE="${BITFUN_RELEASE_SYNC_LOCK:-/var/lock/bitfun-release-sync.lock}"
LEGACY_WINDOWS_INSTALLER_FILENAME="bitfun-installer.exe"
WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME"
WINDOWS_INSTALLER_URL=""
WINDOWS_INSTALLER_SIGNATURE_URL=""
WEBSITE_DOWNLOADS_MANIFEST="downloads.json"
KEEP_VERSIONS=6
CONNECT_TIMEOUT=30
MAX_TIME=1800
MAX_RETRIES=3
RETRY_DELAY=5
PYTHON="${PYTHON:-python3}"
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }
download_asset() {
local url="$1"
local dest="$2"
local filename tmp ok attempt
filename="$(basename "$dest")"
if [ -f "$dest" ]; then
log " Already exists: $filename"
return 0
fi
tmp="${dest}.part"
ok=0
for attempt in $(seq 1 "$MAX_RETRIES"); do
if curl -fsSL \
--connect-timeout "$CONNECT_TIMEOUT" \
--max-time "$MAX_TIME" \
-o "$tmp" "$url"; then
mv "$tmp" "$dest"
ok=1
break
fi
log " Retry $attempt/$MAX_RETRIES for $filename"
sleep "$RETRY_DELAY"
done
if [ "$ok" -ne 1 ]; then
rm -f "$tmp"
log "ERROR: Failed to download $filename after $MAX_RETRIES attempts"
return 1
fi
}
publish_file_atomically() {
local source="$1"
local dest="$2"
local tmp="${dest}.part"
cp "$source" "$tmp"
mv "$tmp" "$dest"
}
mirror_windows_installer() {
local installer_url signature_url metadata
installer_url="${WINDOWS_INSTALLER_URL:-${RELEASE_ASSET_BASE_URL}/${WINDOWS_INSTALLER_FILENAME}}"
signature_url="${WINDOWS_INSTALLER_SIGNATURE_URL:-${installer_url}.sig}"
if [ -n "${LATEST_JSON:-}" ]; then
metadata=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c "
import json, sys
data = json.load(sys.stdin)
entry = data.get('manual_installers', {}).get('windows-x86_64')
if entry:
print(entry['url'])
print(entry.get('signature_url', entry['url'] + '.sig'))
")
if [ -n "$metadata" ]; then
installer_url=$(printf '%s\n' "$metadata" | sed -n '1p')
signature_url=$(printf '%s\n' "$metadata" | sed -n '2p')
if [ "${installer_url%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then
log "ERROR: Manual installer URL does not belong to the updater release: $installer_url"
return 1
fi
if [ "$signature_url" != "${installer_url}.sig" ]; then
log "ERROR: Manual installer signature URL does not match the installer URL"
return 1
fi
WINDOWS_INSTALLER_FILENAME="${installer_url##*/}"
fi
fi
log " Mirroring website Windows installer: ${WINDOWS_INSTALLER_FILENAME}"
download_asset \
"$installer_url" \
"${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}" || exit 1
download_asset \
"$signature_url" \
"${VERSION_DIR}/${WINDOWS_INSTALLER_FILENAME}.sig" || exit 1
}
write_website_download_manifest() {
local output="${VERSION_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}"
local output_tmp="${output}.part"
"$PYTHON" - \
"${VERSION_DIR}/latest.json" \
"$output_tmp" \
"$OPENBITFUN_BASE_URL" \
"$WINDOWS_INSTALLER_FILENAME" <<'PY'
import json, sys
source, dest, base, windows_installer = sys.argv[1:]
with open(source, encoding="utf-8") as f:
updater = json.load(f)
version = updater["version"]
platforms = {}
for target, entry in updater.get("platforms", {}).items():
url = entry.get("url")
if url:
platforms[target] = {"url": url}
windows = platforms.get("windows-x86_64")
if windows is None:
raise SystemExit("latest.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}"
windows["url"] = f"{version_base}/{windows_installer}"
windows["signatureUrl"] = f"{version_base}/{windows_installer}.sig"
website = {
"schemaVersion": 1,
"version": version,
"platforms": platforms,
}
for optional_key in ("notes", "pub_date"):
if optional_key in updater:
website[optional_key] = updater[optional_key]
with open(dest, "w", encoding="utf-8") as f:
json.dump(website, f, indent=2)
f.write("\n")
PY
mv "$output_tmp" "$output"
log "Saved ${output}"
}
verify_mirrored_checksums() {
local filename sidecar archive expected actual failed=0
while IFS= read -r filename; do
[ -n "$filename" ] || continue
case "$filename" in
*.sha256) ;;
*) continue ;;
esac
sidecar="${VERSION_DIR}/${filename}"
archive="${sidecar%.sha256}"
if [ ! -f "$sidecar" ] || [ ! -f "$archive" ]; then
continue
fi
expected="$(awk '{print $1; exit}' "$sidecar")"
actual="$(sha256sum "$archive" | awk '{print $1}')"
if [ "$expected" != "$actual" ]; then
log "ERROR: checksum mismatch for $(basename "$archive"): expected $expected, got $actual"
rm -f "$archive"
failed=1
fi
done
if [ "$failed" -ne 0 ]; then
log "ERROR: refusing to publish a manifest for unverified assets"
return 1
fi
return 0
}
fetch_linux_manifest() {
local attempt status
LINUX_MANIFEST_STATE="unhealthy"
for attempt in $(seq 1 "$MAX_RETRIES"); do
status="$(curl -sSL \
--connect-timeout "$CONNECT_TIMEOUT" \
--max-time "$MAX_TIME" \
-o "$LINUX_MANIFEST_TMP" \
-w '%{http_code}' \
"$GITHUB_LINUX_BINARIES_URL" || echo "000")"
if [ "$status" = "200" ]; then
LINUX_MANIFEST_STATE="ok"
return 0
fi
rm -f "$LINUX_MANIFEST_TMP"
if [ "$status" = "404" ]; then
LINUX_MANIFEST_STATE="missing"
return 0
fi
log " Retry $attempt/$MAX_RETRIES for linux-binaries.json (HTTP $status)"
sleep "$RETRY_DELAY"
done
return 0
}
mirror_linux_binaries() {
LINUX_MANIFEST_TMP="${VERSION_DIR}/linux-binaries.github.json.part"
LINUX_MANIFEST_STATE="missing"
fetch_linux_manifest
if [ "$LINUX_MANIFEST_STATE" = "ok" ]; then
LINUX_VERSION=$("$PYTHON" -c \
"import json,sys;print(json.load(open(sys.argv[1], encoding='utf-8'))['version'])" \
"$LINUX_MANIFEST_TMP")
if [ "$LINUX_VERSION" != "$VERSION" ]; then
log "ERROR: Linux manifest version $LINUX_VERSION does not match Desktop version $VERSION"
rm -f "$LINUX_MANIFEST_TMP"
exit 1
fi
LINUX_ASSET_LIST=$("$PYTHON" - "$LINUX_MANIFEST_TMP" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
data = json.load(f)
seen = set()
for platform in data.get("platforms", {}).values():
for product in ("cli", "relay"):
entry = platform.get(product, {})
for key in ("url", "sha256Url", "sha256SigUrl", "sigUrl"):
url = entry.get(key)
if not url:
continue
filename = url.rsplit("/", 1)[-1]
if filename not in seen:
seen.add(filename)
print(f"{url}\t{filename}")
PY
)
while IFS=$'\t' read -r url filename; do
[ -z "$url" ] && continue
log " Mirroring Linux binary asset: $filename"
download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1
done <<< "$LINUX_ASSET_LIST"
cut -f2 <<< "$LINUX_ASSET_LIST" | verify_mirrored_checksums || exit 1
"$PYTHON" - "$LINUX_MANIFEST_TMP" "${VERSION_DIR}/linux-binaries.json" \
"$OPENBITFUN_BASE_URL" <<'PY'
import json, sys
source, dest, base = sys.argv[1:]
with open(source, encoding="utf-8") as f:
data = json.load(f)
version_base = f"{base}/{data['version']}"
for platform in data.get("platforms", {}).values():
for product in ("cli", "relay"):
entry = platform.get(product, {})
for key in ("url", "sha256Url", "sha256SigUrl", "sigUrl"):
if entry.get(key):
entry[key] = f"{version_base}/{entry[key].rsplit('/', 1)[-1]}"
with open(dest, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
rm -f "$LINUX_MANIFEST_TMP"
publish_file_atomically \
"${VERSION_DIR}/linux-binaries.json" \
"${WEBSITE_RELEASE_DIR}/linux-binaries.json"
log "Updated ${WEBSITE_RELEASE_DIR}/linux-binaries.json"
elif [ "$LINUX_MANIFEST_STATE" = "missing" ]; then
rm -f "${WEBSITE_RELEASE_DIR}/linux-binaries.json"
log "Linux binaries manifest is not present in the latest release yet; Desktop mirror only."
else
log "WARN: Linux binaries manifest unreachable this run; keeping the published mirror."
fi
}
mirror_dispatch_macos_cli_archives() {
local target filename base_url suffix ready failed checksum_list
checksum_list=""
for target in x86_64-apple-darwin aarch64-apple-darwin; do
filename="bitfun-cli-${VERSION}-${target}.tar.gz"
base_url="${RELEASE_ASSET_BASE_URL}/${filename}"
ready=1
for suffix in "" .sha256 .sha256.sig .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 Dispatch CLI set is not complete yet: $filename"
continue
fi
failed=0
for suffix in "" .sha256 .sha256.sig .sig; do
log " Mirroring macOS Dispatch CLI asset: ${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}.sha256" \
"${VERSION_DIR}/${filename}.sha256.sig" \
"${VERSION_DIR}/${filename}.sig"
log "WARN: incomplete macOS Dispatch CLI set removed; retrying next sync."
continue
fi
checksum_list="${checksum_list}${filename}.sha256"$'\n'
done
if [ -n "$checksum_list" ]; then
printf '%s' "$checksum_list" | verify_mirrored_checksums || exit 1
fi
}
mirror_relay_image_descriptor() {
local descriptor_tmp signature_tmp status descriptor_version
descriptor_tmp="${VERSION_DIR}/relay-image.json.part"
signature_tmp="${VERSION_DIR}/relay-image.json.sig.part"
rm -f "$descriptor_tmp" "$signature_tmp"
status="$(curl -sSL \
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" \
-o "$descriptor_tmp" -w '%{http_code}' "$GITHUB_RELAY_IMAGE_URL" || echo 000)"
if [ "$status" = "404" ]; then
log "Relay image descriptor is not present in the latest release yet."
rm -f "$descriptor_tmp"
return 0
fi
if [ "$status" != "200" ]; then
log "WARN: relay-image.json unreachable (HTTP $status); keeping any existing versioned copy."
rm -f "$descriptor_tmp"
return 0
fi
if ! curl -fsSL --retry "$MAX_RETRIES" --retry-delay "$RETRY_DELAY" \
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" \
-o "$signature_tmp" "${GITHUB_RELAY_IMAGE_URL}.sig"; then
log "WARN: relay-image.json.sig unreachable; refusing to publish an unsigned descriptor."
rm -f "$descriptor_tmp" "$signature_tmp"
return 0
fi
descriptor_version="$("$PYTHON" - "$descriptor_tmp" <<'PY'
import json, re, sys
with open(sys.argv[1], encoding="utf-8") as f:
data = json.load(f)
assert data.get("schema_version") == 1
assert data.get("image") == "ghcr.io/gcwing/bitfun-relay-server"
assert re.fullmatch(r"sha256:[0-9a-f]{64}", data.get("digest", ""))
print(data["version"])
PY
)" || {
log "ERROR: relay-image.json failed its schema/repository/digest checks"
rm -f "$descriptor_tmp" "$signature_tmp"
return 1
}
if [ "$descriptor_version" != "$VERSION" ]; then
log "ERROR: Relay image descriptor version $descriptor_version does not match Desktop version $VERSION"
rm -f "$descriptor_tmp" "$signature_tmp"
return 1
fi
mv "$descriptor_tmp" "${VERSION_DIR}/relay-image.json"
mv "$signature_tmp" "${VERSION_DIR}/relay-image.json.sig"
publish_file_atomically \
"${VERSION_DIR}/relay-image.json" \
"${WEBSITE_RELEASE_DIR}/relay-image.json"
publish_file_atomically \
"${VERSION_DIR}/relay-image.json.sig" \
"${WEBSITE_RELEASE_DIR}/relay-image.json.sig"
log "Published signed Relay image descriptor for $VERSION"
}
main() {
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
if command -v flock >/dev/null 2>&1 && ! flock -n 9; then
log "Another release sync is still running; skipping this interval."
exit 0
fi
log "=== BitFun ${RELEASE_CHANNEL} release sync started ==="
mkdir -p "$WEBSITE_RELEASE_DIR"
log "Fetching latest.json from GitHub..."
LATEST_JSON=$(curl -fsSL \
--connect-timeout "$CONNECT_TIMEOUT" \
--max-time "$MAX_TIME" \
"$GITHUB_LATEST_JSON_URL") || {
log "ERROR: Failed to fetch latest.json from GitHub"
exit 1
}
VERSION=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c \
"import sys,json;print(json.load(sys.stdin)['version'])") || {
log "ERROR: Failed to parse version from latest.json"
exit 1
}
log "Latest version: $VERSION"
RELEASE_ASSET_BASE_URL=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c "
import json, sys
data = json.load(sys.stdin)
bases = {entry['url'].rsplit('/', 1)[0] for entry in data.get('platforms', {}).values() if entry.get('url')}
if len(bases) != 1:
raise SystemExit(f'expected one release asset base, got {sorted(bases)}')
print(bases.pop())
") || {
log "ERROR: Failed to resolve the release asset base from latest.json"
exit 1
}
INSTALLER_METADATA=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c "
import json, sys
data = json.load(sys.stdin)
entry = data.get('manual_installers', {}).get('windows-x86_64')
if entry:
print(entry['url'])
print(entry.get('signature_url', entry['url'] + '.sig'))
") || {
log "ERROR: Failed to resolve the manual Windows installer from latest.json"
exit 1
}
if [ -n "$INSTALLER_METADATA" ]; then
WINDOWS_INSTALLER_URL=$(printf '%s\n' "$INSTALLER_METADATA" | sed -n '1p')
WINDOWS_INSTALLER_SIGNATURE_URL=$(printf '%s\n' "$INSTALLER_METADATA" | sed -n '2p')
if [ "${WINDOWS_INSTALLER_URL%/*}" != "$RELEASE_ASSET_BASE_URL" ]; then
log "ERROR: Manual installer URL does not belong to release $VERSION"
exit 1
fi
if [ "$WINDOWS_INSTALLER_SIGNATURE_URL" != "${WINDOWS_INSTALLER_URL}.sig" ]; then
log "ERROR: Manual installer signature URL does not match the installer URL"
exit 1
fi
WINDOWS_INSTALLER_FILENAME="${WINDOWS_INSTALLER_URL##*/}"
else
WINDOWS_INSTALLER_URL="${RELEASE_ASSET_BASE_URL}/${LEGACY_WINDOWS_INSTALLER_FILENAME}"
WINDOWS_INSTALLER_SIGNATURE_URL="${WINDOWS_INSTALLER_URL}.sig"
WINDOWS_INSTALLER_FILENAME="$LEGACY_WINDOWS_INSTALLER_FILENAME"
fi
VERSION_DIR="${WEBSITE_RELEASE_DIR}/${VERSION}"
mkdir -p "$VERSION_DIR"
if [ "$RELEASE_CHANNEL" = "stable" ]; then
mirror_relay_image_descriptor
mirror_linux_binaries
mirror_dispatch_macos_cli_archives
else
log "Skipping stable-only CLI and Relay metadata for the beta channel"
fi
ASSET_LIST=$(printf '%s' "$LATEST_JSON" | "$PYTHON" -c "
import sys, json
data = json.load(sys.stdin)
for p, info in data.get('platforms', {}).items():
url = info['url']
fname = url.split('/')[-1]
print(f'{url}\t{fname}')
") || {
log "ERROR: Failed to extract asset list from latest.json"
exit 1
}
while IFS=$'\t' read -r url filename; do
[ -z "$url" ] && continue
log " Mirroring Desktop asset: $filename"
download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1
done <<< "$ASSET_LIST"
mirror_windows_installer
LATEST_MANIFEST_TMP="${VERSION_DIR}/latest.json.part"
printf '%s' "$LATEST_JSON" | "$PYTHON" -c "
import sys, json
data = json.load(sys.stdin)
version = data['version']
base = '${OPENBITFUN_BASE_URL}/' + version
for p, info in data.get('platforms', {}).items():
fname = info['url'].split('/')[-1]
info['url'] = base + '/' + fname
for p, info in data.get('manual_installers', {}).items():
for key in ('url', 'signature_url'):
if info.get(key):
info[key] = base + '/' + info[key].split('/')[-1]
print(json.dumps(data, indent=2))
" > "$LATEST_MANIFEST_TMP"
mv "$LATEST_MANIFEST_TMP" "${VERSION_DIR}/latest.json"
log "Saved ${VERSION_DIR}/latest.json"
write_website_download_manifest
publish_file_atomically \
"${VERSION_DIR}/latest.json" \
"${WEBSITE_RELEASE_DIR}/latest.json"
log "Updated ${WEBSITE_RELEASE_DIR}/latest.json"
publish_file_atomically \
"${VERSION_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}" \
"${WEBSITE_RELEASE_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}"
log "Updated ${WEBSITE_RELEASE_DIR}/${WEBSITE_DOWNLOADS_MANIFEST}"
ALL_DIRS=()
while IFS= read -r d; do
ALL_DIRS+=("$d")
done < <(find "$WEBSITE_RELEASE_DIR" -mindepth 1 -maxdepth 1 -type d | 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
fi
log "=== ${RELEASE_CHANNEL} sync complete: version $VERSION ==="
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi