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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove step output from workflow run-name

run-name is evaluated before any job steps run, so steps.meta.outputs.short_sha is not in scope at this level. In this workflow, that expression can cause validation failure on trigger (push/workflow_dispatch), which blocks the Beta pipeline from starting at all.

Useful? React with 👍 / 👎.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Publish beta release artifacts for updater access

This workflow always creates openvcs-beta as a draft release, but the beta channel endpoint configured in Backend/build.rs points clients to .../releases/download/openvcs-beta/latest.json for unauthenticated updater checks. Draft release assets are not publicly retrievable, so beta clients cannot fetch latest.json and beta auto-update checks/install flows will fail in production.

Useful? React with 👍 / 👎.

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