diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml index 7bd035079..0e88d3952 100644 --- a/.github/workflows/cli-package.yml +++ b/.github/workflows/cli-package.yml @@ -80,6 +80,11 @@ jobs: runs-on: ${{ matrix.platform.os }} needs: prepare + # Linux x86_64/aarch64 are intentionally absent. They are built and published + # by .github/workflows/linux-binaries.yml, which is the only producer that + # also covers nightly and which must hold the CLI and Relay archives together + # to emit linux-binaries.json. Adding them back here would upload identical + # asset names from two workflows racing on the same `release: published`. strategy: fail-fast: false matrix: @@ -90,12 +95,6 @@ jobs: - os: macos-15-intel name: macos-x64 target: x86_64-apple-darwin - - os: ubuntu-latest - name: linux-x64 - target: x86_64-unknown-linux-gnu - - os: ubuntu-24.04-arm - name: linux-arm64 - target: aarch64-unknown-linux-gnu - os: windows-latest name: windows-x64 target: x86_64-pc-windows-msvc @@ -106,20 +105,6 @@ jobs: with: ref: ${{ needs.prepare.outputs.checkout_ref }} - - name: Install Linux system dependencies - if: runner.os == 'Linux' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config \ - build-essential \ - libssl-dev \ - libxcb1-dev \ - libxcb-render0-dev \ - libxcb-shape0-dev \ - libxcb-xfixes0-dev - - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -214,11 +199,16 @@ jobs: echo "CLI release assets:" find cli-release-assets -type f | sort - - name: Generate combined SHA256SUMS + - name: Generate SHA256SUMS for macOS and Windows archives working-directory: cli-release-assets run: | set -euo pipefail - # Sort for reproducibility; include Unix tarballs and the Windows zip. + # Scope note: Linux archives are published by linux-binaries.yml in a + # different workflow run that finishes after this one, so they cannot + # be folded in here deterministically. Every archive on the release — + # Linux included — still ships its own `.sha256` sidecar, which + # is the complete and uniform verification surface. Keep this file's + # contents deterministic rather than dependent on cross-workflow timing. mapfile -t archives < <( find . -maxdepth 1 -type f \ \( -name 'bitfun-cli-*.tar.gz' -o -name 'bitfun-cli-*.zip' \) \ diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index e2e8aab6b..5d6c77a56 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -239,10 +239,19 @@ jobs: src/apps/desktop/target/release/bundle BitFun-Installer/src-tauri/target/release/bitfun-installer.exe + linux-binaries: + name: Linux CLI and Relay Server + needs: prepare + uses: ./.github/workflows/linux-binaries.yml + with: + checkout_ref: ${{ needs.prepare.outputs.checkout_ref }} + version: ${{ needs.prepare.outputs.version }} + artifact_prefix: ${{ needs.prepare.outputs.release_tag }} + # ── Upload assets to GitHub Release ──────────────────────────────── upload-release-assets: name: Upload Release Assets - needs: [prepare, package] + needs: [prepare, package, linux-binaries] if: needs.prepare.outputs.upload_to_release == 'true' runs-on: ubuntu-latest env: @@ -259,10 +268,19 @@ jobs: path: release-assets merge-multiple: true + - name: Download Linux binary artifacts + uses: actions/download-artifact@v7 + with: + pattern: bitfun-linux-${{ needs.prepare.outputs.release_tag }}-* + path: linux-release-assets + merge-multiple: true + - name: List release assets run: | echo "Release assets:" find release-assets -type f | sort + echo "Linux CLI and Relay Server assets:" + find linux-release-assets -type f | sort - name: Collect updater assets run: | @@ -289,6 +307,15 @@ jobs: --version "${{ needs.prepare.outputs.version }}" \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" + - name: Generate Linux binaries manifest + run: | + node scripts/generate-linux-binaries-manifest.mjs \ + --assets-dir linux-release-assets \ + --version "${{ needs.prepare.outputs.version }}" \ + --tag "${{ needs.prepare.outputs.release_tag }}" \ + --repo "GCWing/BitFun" \ + --out linux-release-assets/linux-binaries.json + - name: Upload to release uses: softprops/action-gh-release@v3 with: @@ -301,6 +328,11 @@ jobs: release-assets/**/*.dmg release-assets/**/*.rpm release-assets/**/*bitfun-installer.exe + linux-release-assets/bitfun-cli-*.tar.gz + linux-release-assets/bitfun-cli-*.tar.gz.sha256 + linux-release-assets/bitfun-relay-server-*.tar.gz + linux-release-assets/bitfun-relay-server-*.tar.gz.sha256 + linux-release-assets/linux-binaries.json fail_on_unmatched_files: true - name: Verify published updater manifest @@ -313,3 +345,10 @@ jobs: --version "${{ needs.prepare.outputs.version }}" \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ --check-urls true + + - name: Verify published Linux binaries manifest + run: | + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/linux-binaries.json" \ + -o linux-binaries.published.json + test "$(jq -r '.version' linux-binaries.published.json)" = "${{ needs.prepare.outputs.version }}" diff --git a/.github/workflows/linux-binaries.yml b/.github/workflows/linux-binaries.yml new file mode 100644 index 000000000..cd39e3d65 --- /dev/null +++ b/.github/workflows/linux-binaries.yml @@ -0,0 +1,131 @@ +name: Linux Binary Build + +on: + workflow_call: + inputs: + checkout_ref: + description: "Tag or commit SHA to build." + required: true + type: string + version: + description: "Release version used by binaries and archive metadata." + required: true + type: string + artifact_prefix: + description: "Stable prefix that isolates artifacts in the caller run." + required: true + type: string + +permissions: + contents: read + +jobs: + build: + name: Linux Binaries (${{ matrix.platform.name }}) + runs-on: ${{ matrix.platform.os }} + timeout-minutes: 90 + + strategy: + fail-fast: false + matrix: + platform: + - os: ubuntu-22.04 + name: linux-x64 + target: x86_64-unknown-linux-gnu + - os: ubuntu-24.04-arm + name: linux-arm64 + target: aarch64-unknown-linux-gnu + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ inputs.checkout_ref }} + + - name: Install Linux system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + clang \ + cmake \ + curl \ + libssl-dev \ + libxcb1-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + pkg-config \ + python3 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform.target }} + + - name: Cache Rust build + uses: swatinem/rust-cache@v2 + with: + shared-key: "linux-binaries-v1-${{ matrix.platform.name }}" + cache-bin: false + + - name: Patch build version + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + # Strip SemVer build metadata (`+`): GitHub rewrites non-alphanumeric + # characters in uploaded release asset names, so a `+` in a filename would + # make every URL in linux-binaries.json a 404. Desktop packaging strips it + # the same way, which also keeps the two asset sets on one version string. + ASSET_VERSION="${RELEASE_VERSION%%+*}" + echo "ASSET_VERSION=${ASSET_VERSION}" >>"$GITHUB_ENV" + sed -i \ + "s/^version = \".*\" # x-release-please-version/version = \"${ASSET_VERSION}\" # x-release-please-version/" \ + Cargo.toml + sed -i \ + "s/^version = \".*\" # x-release-please-version/version = \"${ASSET_VERSION}\" # x-release-please-version/" \ + src/apps/relay-server/Cargo.toml + + - name: Build CLI and Relay Server + shell: bash + run: | + cargo build --release \ + --target ${{ matrix.platform.target }} \ + -p bitfun-cli \ + -p bitfun-relay-server \ + --bins + + - name: Test CLI installer + shell: bash + env: + CARGO_BUILD_TARGET: ${{ matrix.platform.target }} + run: bash scripts/cli/test-install-unix.sh "${{ matrix.platform.target }}" + + - name: Package CLI + id: cli-stage + shell: bash + env: + TARGET: ${{ matrix.platform.target }} + run: bash scripts/cli/package-unix.sh "$ASSET_VERSION" "$TARGET" + + - name: Package Relay Server + id: relay-stage + shell: bash + env: + TARGET: ${{ matrix.platform.target }} + run: bash scripts/relay/package-unix.sh "$ASSET_VERSION" "$TARGET" + + - name: Upload Linux binary artifacts + uses: actions/upload-artifact@v6 + with: + name: bitfun-linux-${{ inputs.artifact_prefix }}-${{ matrix.platform.name }} + if-no-files-found: error + retention-days: 7 + path: | + ${{ steps.cli-stage.outputs.archive }} + ${{ steps.cli-stage.outputs.checksum }} + ${{ steps.relay-stage.outputs.archive }} + ${{ steps.relay-stage.outputs.checksum }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c350888f7..f192b3dd4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -212,10 +212,20 @@ jobs: src/apps/desktop/target/release/bundle BitFun-Installer/src-tauri/target/release/bitfun-installer.exe + linux-binaries: + name: Linux CLI and Relay Server + needs: check-changes + if: needs.check-changes.outputs.should_build == 'true' + uses: ./.github/workflows/linux-binaries.yml + with: + checkout_ref: ${{ github.sha }} + version: ${{ needs.check-changes.outputs.nightly_version }} + artifact_prefix: nightly + # ── Publish nightly pre-release ──────────────────────────────────── publish-nightly: name: Publish Nightly - needs: [check-changes, package] + needs: [check-changes, package, linux-binaries] if: needs.check-changes.outputs.should_build == 'true' runs-on: ubuntu-latest @@ -229,10 +239,19 @@ jobs: path: release-assets merge-multiple: true + - name: Download Linux binary artifacts + uses: actions/download-artifact@v7 + with: + pattern: bitfun-linux-nightly-* + path: linux-release-assets + merge-multiple: true + - name: List release assets run: | echo "Nightly assets:" find release-assets -type f | sort + echo "Nightly Linux CLI and Relay Server assets:" + find linux-release-assets -type f | sort - name: Delete previous nightly release env: @@ -240,6 +259,19 @@ jobs: run: | gh release delete "${{ env.NIGHTLY_TAG }}" --yes --cleanup-tag 2>/dev/null || true + - name: Generate Linux binaries manifest + env: + NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} + run: | + set -euo pipefail + # Asset names drop the `+` build metadata (see linux-binaries.yml). + node scripts/generate-linux-binaries-manifest.mjs \ + --assets-dir linux-release-assets \ + --version "${NIGHTLY_VERSION%%+*}" \ + --tag "${{ env.NIGHTLY_TAG }}" \ + --repo "GCWing/BitFun" \ + --out linux-release-assets/linux-binaries.json + - name: Create nightly release uses: softprops/action-gh-release@v3 with: @@ -260,3 +292,8 @@ jobs: release-assets/**/*.dmg release-assets/**/*.rpm release-assets/**/*bitfun-installer.exe + linux-release-assets/bitfun-cli-*.tar.gz + linux-release-assets/bitfun-cli-*.tar.gz.sha256 + linux-release-assets/bitfun-relay-server-*.tar.gz + linux-release-assets/bitfun-relay-server-*.tar.gz.sha256 + linux-release-assets/linux-binaries.json diff --git a/release-please-config.json b/release-please-config.json index 22eb86173..09ae628e6 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -21,6 +21,10 @@ { "type": "generic", "path": "Cargo.toml" + }, + { + "type": "generic", + "path": "src/apps/relay-server/Cargo.toml" } ] } diff --git a/scripts/generate-linux-binaries-manifest.mjs b/scripts/generate-linux-binaries-manifest.mjs new file mode 100644 index 000000000..ca2a51ef1 --- /dev/null +++ b/scripts/generate-linux-binaries-manifest.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +function option(name) { + const index = process.argv.indexOf(name); + if (index < 0 || !process.argv[index + 1]) { + throw new Error(`Missing required option: ${name}`); + } + return process.argv[index + 1]; +} + +const assetsDir = path.resolve(option('--assets-dir')); +const version = option('--version'); +const tag = option('--tag'); +const repo = option('--repo'); +const out = path.resolve(option('--out')); +const releaseBase = `https://github.com/${repo}/releases/download/${tag}`; + +const platforms = [ + { + key: 'linux-x86_64', + target: 'x86_64-unknown-linux-gnu', + }, + { + key: 'linux-aarch64', + target: 'aarch64-unknown-linux-gnu', + }, +]; + +// GitHub rewrites characters outside this set when it stores a release asset +// (`+` becomes `.`, spaces become `.`), which would silently turn every URL in +// this manifest into a 404. Fail at generation time instead. +const GITHUB_SAFE_ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +function asset(filename) { + if (!GITHUB_SAFE_ASSET_NAME.test(filename)) { + throw new Error( + `Release asset name is not preserved verbatim by GitHub: ${filename}. ` + + 'Use only alphanumerics, dot, dash and underscore (strip SemVer build metadata).' + ); + } + const absolutePath = path.join(assetsDir, filename); + if (!fs.existsSync(absolutePath)) { + throw new Error(`Required Linux release asset was not found: ${absolutePath}`); + } + const checksum = `${filename}.sha256`; + const checksumPath = path.join(assetsDir, checksum); + if (!fs.existsSync(checksumPath)) { + throw new Error(`Required Linux checksum was not found: ${checksumPath}`); + } + return { + filename, + url: `${releaseBase}/${filename}`, + sha256Url: `${releaseBase}/${checksum}`, + }; +} + +const manifest = { + schemaVersion: 1, + version, + tag, + platforms: Object.fromEntries( + platforms.map(({ key, target }) => [ + key, + { + target, + cli: asset(`bitfun-cli-${version}-${target}.tar.gz`), + relay: asset(`bitfun-relay-server-${target}.tar.gz`), + }, + ]) + ), +}; + +fs.mkdirSync(path.dirname(out), { recursive: true }); +fs.writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +console.log(`Generated Linux binaries manifest: ${out}`); diff --git a/scripts/linux-binaries-manifest.test.mjs b/scripts/linux-binaries-manifest.test.mjs new file mode 100644 index 000000000..254b6864f --- /dev/null +++ b/scripts/linux-binaries-manifest.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const repoRoot = path.resolve(import.meta.dirname, '..'); + +test('generates GitHub URLs for both Linux CLI and Relay architectures', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-linux-manifest-')); + const assets = path.join(temp, 'assets'); + const out = path.join(temp, 'linux-binaries.json'); + fs.mkdirSync(assets); + + for (const target of [ + 'x86_64-unknown-linux-gnu', + 'aarch64-unknown-linux-gnu', + ]) { + for (const filename of [ + `bitfun-cli-1.2.3-${target}.tar.gz`, + `bitfun-relay-server-${target}.tar.gz`, + ]) { + fs.writeFileSync(path.join(assets, filename), ''); + fs.writeFileSync(path.join(assets, `${filename}.sha256`), ''); + } + } + + const result = spawnSync( + process.execPath, + [ + 'scripts/generate-linux-binaries-manifest.mjs', + '--assets-dir', + assets, + '--version', + '1.2.3', + '--tag', + 'v1.2.3', + '--repo', + 'GCWing/BitFun', + '--out', + out, + ], + { cwd: repoRoot, encoding: 'utf8' } + ); + assert.equal(result.status, 0, result.stderr); + + const manifest = JSON.parse(fs.readFileSync(out, 'utf8')); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.platforms.linux_x86_64, undefined); + assert.match( + manifest.platforms['linux-x86_64'].cli.url, + /releases\/download\/v1\.2\.3\/bitfun-cli-1\.2\.3-x86_64/ + ); + assert.match( + manifest.platforms['linux-aarch64'].relay.sha256Url, + /bitfun-relay-server-aarch64-unknown-linux-gnu\.tar\.gz\.sha256$/ + ); +}); + +test('rejects versions whose build metadata GitHub would rewrite in asset names', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-linux-manifest-meta-')); + const assets = path.join(temp, 'assets'); + const out = path.join(temp, 'linux-binaries.json'); + fs.mkdirSync(assets); + + const version = '1.2.3-nightly.20260724+abc1234'; + for (const target of ['x86_64-unknown-linux-gnu', 'aarch64-unknown-linux-gnu']) { + for (const filename of [ + `bitfun-cli-${version}-${target}.tar.gz`, + `bitfun-relay-server-${target}.tar.gz`, + ]) { + fs.writeFileSync(path.join(assets, filename), ''); + fs.writeFileSync(path.join(assets, `${filename}.sha256`), ''); + } + } + + const result = spawnSync( + process.execPath, + [ + 'scripts/generate-linux-binaries-manifest.mjs', + '--assets-dir', + assets, + '--version', + version, + '--tag', + 'nightly', + '--repo', + 'GCWing/BitFun', + '--out', + out, + ], + { cwd: repoRoot, encoding: 'utf8' } + ); + assert.notEqual(result.status, 0, 'build metadata must not reach a release asset name'); + assert.match(result.stderr, /not preserved verbatim by GitHub/); + assert.equal(fs.existsSync(out), false); +}); + +test('openbitfun sync mirrors both products and their checksums', () => { + const syncScript = fs.readFileSync( + path.join(repoRoot, 'scripts/openbitfun-release-sync.sh'), + 'utf8' + ); + + assert.match(syncScript, /linux-binaries\.json/); + assert.match(syncScript, /for product in \("cli", "relay"\)/); + assert.match(syncScript, /for key in \("url", "sha256Url"\)/); + assert.match(syncScript, /OPENBITFUN_BASE_URL/); + assert.match(syncScript, /WEBSITE_RELEASE_DIR.*linux-binaries\.json/); +}); diff --git a/scripts/openbitfun-release-sync.sh b/scripts/openbitfun-release-sync.sh index 8a932f734..23c266bb7 100755 --- a/scripts/openbitfun-release-sync.sh +++ b/scripts/openbitfun-release-sync.sh @@ -4,25 +4,28 @@ # # Flow: # 1. Fetch latest.json from GitHub (follows /releases/latest/download/ redirect) -# 2. Download every platform installer package into release/{version}/ -# 3. Rewrite download URLs in latest.json to point at openbitfun.com -# 4. Publish release/{version}/latest.json and release/latest.json -# 5. Remove old version dirs, keeping only the two most recent +# 2. Download every Desktop updater package into release/{version}/ +# 3. If present, download linux-binaries.json plus its CLI/Relay assets +# 4. Rewrite all mirrored URLs to point at openbitfun.com +# 5. Publish versioned and root manifests +# 6. Remove old version dirs, keeping only the two most recent # # The published release/latest.json is the Tauri updater fallback endpoint. # When GitHub is unreachable, the desktop client automatically falls through # to https://openbitfun.com/release/latest.json and downloads from this mirror. # -# Cron (every 12 hours): -# 0 */12 * * * /root/lwb/repo/BitFun-AutoUpdate/sync-release.sh \ -# >> /root/lwb/repo/BitFun-AutoUpdate/sync.log 2>&1 +# Cron (every 10 minutes): +# */10 * * * * /root/repos/BitFun-AutoUpdate/openbitfun-release-sync.sh \ +# >> /root/repos/BitFun-AutoUpdate/sync.log 2>&1 # set -euo pipefail # ── Configuration ────────────────────────────────────────────── GITHUB_LATEST_JSON_URL="https://github.com/GCWing/BitFun/releases/latest/download/latest.json" +GITHUB_LINUX_BINARIES_URL="https://github.com/GCWing/BitFun/releases/latest/download/linux-binaries.json" OPENBITFUN_BASE_URL="https://openbitfun.com/release" -WEBSITE_RELEASE_DIR="/root/lwb/repo/BitFun-Website/dist/release" +WEBSITE_RELEASE_DIR="/root/repos/BitFun-Website/dist/release" +LOCK_FILE="/root/repos/BitFun-AutoUpdate/sync.lock" KEEP_VERSIONS=2 CONNECT_TIMEOUT=30 MAX_TIME=1800 # per-request ceiling (30 min; installer packages can be large) @@ -33,8 +36,76 @@ PYTHON="${PYTHON:-python3}" # ── Helpers ──────────────────────────────────────────────────── 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 +} + +# Fetch linux-binaries.json into $LINUX_MANIFEST_TMP, retrying transient +# failures. Sets LINUX_MANIFEST_STATE to one of: +# ok — downloaded +# missing — GitHub answered 404: the release genuinely has no manifest +# unhealthy — network/5xx: unknown, so callers must keep the published mirror +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 +} + # ── Main ─────────────────────────────────────────────────────── 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 sync started ===" mkdir -p "$WEBSITE_RELEASE_DIR" @@ -77,32 +148,8 @@ for p, info in data.get('platforms', {}).items(): while IFS=$'\t' read -r url filename; do [ -z "$url" ] && continue - dest="${VERSION_DIR}/${filename}" - - if [ -f "$dest" ]; then - log " Already exists: $filename" - continue - fi - - log " Downloading: $filename" - ok=0 - for attempt in $(seq 1 "$MAX_RETRIES"); do - if curl -fsSL \ - --connect-timeout "$CONNECT_TIMEOUT" \ - --max-time "$MAX_TIME" \ - -o "$dest" "$url"; then - ok=1 - break - fi - log " Retry $attempt/$MAX_RETRIES for $filename" - sleep "$RETRY_DELAY" - done - - if [ "$ok" -ne 1 ]; then - log "ERROR: Failed to download $filename after $MAX_RETRIES attempts" - rm -f "$dest" - exit 1 - fi + log " Mirroring Desktop asset: $filename" + download_asset "$url" "${VERSION_DIR}/${filename}" || exit 1 done <<< "$ASSET_LIST" # 5. Rewrite URLs in latest.json to point at openbitfun.com @@ -122,7 +169,75 @@ print(json.dumps(data, indent=2)) cp "${VERSION_DIR}/latest.json" "${WEBSITE_RELEASE_DIR}/latest.json" log "Updated ${WEBSITE_RELEASE_DIR}/latest.json" - # 7. Clean up old versions — keep only the latest KEEP_VERSIONS dirs + # 7. Mirror the CLI + Relay release manifest and every asset it references. + 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"): + 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" + + "$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"): + 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" + cp "${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 + # Transient failure. Keep whatever is already published: CLI self-update and + # one-click Relay deploy both fall back to this file, so a single flaky run + # must not take it offline for the next 10 minutes. + log "WARN: Linux binaries manifest unreachable this run; keeping the published mirror." + fi + + # 8. Clean up old versions — keep only the latest KEEP_VERSIONS dirs ALL_DIRS=() while IFS= read -r d; do ALL_DIRS+=("$d") diff --git a/scripts/relay/package-contract.test.mjs b/scripts/relay/package-contract.test.mjs new file mode 100644 index 000000000..e60e9148c --- /dev/null +++ b/scripts/relay/package-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (relativePath) => + fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); + +test('relay archive contains the runtime and admin binaries plus static assets', () => { + const packageScript = read('scripts/relay/package-unix.sh'); + assert.match(packageScript, /bitfun-relay-server/); + assert.match(packageScript, /relay-admin/); + assert.match(packageScript, /src\/apps\/relay-server\/static/); + assert.match(packageScript, /\/health/); + assert.match(packageScript, /\.sha256/); +}); + +test('formal and nightly releases gate publication on Linux binaries', () => { + const desktop = read('.github/workflows/desktop-package.yml'); + const nightly = read('.github/workflows/nightly.yml'); + const reusable = read('.github/workflows/linux-binaries.yml'); + + for (const workflow of [desktop, nightly]) { + assert.match(workflow, /uses:\s+\.\/\.github\/workflows\/linux-binaries\.yml/); + assert.match(workflow, /needs:\s*\[[^\]]*linux-binaries[^\]]*\]/); + assert.match(workflow, /bitfun-relay-server-\*\.tar\.gz/); + assert.match(workflow, /bitfun-cli-\*\.tar\.gz/); + assert.match(workflow, /linux-binaries\.json/); + } + + assert.match(reusable, /ubuntu-24\.04-arm/); + assert.match(reusable, /aarch64-unknown-linux-gnu/); + assert.match(reusable, /x86_64-unknown-linux-gnu/); + assert.match(reusable, /scripts\/relay\/package-unix\.sh/); + assert.match(reusable, /scripts\/cli\/package-unix\.sh/); +}); + +test('exactly one workflow publishes the Linux CLI archives', () => { + // Both cli-package.yml and desktop-package.yml run on `release: published`. + // If both built Linux they would upload identical asset names concurrently, + // and softprops/action-gh-release deletes a same-named asset before writing, + // so the two runs can destroy each other's upload. + const cli = read('.github/workflows/cli-package.yml'); + + assert.doesNotMatch(cli, /target:\s*x86_64-unknown-linux-gnu/); + assert.doesNotMatch(cli, /target:\s*aarch64-unknown-linux-gnu/); + assert.doesNotMatch(cli, /uses:\s+\.\/\.github\/workflows\/linux-binaries\.yml/); + + // macOS and Windows stay owned by cli-package.yml. + assert.match(cli, /target:\s*aarch64-apple-darwin/); + assert.match(cli, /target:\s*x86_64-apple-darwin/); + assert.match(cli, /target:\s*x86_64-pc-windows-msvc/); +}); + +test('release asset names carry no SemVer build metadata', () => { + // GitHub rewrites `+` in stored asset filenames, which would make every URL + // in linux-binaries.json a 404 on the nightly channel. + const reusable = read('.github/workflows/linux-binaries.yml'); + const nightly = read('.github/workflows/nightly.yml'); + + assert.match(reusable, /ASSET_VERSION="\$\{RELEASE_VERSION%%\+\*\}"/); + assert.match(reusable, /package-unix\.sh "\$ASSET_VERSION"/); + assert.doesNotMatch(reusable, /package-unix\.sh "\$VERSION"/); + assert.match(nightly, /--version "\$\{NIGHTLY_VERSION%%\+\*\}"/); +}); diff --git a/scripts/relay/package-unix.sh b/scripts/relay/package-unix.sh new file mode 100755 index 000000000..f64788918 --- /dev/null +++ b/scripts/relay/package-unix.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$#" -gt 4 ]; then + echo "Usage: package-unix.sh [release-dir] [output-dir]" >&2 + exit 2 +fi + +VERSION="$1" +TARGET="$2" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +RELEASE_DIR="${3:-${REPO_ROOT}/target/${TARGET}/release}" +OUTPUT_DIR="${4:-${REPO_ROOT}}" +SERVER="${RELEASE_DIR}/bitfun-relay-server" +ADMIN="${RELEASE_DIR}/relay-admin" +STAGE_NAME="bitfun-relay-server-${VERSION}-${TARGET}" +STAGE_DIR="${OUTPUT_DIR}/dist-relay/${STAGE_NAME}" +# Keep the release asset name stable so Desktop can use GitHub's +# releases//download URL without querying the GitHub API for a versioned name. +ARCHIVE_NAME="bitfun-relay-server-${TARGET}.tar.gz" +ARCHIVE="${OUTPUT_DIR}/${ARCHIVE_NAME}" + +smoke_server() { + local executable="$1" + local temp_dir port pid log_contents result + temp_dir="$(mktemp -d)" + port=19700 + mkdir -p "$temp_dir/room-web" + RELAY_PORT="$port" \ + RELAY_DB_PATH="$temp_dir/relay.db" \ + RELAY_ROOM_WEB_DIR="$temp_dir/room-web" \ + "$executable" >"$temp_dir/server.log" 2>&1 & + pid=$! + result=1 + local attempt + for attempt in $(seq 1 30); do + if curl -fsS --max-time 2 "http://127.0.0.1:${port}/health" \ + 2>/dev/null | grep -F "\"version\":\"${VERSION%%+*}\"" >/dev/null; then + result=0 + break + fi + if ! kill -0 "$pid" >/dev/null 2>&1; then + echo "Error: relay server exited during smoke test" >&2 + break + fi + sleep 1 + done + + log_contents="$(cat "$temp_dir/server.log")" + kill "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + rm -rf "$temp_dir" + if [ "$result" -ne 0 ]; then + echo "Error: relay server health check failed" >&2 + printf '%s\n' "$log_contents" >&2 + fi + return "$result" +} + +"$ADMIN" --help >/dev/null +smoke_server "$SERVER" + +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR" +cp "$SERVER" "$ADMIN" "$STAGE_DIR/" +cp "${REPO_ROOT}/LICENSE" "$STAGE_DIR/" 2>/dev/null || true +cp "${REPO_ROOT}/src/apps/relay-server/README.md" "$STAGE_DIR/README.md" +cp "${REPO_ROOT}/README.md" "$STAGE_DIR/PROJECT-README.md" +cp -R "${REPO_ROOT}/src/apps/relay-server/static" "$STAGE_DIR/static" + +tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" + +if command -v sha256sum >/dev/null 2>&1; then + ARCHIVE_HASH="$(sha256sum "$ARCHIVE" | awk '{print $1}')" + printf '%s %s\n' "$ARCHIVE_HASH" "$ARCHIVE_NAME" >"${ARCHIVE}.sha256" + (cd "$OUTPUT_DIR" && sha256sum -c "${ARCHIVE_NAME}.sha256") +else + ARCHIVE_HASH="$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')" + printf '%s %s\n' "$ARCHIVE_HASH" "$ARCHIVE_NAME" >"${ARCHIVE}.sha256" + (cd "$OUTPUT_DIR" && shasum -a 256 -c "${ARCHIVE_NAME}.sha256") +fi + +EXTRACT_DIR="$(mktemp -d)" +trap 'rm -rf "$EXTRACT_DIR"' EXIT +tar -xzf "$ARCHIVE" -C "$EXTRACT_DIR" + +EXTRACTED="$EXTRACT_DIR/$STAGE_NAME" +[ -x "$EXTRACTED/bitfun-relay-server" ] +[ -x "$EXTRACTED/relay-admin" ] +[ -f "$EXTRACTED/static/index.html" ] +"$EXTRACTED/relay-admin" --help >/dev/null +smoke_server "$EXTRACTED/bitfun-relay-server" + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "archive=$ARCHIVE_NAME" >>"$GITHUB_OUTPUT" + echo "checksum=${ARCHIVE_NAME}.sha256" >>"$GITHUB_OUTPUT" +fi + +echo "Packaged and verified: $ARCHIVE" diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index dc766bdcc..91b3f2766 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -77,10 +77,13 @@ serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +flate2 = { workspace = true } +reqwest = { workspace = true } +sha2 = { workspace = true } +tar = { workspace = true } +tempfile = "3" [dev-dependencies] -tempfile = "3" -sha2 = { workspace = true } hex = { workspace = true } [target.'cfg(windows)'.dependencies] diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index a2f3ee49c..396480d78 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -20,8 +20,17 @@ bitfun sessions list bitfun usage bitfun doctor bitfun health +bitfun update # GitHub first, openbitfun.com fallback +bitfun update --check # report only; do not install ``` +Official Linux archive installations check for updates before interactive TUI +startup at most once every six hours. The check uses GitHub first, then +`https://openbitfun.com/release/linux-binaries.json` when GitHub or its asset +download fails. Set `behavior.auto_update = false` in the CLI config or export +`BITFUN_CLI_DISABLE_AUTO_UPDATE=1` to disable automatic checks. Development and +nightly binaries are never replaced by the stable automatic updater. + `bitfun-cli` is a deprecated compatibility entrypoint. It writes `Warning: \`bitfun-cli\` is deprecated; use \`bitfun\` instead.` to stderr; new scripts and integrations must use `bitfun`. Official installers and archives ship both commands as a pair; a @@ -173,6 +182,14 @@ Do not copy only `bitfun-cli` from an archive. The deprecated command is intenti launcher for its sibling `bitfun`; if the sibling is missing, it reports an incomplete installation with recovery guidance instead of attempting another lookup. +Every archive ships an adjacent `.sha256` — the complete verification surface across all +platforms and both the stable and nightly channels. The release also carries a `SHA256SUMS` +aggregate, but it covers only the macOS and Windows archives: Linux archives are published by +`.github/workflows/linux-binaries.yml` in a separate workflow run (the only producer that also +covers nightly, and the one that must hold the CLI and Relay archives together to emit +`linux-binaries.json`), so they cannot be folded into that file deterministically. Verify Linux +downloads with their `.sha256` sidecar. + On a server that should stay reachable for account multi-device access, continue with the [daemon section](#always-on-account-device-host-daemon) above after `/login`. diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index b81685217..fee710993 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -47,6 +47,8 @@ pub(crate) struct BehaviorConfig { pub confirm_dangerous: bool, /// Default Agent pub default_agent: String, + /// Check the official Linux release and fallback mirror for CLI updates. + pub auto_update: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -90,6 +92,7 @@ impl Default for BehaviorConfig { auto_save: true, confirm_dangerous: true, default_agent: "agentic".to_string(), + auto_update: true, } } } @@ -203,6 +206,7 @@ mod tests { assert!(config.behavior.auto_save); assert!(config.behavior.confirm_dangerous); assert_eq!(config.behavior.default_agent, "agentic"); + assert!(config.behavior.auto_update); assert_eq!(config.workspace.default_path, "."); assert_eq!( config.workspace.exclude_patterns, diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 628a5e991..88985d7ae 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -24,6 +24,7 @@ mod product_assembly; mod prompts; mod root_handlers; mod runtime; +mod self_update; mod ui; use anyhow::{anyhow, Result}; @@ -200,6 +201,13 @@ enum Commands { /// Health check Health, + /// Check for and install the latest official Linux CLI release + Update { + /// Only report whether a newer version exists + #[arg(long)] + check: bool, + }, + /// Manage the always-on account device host daemon /// /// The daemon holds the relay device-routing connection in a headless @@ -838,6 +846,9 @@ async fn run_cli() -> Result<()> { } CliConfig::default() }); + if is_tui_mode && config.behavior.auto_update { + self_update::maybe_run_automatic().await; + } match cli.command { Some(Commands::Chat { agent }) => { @@ -982,6 +993,10 @@ async fn run_cli() -> Result<()> { root_handlers::handle_health_command()?; } + Some(Commands::Update { check }) => { + self_update::run_manual(check).await?; + } + Some(Commands::Daemon { action }) => match action { DaemonAction::Run => daemon::run_daemon().await?, DaemonAction::Install => daemon::install_service()?, diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs new file mode 100644 index 000000000..ae4f0dff0 --- /dev/null +++ b/src/apps/cli/src/self_update.rs @@ -0,0 +1,463 @@ +use anyhow::{anyhow, Context, Result}; +use flate2::read::GzDecoder; +use reqwest::Client; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, SystemTime}; +use tar::Archive; + +const GITHUB_MANIFEST: &str = + "https://github.com/GCWing/BitFun/releases/latest/download/linux-binaries.json"; +const OPENBITFUN_MANIFEST: &str = "https://openbitfun.com/release/linux-binaries.json"; +const AUTO_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +/// Hard ceiling on how long an automatic check may delay interactive startup. +const AUTO_UPDATE_BUDGET: Duration = Duration::from_secs(90); +const DEPRECATION_WARNING: &str = "Warning: `bitfun-cli` is deprecated; use `bitfun` instead."; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinuxBinariesManifest { + schema_version: u32, + version: String, + platforms: std::collections::HashMap, +} + +#[derive(Debug, Deserialize)] +struct LinuxPlatform { + target: String, + cli: ReleaseAsset, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReleaseAsset { + filename: String, + url: String, + sha256_url: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateOutcome { + Current, + /// `--check` found a newer release but was asked not to install it. + Available, + Updated, + Unsupported, +} + +pub(crate) async fn run_manual(check_only: bool) -> Result { + let outcome = update_from_configured_sources(check_only).await?; + match outcome { + UpdateOutcome::Current => { + println!("BitFun CLI is up to date ({}).", env!("CARGO_PKG_VERSION")) + } + // `try_source` already printed the available version and its source. + UpdateOutcome::Available => println!("Run `bitfun update` to install it."), + UpdateOutcome::Updated => println!( + "BitFun CLI was updated successfully. Restart this command to use the new version." + ), + UpdateOutcome::Unsupported => println!( + "BitFun CLI self-update supports official Linux x86_64/ARM64 archive installations." + ), + } + Ok(outcome) +} + +pub(crate) async fn maybe_run_automatic() { + if !automatic_update_is_eligible() || !automatic_check_is_due() { + return; + } + mark_automatic_check(); + // Interactive startup must never wait on the network longer than this, no + // matter how slowly a mirror trickles the archive out. + match tokio::time::timeout(AUTO_UPDATE_BUDGET, update_from_configured_sources(false)).await { + Ok(Ok(UpdateOutcome::Updated)) => eprintln!( + "BitFun CLI updated in the background. The new version will be used next time." + ), + Ok(Ok(_)) => {} + Ok(Err(error)) => tracing::debug!("Automatic CLI update check failed: {error}"), + Err(_) => tracing::debug!( + "Automatic CLI update check exceeded {}s; continuing startup.", + AUTO_UPDATE_BUDGET.as_secs() + ), + } +} + +async fn update_from_configured_sources(check_only: bool) -> Result { + let Some(platform_key) = current_platform_key() else { + return Ok(UpdateOutcome::Unsupported); + }; + let current_exe = std::env::current_exe().context("resolve current BitFun CLI executable")?; + if is_development_binary(¤t_exe) { + return Ok(UpdateOutcome::Unsupported); + } + + let client = Client::builder() + .connect_timeout(Duration::from_secs(8)) + .timeout(Duration::from_secs(120)) + .build() + .context("build CLI updater HTTP client")?; + let mut errors = Vec::new(); + + for (source, manifest_url) in [ + ("GitHub", GITHUB_MANIFEST), + ("openbitfun.com", OPENBITFUN_MANIFEST), + ] { + match try_source( + &client, + source, + manifest_url, + platform_key, + ¤t_exe, + check_only, + ) + .await + { + Ok(Some(outcome)) => return Ok(outcome), + Ok(None) => {} + Err(error) => errors.push(format!("{source}: {error:#}")), + } + } + + Err(anyhow!( + "CLI update failed from both configured sources: {}", + errors.join("; ") + )) +} + +async fn try_source( + client: &Client, + source: &str, + manifest_url: &str, + platform_key: &str, + current_exe: &Path, + check_only: bool, +) -> Result> { + let manifest = client + .get(manifest_url) + .send() + .await + .with_context(|| format!("request {manifest_url}"))? + .error_for_status() + .with_context(|| format!("fetch {manifest_url}"))? + .json::() + .await + .with_context(|| format!("parse {manifest_url}"))?; + if manifest.schema_version != 1 { + return Err(anyhow!( + "unsupported Linux binaries manifest schema {}", + manifest.schema_version + )); + } + if !is_newer_version(&manifest.version, env!("CARGO_PKG_VERSION")) { + return Ok(Some(UpdateOutcome::Current)); + } + if check_only { + println!( + "BitFun CLI {} is available from {} (current {}).", + manifest.version, + source, + env!("CARGO_PKG_VERSION") + ); + return Ok(Some(UpdateOutcome::Available)); + } + + let platform = manifest + .platforms + .get(platform_key) + .ok_or_else(|| anyhow!("manifest does not contain {platform_key}"))?; + let expected_target = match platform_key { + "linux-x86_64" => "x86_64-unknown-linux-gnu", + "linux-aarch64" => "aarch64-unknown-linux-gnu", + _ => return Err(anyhow!("unsupported updater platform {platform_key}")), + }; + if platform.target != expected_target { + return Err(anyhow!( + "manifest target {} does not match {}", + platform.target, + expected_target + )); + } + if !platform.cli.filename.ends_with(".tar.gz") { + return Err(anyhow!("CLI release asset is not a tar.gz archive")); + } + + let archive = download_bytes(client, &platform.cli.url).await?; + let checksum_text = download_text(client, &platform.cli.sha256_url).await?; + verify_sha256(&archive, &checksum_text, &platform.cli.filename)?; + install_archive(&archive, current_exe)?; + restart_managed_daemon(); + println!("Updated from {source}: {}", manifest.version); + Ok(Some(UpdateOutcome::Updated)) +} + +async fn download_bytes(client: &Client, url: &str) -> Result> { + Ok(client + .get(url) + .send() + .await + .with_context(|| format!("request {url}"))? + .error_for_status() + .with_context(|| format!("download {url}"))? + .bytes() + .await + .with_context(|| format!("read {url}"))? + .to_vec()) +} + +async fn download_text(client: &Client, url: &str) -> Result { + client + .get(url) + .send() + .await + .with_context(|| format!("request {url}"))? + .error_for_status() + .with_context(|| format!("download {url}"))? + .text() + .await + .with_context(|| format!("read {url}")) +} + +fn verify_sha256(archive: &[u8], checksum_text: &str, filename: &str) -> Result<()> { + let expected = checksum_text + .split_whitespace() + .next() + .filter(|value| value.len() == 64) + .ok_or_else(|| anyhow!("invalid SHA256 file for {filename}"))?; + let actual = format!("{:x}", Sha256::digest(archive)); + if !actual.eq_ignore_ascii_case(expected) { + return Err(anyhow!("SHA256 mismatch for {filename}")); + } + Ok(()) +} + +#[cfg(unix)] +fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let install_dir = current_exe + .parent() + .ok_or_else(|| anyhow!("current executable has no parent directory"))?; + if current_exe.file_name().and_then(|name| name.to_str()) != Some("bitfun") { + return Err(anyhow!( + "self-update requires the official executable name `bitfun`" + )); + } + let legacy_target = install_dir.join("bitfun-cli"); + if !legacy_target.is_file() { + return Err(anyhow!( + "official bitfun-cli companion was not found beside {}", + current_exe.display() + )); + } + + let extract_dir = tempfile::tempdir().context("create CLI update extraction directory")?; + Archive::new(GzDecoder::new(Cursor::new(archive))) + .unpack(extract_dir.path()) + .context("extract CLI update archive")?; + let package_dir = find_package_dir(extract_dir.path())?; + let new_primary = package_dir.join("bitfun"); + let new_legacy = package_dir.join("bitfun-cli"); + validate_entrypoint_pair(&new_primary, &new_legacy)?; + + let stage = tempfile::Builder::new() + .prefix(".bitfun-update.") + .tempdir_in(install_dir) + .with_context(|| { + format!( + "create update staging directory in {}", + install_dir.display() + ) + })?; + let staged_primary = stage.path().join("bitfun"); + let staged_legacy = stage.path().join("bitfun-cli"); + fs::copy(&new_primary, &staged_primary).context("stage bitfun")?; + fs::copy(&new_legacy, &staged_legacy).context("stage bitfun-cli")?; + fs::set_permissions(&staged_primary, fs::Permissions::from_mode(0o755))?; + fs::set_permissions(&staged_legacy, fs::Permissions::from_mode(0o755))?; + validate_entrypoint_pair(&staged_primary, &staged_legacy)?; + + let primary_backup = stage.path().join("previous-bitfun"); + let legacy_backup = stage.path().join("previous-bitfun-cli"); + fs::rename(current_exe, &primary_backup).context("back up current bitfun")?; + if let Err(error) = fs::rename(&legacy_target, &legacy_backup) { + let _ = fs::rename(&primary_backup, current_exe); + return Err(error).context("back up current bitfun-cli"); + } + if let Err(error) = fs::rename(&staged_primary, current_exe) { + let _ = fs::rename(&legacy_backup, &legacy_target); + let _ = fs::rename(&primary_backup, current_exe); + return Err(error).context("install updated bitfun"); + } + if let Err(error) = fs::rename(&staged_legacy, &legacy_target) { + let _ = fs::remove_file(current_exe); + let _ = fs::rename(&legacy_backup, &legacy_target); + let _ = fs::rename(&primary_backup, current_exe); + return Err(error).context("install updated bitfun-cli"); + } + if let Err(error) = validate_entrypoint_pair(current_exe, &legacy_target) { + let _ = fs::remove_file(current_exe); + let _ = fs::remove_file(&legacy_target); + let _ = fs::rename(&legacy_backup, &legacy_target); + let _ = fs::rename(&primary_backup, current_exe); + return Err(error).context("validate installed CLI update"); + } + Ok(()) +} + +#[cfg(not(unix))] +fn install_archive(_archive: &[u8], _current_exe: &Path) -> Result<()> { + Err(anyhow!("CLI self-update is only available on Linux")) +} + +fn find_package_dir(root: &Path) -> Result { + for entry in fs::read_dir(root).context("inspect CLI update archive")? { + let path = entry?.path(); + if path.is_dir() && path.join("bitfun").is_file() && path.join("bitfun-cli").is_file() { + return Ok(path); + } + } + Err(anyhow!( + "CLI update archive does not contain the official entrypoint pair" + )) +} + +fn validate_entrypoint_pair(primary: &Path, legacy: &Path) -> Result<()> { + let primary_status = Command::new(primary) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("run {}", primary.display()))?; + if !primary_status.success() { + return Err(anyhow!("{} --version failed", primary.display())); + } + let legacy_output = Command::new(legacy) + .arg("--version") + .stdout(Stdio::null()) + .output() + .with_context(|| format!("run {}", legacy.display()))?; + if !legacy_output.status.success() + || String::from_utf8_lossy(&legacy_output.stderr).trim() != DEPRECATION_WARNING + { + return Err(anyhow!("deprecated bitfun-cli entrypoint contract failed")); + } + Ok(()) +} + +fn current_platform_key() -> Option<&'static str> { + if !cfg!(target_os = "linux") { + return None; + } + match std::env::consts::ARCH { + "x86_64" => Some("linux-x86_64"), + "aarch64" => Some("linux-aarch64"), + _ => None, + } +} + +fn is_development_binary(executable: &Path) -> bool { + executable + .components() + .any(|component| component.as_os_str() == "target") +} + +fn is_newer_version(candidate: &str, current: &str) -> bool { + fn core(version: &str) -> Option<(u64, u64, u64)> { + let mut parts = version.split(['-', '+']).next()?.split('.'); + Some(( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + )) + } + matches!((core(candidate), core(current)), (Some(next), Some(now)) if next > now) +} + +fn automatic_update_is_eligible() -> bool { + if std::env::var_os("BITFUN_CLI_DISABLE_AUTO_UPDATE").is_some() + || env!("CARGO_PKG_VERSION").contains("-nightly.") + { + return false; + } + std::env::current_exe() + .ok() + .is_some_and(|path| current_platform_key().is_some() && !is_development_binary(&path)) +} + +/// Share the CLI's own config directory so a relocated profile (E2E storage +/// guard, non-default home) does not silently re-check on every launch. +fn automatic_stamp_path() -> Option { + crate::config::CliConfig::config_dir() + .ok() + .map(|dir| dir.join("last-update-check")) +} + +fn automatic_check_is_due() -> bool { + let Some(path) = automatic_stamp_path() else { + return false; + }; + let Ok(modified) = fs::metadata(path).and_then(|metadata| metadata.modified()) else { + return true; + }; + SystemTime::now() + .duration_since(modified) + .map_or(true, |elapsed| elapsed >= AUTO_CHECK_INTERVAL) +} + +fn mark_automatic_check() { + let Some(path) = automatic_stamp_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(path, env!("CARGO_PKG_VERSION")); +} + +fn restart_managed_daemon() { + let Some(home) = dirs::home_dir() else { + return; + }; + let unit = home.join(".config/systemd/user/bitfun-cli-daemon.service"); + if unit.is_file() { + let _ = Command::new("systemctl") + .args(["--user", "try-restart", "bitfun-cli-daemon.service"]) + .status(); + } +} + +#[cfg(test)] +mod tests { + use super::{is_newer_version, verify_sha256}; + use sha2::{Digest, Sha256}; + + #[test] + fn version_comparison_ignores_release_metadata() { + assert!(is_newer_version("0.2.14", "0.2.13")); + assert!(!is_newer_version("0.2.13", "0.2.13-nightly.1+abc")); + assert!(!is_newer_version("0.2.12", "0.2.13")); + } + + #[test] + fn checksum_contract_accepts_standard_sha_file() { + let data = b"bitfun"; + let digest = format!("{:x}", Sha256::digest(data)); + verify_sha256( + data, + &format!("{digest} archive.tar.gz\n"), + "archive.tar.gz", + ) + .unwrap(); + assert!(verify_sha256( + data, + &format!("{} archive.tar.gz", "0".repeat(64)), + "archive.tar.gz" + ) + .is_err()); + } +} diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index e4268d778..f24421304 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-relay-server" -version = "0.2.13" +version = "0.2.13" # x-release-please-version authors = ["BitFun Team"] edition = "2021" description = "BitFun standalone Remote Connect relay server" diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 09444be54..c3564ba4a 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -95,15 +95,22 @@ Use this checklist on a machine you control (VPS, LAN server, or localhost). ### Desktop one-click deploy (preferred for end users) BitFun Desktop can SSH to your host and run the same Docker path without a -manual clone. Entry points: Account Login → “一键部署到自己的服务器”, or +manual clone. It first downloads the matching checksum-verified GitHub Release +archive for Linux amd64/arm64, falls back to the versioned openbitfun.com mirror, +and builds only a small runtime image around the published binaries. If both +binary sources, checksum verification, image creation, startup, or health +validation fail, it restores the previous healthy container and automatically +falls back to the source Docker build. Entry points: Account Login → +“一键部署到自己的服务器”, or Remote Connect → Network Relay → Self-Hosted → the same action. - Orchestration: `src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs` - Wizard + invariants: `src/web-ui/src/features/relay-deploy/README.md` -Remote checkout path is always `~/.bitfun/relay-src` (never `$HOME/BitFun`). -Closing the wizard cancels the remote task. Account passwords are provisioned -locally and imported via `relay-admin import-user`. +Release runtime state lives under `~/.bitfun/relay-release`; fallback source +checkout is always `~/.bitfun/relay-src` (never `$HOME/BitFun`). Closing the +wizard cancels the remote task. Account passwords are provisioned locally and +imported via `relay-admin import-user`. ### 1. Deploy the relay (manual / server shell) diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 51feb827f..496bf1d3f 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -1,7 +1,6 @@ //! One-click relay server self-deploy orchestration over an existing SSH connection. //! -//! Drives the open-source relay-server deployment (`src/apps/relay-server/deploy.sh`) -//! on a user-owned server: +//! Drives the open-source relay-server deployment on a user-owned server: //! //! 1. `run_preflight` — probe OS/arch, Docker access mode, memory, port, existing installs. //! 2. `start_task` — stage an interactive driver script (run inside a remote PTY so sudo @@ -12,9 +11,10 @@ //! best-effort compose teardown for in-progress deploys). //! 5. `import_account` — hand a locally-provisioned account to `relay-admin import-user`. //! -//! Remote deploy state lives under `~/.bitfun/relay-deploy/`; the cloned source -//! tree lives under `~/.bitfun/relay-src/` (never `$HOME/bitfun`, which may be -//! the user's own project). +//! Remote deploy state lives under `~/.bitfun/relay-deploy/`. Published binaries +//! are staged under `~/.bitfun/relay-release/`; only the automatic fallback clones +//! source under `~/.bitfun/relay-src/` (never `$HOME/bitfun`, which may be the +//! user's own project). //! //! Product / regression invariants (wizard + entry points): //! `src/web-ui/src/features/relay-deploy/README.md`. Do not change clone destination, @@ -49,6 +49,11 @@ const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; const REPO_GIT_BRANCH: &str = "main"; /// Tarball fallback when git is unavailable or clone/fetch fails. const REPO_TARBALL_URL: &str = "https://github.com/GCWing/BitFun/archive/refs/heads/main.tar.gz"; +/// Release asset base. Asset names are stable across tags so the embedded +/// Desktop version can address its matching server build without a GitHub API call. +const RELEASE_DOWNLOAD_BASE: &str = "https://github.com/GCWing/BitFun/releases/download"; +const OPENBITFUN_RELEASE_BASE: &str = "https://openbitfun.com/release"; +const RELEASE_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Canonical China-mirror helper (shared with `src/apps/relay-server/deploy.sh`). /// Embedded so Desktop orchestration can apply mirrors before the git clone. const RELAY_MIRROR_SH: &str = include_str!(concat!( @@ -63,6 +68,14 @@ const SOURCE_DIR: &str = ".bitfun/relay-src"; /// Line printed by task scripts on success; polled to detect completion. const TASK_DONE_MARKER: &str = "RELAY_TASK_DONE"; +fn release_tag_for_version(version: &str) -> String { + if version.contains("-nightly.") { + "nightly".to_string() + } else { + format!("v{}", version.split('+').next().unwrap_or(version)) + } +} + /// Long-running remote operations that run detached and are polled. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1333,15 +1346,222 @@ bitfun_sync_source() {{ ) } +/// Download the matching published Relay archive and build only a small runtime +/// image around it. The existing container name, volumes, ports, and relay-admin +/// path stay identical to the source-compose deployment. +fn release_binary_deploy_bash() -> String { + let release_tag = release_tag_for_version(RELEASE_VERSION); + format!( + r#" +bitfun_try_release_deploy() {{ + local release_dir="$HOME/.bitfun/relay-release" + local target archive upstream_url openbitfun_url proxy_url download_dir extracted context image + case "$(uname -m 2>/dev/null)" in + x86_64|amd64) target="x86_64-unknown-linux-gnu" ;; + aarch64|arm64) target="aarch64-unknown-linux-gnu" ;; + *) + echo ">>> No published Relay binary for architecture $(uname -m); using source build." + return 1 + ;; + esac + + archive="bitfun-relay-server-${{target}}.tar.gz" + upstream_url="{RELEASE_DOWNLOAD_BASE}/{release_tag}/${{archive}}" + openbitfun_url="{OPENBITFUN_RELEASE_BASE}/{release_version}/${{archive}}" + mkdir -p "$release_dir" + chmod 700 "$release_dir" 2>/dev/null || true + download_dir="$(mktemp -d "$release_dir/download.XXXXXX")" + + bitfun_verify_release_archive() {{ + ( + cd "$download_dir" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "${{archive}}.sha256" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -c "${{archive}}.sha256" + else + echo "ERROR: sha256sum or shasum is required to verify the Relay release." >&2 + return 1 + fi + ) + }} + + bitfun_download_release_pair() {{ + local url="$1" + rm -f "$download_dir/$archive" "$download_dir/${{archive}}.sha256" + echo ">>> Downloading published Relay binary: $url" + curl -fsSL --retry 3 --connect-timeout 15 --max-time 900 \ + -o "$download_dir/$archive" "$url" \ + && curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \ + -o "$download_dir/${{archive}}.sha256" "${{url}}.sha256" \ + && bitfun_verify_release_archive + }} + + proxy_url="" + if [ "${{BITFUN_MIRROR_MODE:-global}}" = "cn" ] && [ -n "${{BITFUN_GITHUB_PROXY:-}}" ]; then + proxy_url="${{BITFUN_GITHUB_PROXY%/}}/${{upstream_url}}" + fi + if [ -n "$proxy_url" ] && bitfun_download_release_pair "$proxy_url"; then + : + elif bitfun_download_release_pair "$upstream_url"; then + : + elif bitfun_download_release_pair "$openbitfun_url"; then + : + else + echo ">>> Published Relay binary unavailable from GitHub and openbitfun.com; falling back to source build." + rm -rf "$download_dir" + return 1 + fi + + mkdir -p "$download_dir/extracted" + if ! tar xzf "$download_dir/$archive" -C "$download_dir/extracted"; then + echo ">>> Published Relay archive could not be extracted; falling back to source build." + rm -rf "$download_dir" + return 1 + fi + extracted="$(find "$download_dir/extracted" -mindepth 1 -maxdepth 1 -type d \ + -name 'bitfun-relay-server-*' | head -n 1)" + if [ -z "$extracted" ] \ + || [ ! -x "$extracted/bitfun-relay-server" ] \ + || [ ! -x "$extracted/relay-admin" ] \ + || [ ! -f "$extracted/static/index.html" ]; then + echo ">>> Published Relay archive layout is invalid; falling back to source build." + rm -rf "$download_dir" + return 1 + fi + + context="$release_dir/runtime" + rm -rf "$context.new" + mkdir -p "$context.new" + cp "$extracted/bitfun-relay-server" "$extracted/relay-admin" "$context.new/" + cp -R "$extracted/static" "$context.new/static" + cat >"$context.new/Dockerfile" <<'DOCKERFILE' +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY bitfun-relay-server relay-admin /app/ +COPY static /app/static +RUN chmod 755 /app/bitfun-relay-server /app/relay-admin \ + && mkdir -p /app/data /app/room-web +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ + CMD curl -fsS "http://127.0.0.1:${{RELAY_PORT:-9700}}/health" || exit 1 +CMD ["/app/bitfun-relay-server"] +DOCKERFILE + rm -rf "$context" + mv "$context.new" "$context" + rm -rf "$download_dir" + + image="bitfun-relay:release-{release_tag}" + echo ">>> Building lightweight Relay runtime image (no Rust/Cargo compilation)..." + if ! bitfun_docker build -t "$image" "$context"; then + echo ">>> Published binary image build failed; falling back to source build." + return 1 + fi + + bitfun_docker volume create relay-server_relay-db >/dev/null + bitfun_docker volume create relay-server_room-web >/dev/null + + local backup_container="" + if bitfun_docker container inspect bitfun-relay >/dev/null 2>&1; then + backup_container="bitfun-relay-before-release-$$" + bitfun_docker stop bitfun-relay >/dev/null 2>&1 || true + if ! bitfun_docker rename bitfun-relay "$backup_container"; then + echo ">>> Could not stage the existing Relay container; falling back to source build." + bitfun_docker start bitfun-relay >/dev/null 2>&1 || true + return 1 + fi + fi + + bitfun_restore_previous_relay() {{ + bitfun_docker rm -f bitfun-relay >/dev/null 2>&1 || true + if [ -n "$backup_container" ]; then + bitfun_docker rename "$backup_container" bitfun-relay >/dev/null 2>&1 || true + bitfun_docker start bitfun-relay >/dev/null 2>&1 || true + fi + }} + + # Wizard-close cancels this script with TERM/INT. Without a trap the user's + # relay would stay stopped under its backup name and disappear from the + # "already deployed" probe, so always put the previous container back. + trap 'bitfun_restore_previous_relay; trap - INT TERM; exit 1' INT TERM + + echo ">>> Starting published Relay binary on port $RELAY_PORT..." + if ! bitfun_docker run -d \ + --name bitfun-relay \ + --restart unless-stopped \ + --label com.docker.compose.project=relay-server \ + --label com.docker.compose.service=relay-server \ + -p "${{RELAY_HOST_BIND_IP:-0.0.0.0}}:${{RELAY_PORT}}:${{RELAY_PORT}}" \ + -e "RELAY_PORT=${{RELAY_PORT}}" \ + -e RELAY_STATIC_DIR=/app/static \ + -e RELAY_ROOM_WEB_DIR=/app/room-web \ + -e RELAY_ROOM_TTL=300 \ + -e RELAY_ASSET_STORE_MAX_BYTES=1073741824 \ + -e RELAY_DB_PATH=/app/data/bitfun_relay.db \ + -v relay-server_room-web:/app/room-web \ + -v relay-server_relay-db:/app/data \ + "$image" >/dev/null; then + echo ">>> Published Relay binary could not start; restoring previous container." + bitfun_restore_previous_relay + trap - INT TERM + return 1 + fi + + # Probe the address the container is actually published on; a wildcard bind + # is reachable through loopback. + local attempt stale probe_host="${{RELAY_HOST_BIND_IP:-0.0.0.0}}" + if [ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ]; then + probe_host="127.0.0.1" + fi + for attempt in $(seq 1 20); do + if curl -fsS --max-time 3 "http://${{probe_host}}:${{RELAY_PORT}}/health" >/dev/null 2>&1; then + trap - INT TERM + if [ -n "$backup_container" ]; then + bitfun_docker rm "$backup_container" >/dev/null 2>&1 || true + fi + # Sweep backups orphaned by an earlier interrupted release deploy. + for stale in $(bitfun_docker ps -aq \ + --filter 'name=^bitfun-relay-before-release-' 2>/dev/null); do + bitfun_docker rm -f "$stale" >/dev/null 2>&1 || true + done + echo ">>> Published Relay binary is healthy." + return 0 + fi + if ! bitfun_docker inspect -f '{{{{.State.Running}}}}' bitfun-relay 2>/dev/null \ + | grep -qx true; then + break + fi + sleep 2 + done + + echo ">>> Published Relay binary failed its health check; restoring previous container." + bitfun_docker logs --tail 40 bitfun-relay 2>/dev/null || true + bitfun_restore_previous_relay + trap - INT TERM + return 1 +}} +"#, + RELEASE_DOWNLOAD_BASE = RELEASE_DOWNLOAD_BASE, + OPENBITFUN_RELEASE_BASE = OPENBITFUN_RELEASE_BASE, + release_tag = release_tag, + release_version = RELEASE_VERSION, + ) +} + /// Non-interactive body for deploy (runs under nohup after prepare). fn deploy_body_script(port: u16) -> String { let helpers = prepare_helpers_bash(); let sync = sync_source_bash(); + let release_binary_deploy = release_binary_deploy_bash(); format!( r#"#!/usr/bin/env bash set -euo pipefail {helpers} {sync} +{release_binary_deploy} export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" export DOCKER_BUILDKIT=1 export COMPOSE_DOCKER_CLI_BUILD=1 @@ -1361,6 +1581,11 @@ echo ">>> Using RELAY_PORT=$RELAY_PORT" export BITFUN_REPO_GIT_URL="{REPO_GIT_URL}" export BITFUN_REPO_TARBALL_URL="{REPO_TARBALL_URL}" bitfun_mirror_init +if bitfun_try_release_deploy; then + echo {TASK_DONE_MARKER} + exit 0 +fi +echo ">>> Release binary path did not complete; starting source-build fallback." SRC="$HOME/{SOURCE_DIR}" bitfun_sync_source "$SRC" cd "$SRC/src/apps/relay-server" @@ -1386,6 +1611,7 @@ echo {TASK_DONE_MARKER} "#, helpers = helpers, sync = sync, + release_binary_deploy = release_binary_deploy, DEPLOY_STATE_DIR = DEPLOY_STATE_DIR, SOURCE_DIR = SOURCE_DIR, port = port, @@ -1398,7 +1624,8 @@ echo {TASK_DONE_MARKER} #[cfg(test)] mod tests { use super::{ - classify_docker_access, decide_task_status, parse_preflight, prepare_helpers_bash, + classify_docker_access, decide_task_status, deploy_body_script, parse_preflight, + prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, split_poll_stdout, sync_source_bash, DockerAccessMode, RelayTaskStatus, RELAY_MIRROR_SH, }; @@ -1451,6 +1678,49 @@ mod tests { assert!(sync.contains("retrying upstream")); } + #[test] + fn release_tag_tracks_stable_and_nightly_channels() { + assert_eq!(release_tag_for_version("0.2.13"), "v0.2.13"); + assert_eq!( + release_tag_for_version("0.2.14-nightly.20260724+abc123"), + "nightly" + ); + } + + #[test] + fn release_binary_deploy_verifies_assets_and_preserves_container_contract() { + let script = release_binary_deploy_bash(); + assert!(script.contains("bitfun-relay-server-${target}.tar.gz")); + assert!(script.contains("sha256sum -c")); + assert!(script.contains("https://openbitfun.com/release/")); + assert!(script.contains("no Rust/Cargo compilation")); + assert!(script.contains("--name bitfun-relay")); + assert!(script.contains("relay-server_relay-db:/app/data")); + assert!(script.contains("/app/relay-admin")); + assert!(script.contains("falling back to source build")); + assert!(script.contains("bitfun_restore_previous_relay")); + // Wizard-close must not leave the relay stopped under its backup name. + assert!(script.contains("trap 'bitfun_restore_previous_relay")); + assert!(script.contains("name=^bitfun-relay-before-release-")); + // Port publishing keeps compose's configurable bind address. + assert!(script.contains("${RELAY_HOST_BIND_IP:-0.0.0.0}:${RELAY_PORT}:${RELAY_PORT}")); + } + + #[cfg(unix)] + #[test] + fn generated_deploy_script_is_valid_bash() { + let script = deploy_body_script(9700); + let output = std::process::Command::new("bash") + .args(["-n", "-c", &script]) + .output() + .expect("parse generated deploy script"); + assert!( + output.status.success(), + "generated deploy script is invalid:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + #[cfg(unix)] #[test] fn mirror_docker_config_round_trip_preserves_unmanaged_settings() { diff --git a/src/web-ui/src/features/relay-deploy/README.md b/src/web-ui/src/features/relay-deploy/README.md index 561f69a30..d8b3e6bfd 100644 --- a/src/web-ui/src/features/relay-deploy/README.md +++ b/src/web-ui/src/features/relay-deploy/README.md @@ -1,7 +1,8 @@ # One-click Relay Deploy -Desktop wizard that SSHes to a user-owned Linux host and deploys -`src/apps/relay-server` (Docker compose + optional first-account import). +Desktop wizard that SSHes to a user-owned Linux host and deploys the matching +published Relay binary in a lightweight Docker image, with the source Docker +build retained as an automatic fallback. Account import remains optional. Entry points: @@ -15,38 +16,45 @@ Desktop Tauri surface: `src/apps/desktop/src/api/relay_deploy_api.rs` ## Invariants (do not regress) -1. **Source path is `~/.bitfun/relay-src`**, never `$HOME/BitFun` / `$HOME/bitfun`. - Sync always passes an explicit clone destination. Destructive replace is only - safe under `~/.bitfun/`. +1. **Published binary first, source fallback.** Download the matching stable + `v` asset or the `nightly` asset for nightly Desktop builds, + trying GitHub before the versioned openbitfun.com mirror. Verify its `.sha256` + and preserve the existing `bitfun-relay` container, volumes, ports, and + `/app/relay-admin` contract. A download, checksum, runtime-image, startup, or + health failure restores the previous container before falling back to source. -2. **Git first, tarball fallback.** When `.git` already exists, deploy must +2. **Fallback source path is `~/.bitfun/relay-src`**, never `$HOME/BitFun` / + `$HOME/bitfun`. Sync always passes an explicit clone destination. Destructive + replace is only safe under `~/.bitfun/`. + +3. **Git first, tarball fallback.** When `.git` already exists, deploy must `fetch` + checkout, not re-clone from scratch (preserves BuildKit layers and Cargo cache mounts for registry/git/`target`). -3. **Close wizard = cancel remote task.** Do not leave nohup builds running +4. **Close wizard = cancel remote task.** Do not leave nohup builds running after the modal closes; cancel must kill the pid tree and best-effort stop compose/buildx workers. -4. **Account password never leaves this device.** Provision locally, then +5. **Account password never leaves this device.** Provision locally, then `relay-admin import-user` over the SSH session. Do not send plaintext passwords to the remote as env/script args. -5. **“Already deployed” is container-aware, not only selected-port health.** +6. **“Already deployed” is container-aware, not only selected-port health.** Changing the listen port must not hide a running `bitfun-relay`. Use `container_running` / `existing_relay_port` / `relay_healthy` (health on selected **or** existing port). “Create account” must hit the running port. -6. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks +7. **Port conflict ≠ our relay.** `port_busy && !port_owned_by_relay` blocks deploy; busy-because-bitfun-relay does not. -7. **Privilege / Docker install.** Do not call `sudo -v` unconditionally. +8. **Privilege / Docker install.** Do not call `sudo -v` unconditionally. Detect root / passwordless sudo / interactive elevate. Docker install must not require a working daemon *before* install. -8. **Scripts are embedded Rust templates** staged via SFTP. Do not rely on a +9. **Scripts are embedded Rust templates** staged via SFTP. Do not rely on a static repo `.sh` alone on the server until the desktop binary re-stages. -9. **China mirrors before overseas downloads.** Desktop orchestration embeds +10. **China mirrors before overseas downloads.** Desktop orchestration embeds `src/apps/relay-server/mirror.sh` and runs `bitfun_mirror_init` before apt tool install, Docker Engine install, and GitHub sync. `deploy.sh` sources the same file so manual and one-click paths stay aligned. Force with