Skip to content
128 changes: 128 additions & 0 deletions .github/workflows/beta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
name: Beta
run-name: "OpenVCS Beta • Run #${{ github.run_number }} • Beta@${{ steps.meta.outputs.short_sha }}"

on:
push:
branches: [ Beta ]
workflow_dispatch:

permissions:
contents: write
actions: write

env:
TARGET_REF: Beta
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: ${{ vars.SSCCACHE_GHA_ENABLED }}
SCCACHE_CACHE_SIZE: ${{ vars.SSCCACHE_SIZE }}

jobs:
beta:
name: Build & publish Beta
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-24.04
args: ''
- platform: windows-latest
args: ''
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout target ref
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ env.TARGET_REF }}
fetch-depth: 0
submodules: recursive
lfs: true

- name: Compute metadata (date, short SHA)
id: meta
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const date = new Date().toISOString().slice(0, 10);
const short = context.sha.substring(0, 7);
core.setOutput('short_sha', short);
core.setOutput('date', date);

- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: Frontend/package-lock.json

- name: Install frontend deps
working-directory: Frontend
run: npm ci

- name: Build frontend
working-directory: Frontend
run: npm run build

- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@5d458579430fc14a04a08a1e7d3694f545e91ce6 # stable
with:
components: rustfmt, clippy

- name: Setup sccache
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9

- name: Install Linux deps
if: matrix.platform == 'ubuntu-24.04'
run: |
set -euxo pipefail
sudo apt-get update
sudo apt-get install -y libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev

- name: Rust cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
cache-on-failure: true

- name: Cargo fmt (check)
run: cargo fmt --all -- --check

- name: Cargo clippy
run: cargo clippy --all-targets -- -D warnings

- name: Remove existing 'openvcs-beta' release & tag
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const tag = 'openvcs-beta';
const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 });
const rel = releases.find(r => r.tag_name === tag);
if (rel) await github.rest.repos.deleteRelease({ owner, repo, release_id: rel.id });
try {
await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` });
} catch (e) {
if (e.status !== 422) throw e;
}

- name: Build and publish Beta prerelease
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # action-v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FRONTEND_SKIP_BUILD: '1'
OPENVCS_UPDATE_CHANNEL: beta
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_PRIVATE_KEY_PASSWORD }}
with:
projectPath: Backend
tagName: openvcs-beta
releaseName: "OpenVCS Beta ${{ steps.meta.outputs.date }} (${{ env.TARGET_REF }}@${{ steps.meta.outputs.short_sha }})"
releaseBody: |
Beta build from `${{ env.TARGET_REF }}`.
Date (UTC): ${{ steps.meta.outputs.date }}
Commit: ${{ github.sha }} (${{ env.TARGET_REF }}@${{ steps.meta.outputs.short_sha }})
Runner: ${{ runner.os }} • Run #${{ github.run_number }}
releaseDraft: true
prerelease: true
args: ${{ matrix.args }}

43 changes: 30 additions & 13 deletions Backend/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,22 +134,30 @@ fn main() {
// Compute channel based on environment; default to stable
let chan = env::var("OPENVCS_UPDATE_CHANNEL").unwrap_or_else(|_| "stable".into());

// Locations
let stable = serde_json::Value::String(
"https://github.com/Jordonbc/OpenVCS/releases/latest/download/latest.json".into(),
);
let nightly = serde_json::Value::String(
"https://github.com/Jordonbc/OpenVCS/releases/download/openvcs-nightly/latest.json".into(),
);
// Repository URL (can be overridden via env var for forks)
let repo =
env::var("OPENVCS_REPO").unwrap_or_else(|_| "https://github.com/Jordonbc/OpenVCS".into());

// Build update URLs from repository
let stable =
serde_json::Value::String(format!("{}/releases/latest/download/latest.json", repo));
let beta = serde_json::Value::String(format!(
"{}/releases/download/openvcs-beta/latest.json",
repo
));
let nightly = serde_json::Value::String(format!(
"{}/releases/download/openvcs-nightly/latest.json",
repo
));

// Navigate: plugins.updater.endpoints
if let Some(plugins) = json.get_mut("plugins") {
if let Some(updater) = plugins.get_mut("updater") {
let endpoints = match chan.as_str() {
// Beta: check beta first, then stable
"beta" => serde_json::Value::Array(vec![beta.clone(), stable.clone()]),
// Nightly: check nightly first, then stable
"nightly" | "beta" => {
serde_json::Value::Array(vec![nightly.clone(), stable.clone()])
}
"nightly" => serde_json::Value::Array(vec![nightly.clone(), stable.clone()]),
// Stable: stable only
_ => serde_json::Value::Array(vec![stable.clone()]),
};
Expand Down Expand Up @@ -200,6 +208,7 @@ fn main() {
println!("cargo:rerun-if-env-changed=OPENVCS_UPDATE_CHANNEL");
println!("cargo:rerun-if-env-changed=OPENVCS_FLATPAK");
println!("cargo:rerun-if-env-changed=OPENVCS_OFFICIAL_RELEASE");
println!("cargo:rerun-if-env-changed=OPENVCS_REPO");

// Export a GIT_DESCRIBE string for About dialog and diagnostics
let describe = Command::new("git")
Expand Down Expand Up @@ -257,10 +266,18 @@ fn main() {
pkg_version.clone()
} else {
let branch_ident = sanitize_semver_ident(&branch);
format!(
"{pkg_version}+git.{branch_ident}.{hash}{}",
let channel_suffix = match chan.as_str() {
"beta" => "-beta",
"nightly" => "-nightly",
_ => "",
};
let suffix = format!(
"+git.{}{}{}",
branch_ident,
hash,
if dirty { ".dirty" } else { "" }
)
);
format!("{}{}{}", pkg_version, channel_suffix, suffix)
};

println!("cargo:rustc-env=OPENVCS_VERSION={}", version);
Expand Down
1 change: 1 addition & 0 deletions Backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ fn build_invoke_handler<R: tauri::Runtime>(
tauri_commands::ssh_key_candidates,
tauri_commands::ssh_add_key,
tauri_commands::updater_install_now,
tauri_commands::get_update_status,
tauri_commands::open_repo_dotfile,
tauri_commands::open_docs,
tauri_commands::open_output_log_window,
Expand Down
56 changes: 56 additions & 0 deletions Backend/src/tauri_commands/updater.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,66 @@
// Copyright © 2025-2026 OpenVCS Contributors
// SPDX-License-Identifier: GPL-3.0-or-later
use log::{debug, error, info, trace};
use serde::Serialize;
use tauri::{Emitter, Manager, Runtime, Window};

use tauri_plugin_updater::UpdaterExt;

/// Response payload for update status check.
#[derive(Serialize)]
pub struct UpdateStatus {
pub available: bool,
pub version: Option<String>,
pub current_version: Option<String>,
pub body: Option<String>,
pub date: Option<String>,
}

#[tauri::command]
/// Checks for available updates and returns detailed status.
///
/// # Parameters
/// - `window`: Calling window handle.
///
/// # Returns
/// - [`UpdateStatus`] with version info if update available, or {available: false}.
pub async fn get_update_status<R: Runtime>(window: Window<R>) -> Result<UpdateStatus, String> {
let app = window.app_handle();
let updater = app.updater().map_err(|e| {
error!("get_update_status: failed to get updater: {}", e);
e.to_string()
})?;

match updater.check().await {
Ok(Some(update)) => {
let date_str = update.date.map(|d| d.to_string());
let status = UpdateStatus {
available: true,
version: Some(update.version.clone()),
current_version: Some(update.current_version.clone()),
body: update.body.clone(),
date: date_str,
};
debug!(
"get_update_status: update available: {} -> {}",
update.current_version, update.version
);
Ok(status)
}
Ok(None) => Ok(UpdateStatus {
available: false,
version: None,
current_version: None,
body: None,
date: None,
}),
Err(e) => {
error!("get_update_status: check failed: {}", e);
Err(e.to_string())
}
}
}

#[tauri::command]
/// Downloads and installs an available application update.
///
Expand Down
1 change: 1 addition & 0 deletions Frontend/src/modals/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ <h3 id="settings-title" style="margin:0">Settings</h3>
</label>
<select id="set-update-channel">
<option value="stable">Stable</option>
<option value="beta">Beta</option>
<option value="nightly">Nightly</option>
</select>
</div>
Expand Down
5 changes: 2 additions & 3 deletions Frontend/src/scripts/features/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ function collectSettingsFromForm(root: HTMLElement): GlobalSettings {
theme_pack: themePack || DEFAULT_LIGHT_THEME_ID,
language: get<HTMLSelectElement>('#set-language')?.value,
default_backend: (get<HTMLSelectElement>('#set-default-backend')?.value || 'git') as any,
update_channel: (() => { const v = get<HTMLSelectElement>('#set-update-channel')?.value; return v === 'beta' ? 'nightly' : v; })(),
update_channel: get<HTMLSelectElement>('#set-update-channel')?.value || 'stable',
reopen_last_repos: !!get<HTMLInputElement>('#set-reopen-last')?.checked,
checks_on_launch: !!get<HTMLInputElement>('#set-checks-on-launch')?.checked,
};
Expand Down Expand Up @@ -927,8 +927,7 @@ export async function loadSettingsIntoForm(root?: HTMLElement) {
const elLang = get<HTMLSelectElement>('#set-language'); if (elLang) elLang.value = toKebab(cfg.general?.language);
await refreshDefaultBackendOptions(m, cfg);
const elChan = get<HTMLSelectElement>('#set-update-channel'); if (elChan) {
const v = toKebab(cfg.general?.update_channel);
elChan.value = (v === 'beta') ? 'nightly' : v;
elChan.value = toKebab(cfg.general?.update_channel);
}
const elReo = get<HTMLInputElement>('#set-reopen-last'); if (elReo) elReo.checked = !!cfg.general?.reopen_last_repos;
const elChk = get<HTMLInputElement>('#set-checks-on-launch'); if (elChk) elChk.checked = !!cfg.general?.checks_on_launch;
Expand Down
64 changes: 14 additions & 50 deletions Frontend/src/scripts/features/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { TAURI } from '../lib/tauri';
import { openModal, closeModal } from '../ui/modals';
import { notify } from '../lib/notify';

interface UpdateStatus {
available: boolean;
version: string | null;
current_version: string | null;
body: string | null;
date: string | null;
}

export function wireUpdate() {
const modal = document.getElementById('update-modal') as HTMLElement | null;
if (!modal || (modal as any).__wired) return;
Expand All @@ -26,68 +34,24 @@ export function wireUpdate() {
export async function showUpdateDialog(_data: any) {
try {
if (!TAURI.has) return;
const cfg = await TAURI.invoke<any>('get_global_settings');
const about = await TAURI.invoke<any>('about_info');
const channel = String(cfg?.general?.update_channel || 'stable');
const current = String(about?.version || '').trim();

const fetchJson = async (url: string) => {
const r = await fetch(url, { cache: 'no-store' }); return r.ok ? r.json() : null;
};

const stable = await fetchJson('https://api.github.com/repos/Jordonbc/OpenVCS/releases/latest');
const nightly = await fetchJson('https://api.github.com/repos/Jordonbc/OpenVCS/releases/tags/openvcs-nightly');

const norm = (v: string) => String(v || '').replace(/^v/i, '').trim();
const stableTag = norm(stable?.tag_name || stable?.name || '');
const nightlyTag = norm(nightly?.tag_name || nightly?.name || '');

const base = (v: string) => norm(v).split('+', 1)[0];
const currentBase = base(current);
const newerThanCurrent = (v: string) => Boolean(v) && v !== '' && currentBase !== base(v);

let show = false;
let pick = null as any;
const status = await TAURI.invoke<UpdateStatus>('get_update_status');

if (channel === 'stable') {
if (newerThanCurrent(stableTag)) { show = true; pick = stable; }
} else {
// Nightly: pick the most recent by published_at timestamp and ensure it's newer than current
const sDate = Date.parse(String(stable?.published_at || stable?.created_at || '')) || 0;
const nDate = Date.parse(String(nightly?.published_at || nightly?.created_at || '')) || 0;
pick = (nDate > sDate ? nightly : stable) || nightly || stable;
const pickTag = norm(pick?.tag_name || pick?.name || '');
show = newerThanCurrent(pickTag);
if (!status.available) {
notify('Already up to date');
return;
}

if (!show || !pick) { notify('Already up to date'); return; }

openModal('update-modal');
const modal = document.getElementById('update-modal') as HTMLElement | null;
if (!modal) return;
const verEl = modal.querySelector('#update-version');
const notesEl = modal.querySelector('#update-notes');
const v = pick?.tag_name || pick?.name || '';
const body = String(pick?.body || '').trim();
const v = status.version || '';
const body = String(status.body || '').trim();
if (verEl) verEl.textContent = v ? `Version ${v}` : 'Update available';
if (notesEl) (notesEl as HTMLElement).textContent = body || '(No changelog provided)';
} catch {
notify('Update check failed');
}
}

function inferString(obj: any, keys: string[]): string | undefined {
if (!obj || typeof obj !== 'object') return undefined;
for (const k of keys) {
const v = obj[k as any];
if (typeof v === 'string' && v.trim()) return v;
}
// nested common containers
if (obj.update && typeof obj.update === 'object') {
const v = inferString(obj.update, keys); if (v) return v;
}
if (obj.manifest && typeof obj.manifest === 'object') {
const v = inferString(obj.manifest, keys); if (v) return v;
}
return undefined;
}
Loading