feat: align release version naming

This commit is contained in:
lucaronin
2026-04-16 14:43:08 +02:00
parent 991ee8689e
commit edd5a1ce8f
11 changed files with 537 additions and 278 deletions

View File

@@ -22,15 +22,32 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
display_version: ${{ steps.ver.outputs.display_version }}
tag: ${{ steps.ver.outputs.tag }}
steps:
- id: ver
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#stable-v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
echo "### Stable version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
python3 <<'PY' > version.env
import os
import re
from datetime import date
tag = os.environ["GITHUB_REF_NAME"]
version = tag.removeprefix("stable-v")
match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})", version)
if not match:
raise SystemExit(f"Stable tags must use stable-vYYYY.M.D, got {tag}")
date(*map(int, match.groups()))
print(f"version={version}")
print(f"display_version={version}")
print(f"tag={tag}")
PY
cat version.env >> "$GITHUB_OUTPUT"
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build each architecture in parallel
@@ -211,7 +228,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: Tolaria ${{ needs.version.outputs.version }}
name: Tolaria ${{ needs.version.outputs.display_version }}
body_path: release_notes.md
draft: false
prerelease: false
@@ -275,7 +292,7 @@ jobs:
</head>
<body>
<h1>Tolaria Release History</h1>
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vX.Y.Z tag is promoted.</p>
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
<div id="releases"></div>
<script>
fetch('releases.json').then(r=>r.json()).then(releases=>{

View File

@@ -16,13 +16,14 @@ concurrency:
jobs:
# ─────────────────────────────────────────────────────────────
# Phase 1: Compute the alpha version string once
# Alpha builds are prereleases of the next stable patch version.
# Alpha builds use calendar semver and stay newer than the latest stable tag.
# ─────────────────────────────────────────────────────────────
version:
name: Compute alpha version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
display_version: ${{ steps.ver.outputs.display_version }}
tag: ${{ steps.ver.outputs.tag }}
steps:
- uses: actions/checkout@v4
@@ -32,22 +33,63 @@ jobs:
- id: ver
shell: bash
run: |
LAST_STABLE_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | head -n 1)
if [ -z "$LAST_STABLE_TAG" ]; then
BASE_VERSION="0.1.0"
else
BASE_VERSION="${LAST_STABLE_TAG#stable-v}"
fi
python3 <<'PY' > version.env
import re
import subprocess
from datetime import datetime, timedelta, timezone
IFS='.' read -r MAJOR MINOR PATCH <<EOF
$BASE_VERSION
EOF
NEXT_PATCH_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
def lines(command: list[str]) -> list[str]:
output = subprocess.check_output(command, text=True).strip()
return [line for line in output.splitlines() if line]
VERSION="${NEXT_PATCH_VERSION}-alpha.$(date -u +%Y%m%d%H%M).${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=alpha-v$VERSION" >> "$GITHUB_OUTPUT"
echo "### Alpha version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
existing_tags = [
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
if tag.startswith("alpha-v")
]
if existing_tags:
tag = existing_tags[0]
version = tag.removeprefix("alpha-v")
else:
today = datetime.now(timezone.utc).date()
stable_date = None
stable_pattern = re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$")
for stable_tag in lines(["git", "tag", "--list", "stable-v*", "--sort=-version:refname"]):
match = stable_pattern.fullmatch(stable_tag)
if not match:
continue
year, month, day = map(int, match.groups())
try:
stable_date = datetime(year, month, day, tzinfo=timezone.utc).date()
except ValueError:
continue
break
alpha_date = today if stable_date is None or today > stable_date else stable_date + timedelta(days=1)
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
version = f"{calendar_version}-alpha.{sequence}"
tag = f"alpha-v{version}"
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
display_version = (
f"Alpha {int(display_match.group(1))}.{int(display_match.group(2))}.{int(display_match.group(3))}.{int(display_match.group(4))}"
if display_match
else version
)
print(f"version={version}")
print(f"display_version={display_version}")
print(f"tag={tag}")
PY
cat version.env >> "$GITHUB_OUTPUT"
VERSION=$(grep '^version=' version.env | cut -d= -f2-)
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build each architecture in parallel
@@ -225,7 +267,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: Tolaria ${{ needs.version.outputs.version }} (Alpha)
name: Tolaria ${{ needs.version.outputs.display_version }}
body_path: release_notes.md
draft: false
prerelease: true
@@ -288,7 +330,7 @@ jobs:
</head>
<body>
<h1>Tolaria Release History</h1>
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vX.Y.Z tag is promoted.</p>
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
<div id="releases"></div>
<script>
fetch('releases.json').then(r=>r.json()).then(releases=>{

View File

@@ -621,12 +621,13 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
### Rust
- **`src-tauri/src/app_updater.rs`** — Chooses the correct update endpoint (`alpha/latest.json` or `stable/latest.json`) and adapts Tauri updater results into frontend-friendly payloads.
- **`src-tauri/src/commands/version.rs`** — Formats app build/version labels for the status bar, including calendar alpha labels and legacy release compatibility.
### Tauri Commands
- **`check_for_app_update`** — Channel-aware update manifest lookup.
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-v*` tags. Publishes `stable/latest.json`.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -689,7 +689,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes |
`get_build_number` feeds the bottom status bar label. It preserves legacy `bNNN` date-build labels, renders local `0.1.0` / `0.0.0` builds as `dev`, and formats semver alpha prereleases as `alpha <version>` so signed alpha builds never fall back to `?`.
`get_build_number` feeds the bottom status bar label. It preserves legacy `bNNN` date-build labels, renders local `0.1.0` / `0.0.0` builds as `dev`, formats calendar alpha builds as `Alpha YYYY.M.D.N`, strips any calendar `-stable.N` suffix back to `YYYY.M.D`, and keeps legacy semver releases readable instead of falling back to `?`.
## Mock Layer
@@ -766,14 +766,15 @@ Every push to `main` triggers `.github/workflows/release.yml`:
```
push to main
→ version job: read latest stable-vX.Y.Z tag
→ compute next patch prerelease X.Y.(Z+1)-alpha.UTCSTAMP.RUN_NUMBER
→ version job: compute calendar alpha version YYYY.M.D-alpha.N
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ upload signed .app.tar.gz + .sig and .dmg artifacts
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
→ upload signed .app.tar.gz + .sig updater artifacts
→ release job:
→ generate alpha-latest.json
→ publish GitHub prerelease alpha-v<version>
→ publish GitHub prerelease alpha-v<version> named Tolaria Alpha YYYY.M.D.N
→ pages job:
→ build static HTML release history page
→ publish alpha/latest.json
@@ -785,14 +786,14 @@ push to main
Stable promotions trigger `.github/workflows/release-stable.yml`:
```
push stable-vX.Y.Z tag
→ version job: use X.Y.Z from the tag
push stable-vYYYY.M.D tag
→ version job: validate YYYY.M.D from the tag
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ upload signed .app.tar.gz + .sig and .dmg artifacts
→ release job:
→ generate stable-latest.json
→ publish GitHub release Tolaria X.Y.Z
→ publish GitHub release Tolaria YYYY.M.D
→ pages job:
→ publish stable/latest.json
→ preserve alpha/latest.json
@@ -801,10 +802,11 @@ push stable-vX.Y.Z tag
### Versioning
- Stable promotions use git tags in the form `stable-vX.Y.Z`.
- Alpha builds are prereleases of the next stable patch version, for example stable `1.2.3` → alpha `1.2.4-alpha.202604122135.7`.
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`.
- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches.
- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time.
- This keeps semver monotonic when a user switches between Stable and Alpha.
- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha.
### In-App Updates

View File

@@ -0,0 +1,37 @@
---
type: ADR
id: "0066"
title: "Calendar-semver versioning for alpha and stable releases"
status: active
date: 2026-04-16
supersedes: "0057"
---
## Context
ADR-0057 kept Tolaria on two updater channels and used "next stable patch" semver for alpha builds. That preserved ordering, but it no longer matched the agreed product naming:
- Alpha should display as `Alpha YYYY.M.D.N`
- Alpha should ship the technical version `YYYY.M.D-alpha.N`
- Stable should ship and display as `YYYY.M.D`
The naming change still needs to stay semver-safe when users switch between Stable and Alpha. A pure same-day calendar alpha would become older than a same-day stable promotion, so the workflow needs a monotonicity guard in addition to cleaner display strings.
## Decision
**Tolaria keeps exactly two updater channels (`stable` and `alpha`), but both now use calendar-semver release numbers.** Stable promotions use `stable-vYYYY.M.D` tags and stamp the technical version `YYYY.M.D`. Every push to `main` publishes an alpha build with technical version `YYYY.M.D-alpha.N` and display label `Alpha YYYY.M.D.N`.
If the latest stable tag already uses the current UTC calendar date, the alpha workflow advances to the next calendar day before assigning `-alpha.N`. That keeps alpha semver-newer than the most recent stable build even after a same-day promotion.
## Options considered
- **Calendar semver with a next-day safeguard** (chosen): matches the agreed naming, keeps user-facing labels clean, and preserves updater ordering across channel switches.
- **Calendar semver without a safeguard**: simplest display model, but alpha can become semver-older than Stable after a same-day promotion.
- **Keep ADR-0057's next-patch prerelease numbering**: semver-safe, but it does not match the agreed release naming or the product surfaces that should show calendar-based versions.
## Consequences
- Release workflows now compute both a technical version and a display version.
- User-facing version surfaces strip technical prerelease noise into clean labels (`Alpha YYYY.M.D.N` or `YYYY.M.D`).
- Stable promotions must use `stable-vYYYY.M.D` tags instead of patch-based semver tags.
- Alpha sequence numbers are scoped to a calendar core date and remain compatible with the updater manifests.

View File

@@ -3,6 +3,7 @@ mod delete;
mod git;
mod system;
mod vault;
mod version;
use std::borrow::Cow;
@@ -11,6 +12,7 @@ pub use delete::*;
pub use git::*;
pub use system::*;
pub use vault::*;
pub use version::*;
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
@@ -29,17 +31,6 @@ pub fn expand_tilde(path: &str) -> Cow<'_, str> {
}
}
pub fn parse_build_label(version: &str) -> String {
let version = version.trim();
if version.is_empty() {
return "b?".to_string();
}
parse_legacy_build_label(version)
.or_else(|| parse_semver_build_label(version))
.unwrap_or_else(|| "b?".to_string())
}
fn is_numeric_version_part(part: &str) -> bool {
!part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())
}
@@ -56,30 +47,6 @@ fn parse_legacy_build_label(version: &str) -> Option<String> {
}
}
fn parse_semver_build_label(version: &str) -> Option<String> {
let semver = version.split_once('+').map_or(version, |(base, _)| base);
let (core, prerelease) = semver
.split_once('-')
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
let parts: Vec<&str> = core.split('.').collect();
let [major, minor, patch] = parts.as_slice() else {
return None;
};
if ![major, minor, patch]
.iter()
.all(|part| is_numeric_version_part(part))
{
return None;
}
match prerelease {
Some(suffix) if suffix.starts_with("alpha.") => Some(format!("alpha {}", version)),
Some(_) => Some(format!("v{}", version)),
None if version == "0.1.0" || version == "0.0.0" => Some("dev".to_string()),
None => Some(format!("v{}", version)),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -115,35 +82,4 @@ mod tests {
let result = expand_tilde("/home/~user/path");
assert_eq!(result, "/home/~user/path");
}
#[test]
fn parse_build_label_release_version() {
assert_eq!(parse_build_label("0.20260303.281"), "b281");
assert_eq!(parse_build_label("0.20251215.42"), "b42");
}
#[test]
fn parse_build_label_semver_releases() {
assert_eq!(parse_build_label("1.2.3"), "v1.2.3");
assert_eq!(
parse_build_label("1.2.4-alpha.202604122135.7"),
"alpha 1.2.4-alpha.202604122135.7"
);
assert_eq!(
parse_build_label("1.2.4-alpha.202604122135.7+darwin"),
"alpha 1.2.4-alpha.202604122135.7+darwin"
);
}
#[test]
fn parse_build_label_dev_version() {
assert_eq!(parse_build_label("0.1.0"), "dev");
assert_eq!(parse_build_label("0.0.0"), "dev");
}
#[test]
fn parse_build_label_malformed() {
assert_eq!(parse_build_label("invalid"), "b?");
assert_eq!(parse_build_label(""), "b?");
}
}

View File

@@ -0,0 +1,121 @@
use super::{is_numeric_version_part, parse_legacy_build_label};
pub fn parse_build_label(version: &str) -> String {
let version = version.trim();
if version.is_empty() {
return "b?".to_string();
}
parse_legacy_build_label(version)
.or_else(|| parse_calendar_build_label(version))
.or_else(|| parse_semver_build_label(version))
.unwrap_or_else(|| "b?".to_string())
}
fn strip_build_metadata(version: &str) -> &str {
version.split_once('+').map_or(version, |(base, _)| base)
}
fn parse_calendar_build_label(version: &str) -> Option<String> {
let semver = strip_build_metadata(version);
let (core, prerelease) = semver
.split_once('-')
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
let [year, month, day] = split_numeric_version_parts(core)?;
if year.len() != 4 {
return None;
}
let core_version = format!(
"{}.{}.{}",
year.parse::<u32>().ok()?,
month.parse::<u32>().ok()?,
day.parse::<u32>().ok()?
);
match prerelease {
Some(suffix) if suffix.starts_with("alpha.") => suffix
.strip_prefix("alpha.")
.map(|sequence| format!("Alpha {}.{}", core_version, sequence)),
Some(suffix) if suffix.starts_with("stable.") => Some(core_version),
Some(_) => None,
None => Some(core_version),
}
}
fn parse_semver_build_label(version: &str) -> Option<String> {
let semver = strip_build_metadata(version);
let (core, prerelease) = semver
.split_once('-')
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
split_numeric_version_parts(core)?;
match prerelease {
Some(suffix) if suffix.starts_with("alpha.") => Some(format!("Alpha {}", semver)),
Some(_) => Some(format!("v{}", semver)),
None if semver == "0.1.0" || semver == "0.0.0" => Some("dev".to_string()),
None => Some(format!("v{}", semver)),
}
}
fn split_numeric_version_parts(version: &str) -> Option<[&str; 3]> {
let parts: Vec<&str> = version.split('.').collect();
let [major, minor, patch] = parts.as_slice() else {
return None;
};
if ![major, minor, patch]
.iter()
.all(|part| is_numeric_version_part(part))
{
return None;
}
Some([major, minor, patch])
}
#[cfg(test)]
mod tests {
use super::parse_build_label;
#[test]
fn parse_build_label_release_version() {
assert_eq!(parse_build_label("0.20260303.281"), "b281");
assert_eq!(parse_build_label("0.20251215.42"), "b42");
}
#[test]
fn parse_build_label_calendar_versions() {
assert_eq!(parse_build_label("2026.4.16"), "2026.4.16");
assert_eq!(parse_build_label("2026.4.16-stable.1"), "2026.4.16");
assert_eq!(parse_build_label("2026.4.16-alpha.3"), "Alpha 2026.4.16.3");
assert_eq!(
parse_build_label("2026.4.16-alpha.3+darwin"),
"Alpha 2026.4.16.3"
);
}
#[test]
fn parse_build_label_legacy_semver_releases() {
assert_eq!(parse_build_label("1.2.3"), "v1.2.3");
assert_eq!(
parse_build_label("1.2.4-alpha.202604122135.7"),
"Alpha 1.2.4-alpha.202604122135.7"
);
assert_eq!(
parse_build_label("1.2.4-alpha.202604122135.7+darwin"),
"Alpha 1.2.4-alpha.202604122135.7"
);
}
#[test]
fn parse_build_label_dev_version() {
assert_eq!(parse_build_label("0.1.0"), "dev");
assert_eq!(parse_build_label("0.0.0"), "dev");
}
#[test]
fn parse_build_label_malformed() {
assert_eq!(parse_build_label("invalid"), "b?");
assert_eq!(parse_build_label(""), "b?");
}
}

View File

@@ -21,64 +21,93 @@ function makeActions(overrides?: Partial<UpdateActions>): UpdateActions {
}
}
function makeAvailableStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'available' }>>): UpdateStatus {
return {
state: 'available',
version: '2026.4.16',
displayVersion: '2026.4.16',
notes: undefined,
...overrides,
}
}
function makeDownloadingStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'downloading' }>>): UpdateStatus {
return {
state: 'downloading',
version: '2026.4.16-alpha.3',
displayVersion: 'Alpha 2026.4.16.3',
progress: 0.65,
...overrides,
}
}
function makeReadyStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'ready' }>>): UpdateStatus {
return {
state: 'ready',
version: '2026.4.16',
displayVersion: '2026.4.16',
...overrides,
}
}
function renderBanner(status: UpdateStatus, actions = makeActions()) {
const view = render(<UpdateBanner status={status} actions={actions} />)
return { ...view, actions }
}
describe('UpdateBanner', () => {
it('renders nothing when idle', () => {
const status: UpdateStatus = { state: 'idle' }
const { container } = render(<UpdateBanner status={status} actions={makeActions()} />)
const { container } = renderBanner(status)
expect(container.innerHTML).toBe('')
})
it('renders nothing on error state', () => {
const status: UpdateStatus = { state: 'error' }
const { container } = render(<UpdateBanner status={status} actions={makeActions()} />)
const { container } = renderBanner(status)
expect(container.innerHTML).toBe('')
})
it('shows version and action buttons when update is available', () => {
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: 'Bug fixes' }
const actions = makeActions()
render(<UpdateBanner status={status} actions={actions} />)
renderBanner(makeAvailableStatus({
version: '2026.4.16-alpha.3',
displayVersion: 'Alpha 2026.4.16.3',
notes: 'Bug fixes',
}))
expect(screen.getByTestId('update-banner')).toBeTruthy()
expect(screen.getByText(/Tolaria 1\.5\.0/)).toBeTruthy()
expect(screen.getByText('is available')).toBeTruthy()
expect(screen.getByText(/Tolaria Alpha 2026\.4\.16\.3/)).toBeTruthy()
expect(screen.getByText(/is available/)).toBeTruthy()
expect(screen.getByTestId('update-now-btn')).toBeTruthy()
expect(screen.getByTestId('update-release-notes')).toBeTruthy()
expect(screen.getByTestId('update-dismiss')).toBeTruthy()
})
it('"Update Now" calls startDownload', () => {
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
const actions = makeActions()
render(<UpdateBanner status={status} actions={actions} />)
const { actions } = renderBanner(makeAvailableStatus())
fireEvent.click(screen.getByTestId('update-now-btn'))
expect(actions.startDownload).toHaveBeenCalledOnce()
})
it('"Release Notes" link calls openReleaseNotes', () => {
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
const actions = makeActions()
render(<UpdateBanner status={status} actions={actions} />)
const { actions } = renderBanner(makeAvailableStatus())
fireEvent.click(screen.getByTestId('update-release-notes'))
expect(actions.openReleaseNotes).toHaveBeenCalledOnce()
})
it('dismiss button calls dismiss action', () => {
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
const actions = makeActions()
render(<UpdateBanner status={status} actions={actions} />)
const { actions } = renderBanner(makeAvailableStatus())
fireEvent.click(screen.getByTestId('update-dismiss'))
expect(actions.dismiss).toHaveBeenCalledOnce()
})
it('shows progress bar during download', () => {
const status: UpdateStatus = { state: 'downloading', version: '1.5.0', progress: 0.65 }
render(<UpdateBanner status={status} actions={makeActions()} />)
renderBanner(makeDownloadingStatus())
expect(screen.getByText(/Downloading Tolaria 1\.5\.0/)).toBeTruthy()
expect(screen.getByText(/Downloading Tolaria Alpha 2026\.4\.16\.3/)).toBeTruthy()
expect(screen.getByText('65%')).toBeTruthy()
const progressBar = screen.getByTestId('update-progress')
@@ -86,8 +115,11 @@ describe('UpdateBanner', () => {
})
it('shows 0% at start of download', () => {
const status: UpdateStatus = { state: 'downloading', version: '2.0.0', progress: 0 }
render(<UpdateBanner status={status} actions={makeActions()} />)
renderBanner(makeDownloadingStatus({
version: '2026.4.16',
displayVersion: '2026.4.16',
progress: 0,
}))
expect(screen.getByText('0%')).toBeTruthy()
const progressBar = screen.getByTestId('update-progress')
@@ -95,18 +127,16 @@ describe('UpdateBanner', () => {
})
it('shows restart button when update is ready', () => {
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
render(<UpdateBanner status={status} actions={makeActions()} />)
renderBanner(makeReadyStatus())
expect(screen.getByText(/Tolaria 1\.5\.0/)).toBeTruthy()
expect(screen.getByText(/Tolaria 2026\.4\.16/)).toBeTruthy()
expect(screen.getByText(/restart to apply/)).toBeTruthy()
expect(screen.getByTestId('update-restart-btn')).toBeTruthy()
})
it('restart button calls restartApp', async () => {
const { restartApp } = await import('../hooks/useUpdater')
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
render(<UpdateBanner status={status} actions={makeActions()} />)
renderBanner(makeReadyStatus())
fireEvent.click(screen.getByTestId('update-restart-btn'))
expect(restartApp).toHaveBeenCalled()

View File

@@ -1,145 +1,166 @@
import type { CSSProperties } from 'react'
import { Download, ExternalLink, RefreshCw, X } from 'lucide-react'
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
import { restartApp } from '../hooks/useUpdater'
import { Button } from './ui/button'
interface UpdateBannerProps {
status: UpdateStatus
actions: UpdateActions
}
type VisibleUpdateStatus = Exclude<UpdateStatus, { state: 'idle' } | { state: 'error' }>
const bannerStyle = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '6px 12px',
background: '#1a56db',
borderBottom: 'none',
fontSize: 13,
color: '#fff',
flexShrink: 0,
} satisfies CSSProperties
const iconStyle = {
color: '#fff',
flexShrink: 0,
} satisfies CSSProperties
const primaryActionStyle = {
marginLeft: 'auto',
padding: '3px 10px',
background: 'var(--primary)',
color: '#fff',
fontSize: 12,
fontWeight: 500,
} satisfies CSSProperties
const dismissButtonStyle = {
color: '#fff',
display: 'flex',
padding: 2,
} satisfies CSSProperties
const progressTrackStyle = {
flex: 1,
maxWidth: 200,
height: 4,
background: 'rgba(255,255,255,0.3)',
borderRadius: 2,
overflow: 'hidden',
} satisfies CSSProperties
const progressTextStyle = {
fontSize: 11,
color: 'rgba(255,255,255,0.85)',
} satisfies CSSProperties
const readyIconStyle = {
color: 'var(--accent-green, #0F7B0F)',
flexShrink: 0,
} satisfies CSSProperties
function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'available' }>, actions: UpdateActions) {
return (
<>
<Download size={14} style={iconStyle} />
<span>
<strong>Tolaria {status.displayVersion}</strong> is available
</span>
<Button
type="button"
variant="link"
size="xs"
data-testid="update-release-notes"
onClick={actions.openReleaseNotes}
style={{ color: '#fff', padding: 0, height: 'auto' }}
>
Release Notes <ExternalLink size={11} />
</Button>
<Button
type="button"
size="xs"
data-testid="update-now-btn"
onClick={actions.startDownload}
style={primaryActionStyle}
>
Update Now
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
data-testid="update-dismiss"
onClick={actions.dismiss}
style={dismissButtonStyle}
aria-label="Dismiss"
>
<X size={14} />
</Button>
</>
)
}
function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state: 'downloading' }>) {
return (
<>
<RefreshCw size={14} style={{ ...iconStyle, animation: 'spin 1s linear infinite' }} />
<span>Downloading Tolaria {status.displayVersion}...</span>
<div style={progressTrackStyle}>
<div
data-testid="update-progress"
style={{
width: `${Math.round(status.progress * 100)}%`,
height: '100%',
background: 'var(--primary)',
borderRadius: 2,
transition: 'width 0.2s ease',
}}
/>
</div>
<span style={progressTextStyle}>{Math.round(status.progress * 100)}%</span>
</>
)
}
function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready' }>) {
return (
<>
<RefreshCw size={14} style={readyIconStyle} />
<span>
<strong>Tolaria {status.displayVersion}</strong> is ready - restart to apply
</span>
<Button
type="button"
size="xs"
data-testid="update-restart-btn"
onClick={restartApp}
style={{
...primaryActionStyle,
background: 'var(--accent-green, #0F7B0F)',
}}
>
Restart Now
</Button>
</>
)
}
function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions) {
switch (status.state) {
case 'available':
return renderAvailableContent(status, actions)
case 'downloading':
return renderDownloadingContent(status)
case 'ready':
return renderReadyContent(status)
}
}
export function UpdateBanner({ status, actions }: UpdateBannerProps) {
if (status.state === 'idle' || status.state === 'error') return null
return (
<div
data-testid="update-banner"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '6px 12px',
background: '#1a56db',
borderBottom: 'none',
fontSize: 13,
color: '#fff',
flexShrink: 0,
}}
>
{status.state === 'available' && (
<>
<Download size={14} style={{ color: '#fff', flexShrink: 0 }} />
<span>
<strong>Tolaria {status.version}</strong> is available
</span>
<button
data-testid="update-release-notes"
onClick={actions.openReleaseNotes}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
background: 'none',
border: 'none',
color: '#fff',
cursor: 'pointer',
fontSize: 13,
padding: 0,
textDecoration: 'underline',
}}
>
Release Notes <ExternalLink size={11} />
</button>
<button
data-testid="update-now-btn"
onClick={actions.startDownload}
style={{
marginLeft: 'auto',
padding: '3px 10px',
background: 'var(--primary)',
color: '#fff',
border: 'none',
borderRadius: 5,
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
}}
>
Update Now
</button>
<button
data-testid="update-dismiss"
onClick={actions.dismiss}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#fff',
display: 'flex',
padding: 2,
}}
aria-label="Dismiss"
>
<X size={14} />
</button>
</>
)}
{status.state === 'downloading' && (
<>
<RefreshCw size={14} style={{ color: '#fff', flexShrink: 0, animation: 'spin 1s linear infinite' }} />
<span>Downloading Tolaria {status.version}...</span>
<div
style={{
flex: 1,
maxWidth: 200,
height: 4,
background: 'rgba(255,255,255,0.3)',
borderRadius: 2,
overflow: 'hidden',
}}
>
<div
data-testid="update-progress"
style={{
width: `${Math.round(status.progress * 100)}%`,
height: '100%',
background: 'var(--primary)',
borderRadius: 2,
transition: 'width 0.2s ease',
}}
/>
</div>
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>
{Math.round(status.progress * 100)}%
</span>
</>
)}
{status.state === 'ready' && (
<>
<RefreshCw size={14} style={{ color: 'var(--accent-green, #0F7B0F)', flexShrink: 0 }} />
<span>
<strong>Tolaria {status.version}</strong> is ready restart to apply
</span>
<button
data-testid="update-restart-btn"
onClick={restartApp}
style={{
marginLeft: 'auto',
padding: '3px 10px',
background: 'var(--accent-green, #0F7B0F)',
color: '#fff',
border: 'none',
borderRadius: 5,
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
}}
>
Restart Now
</button>
</>
)}
</div>
)
return <div data-testid="update-banner" style={bannerStyle}>{renderBannerContent(status, actions)}</div>
}

View File

@@ -48,8 +48,8 @@ type DownloadArgs = {
function makeUpdate(overrides: Partial<AppUpdateMetadata> = {}): AppUpdateMetadata {
return {
currentVersion: '1.0.0',
version: '1.2.0',
currentVersion: '2026.4.15',
version: '2026.4.16',
body: 'Bug fixes and improvements',
...overrides,
}
@@ -148,12 +148,13 @@ describe('useUpdater', () => {
it('transitions to available when an alpha update is found', async () => {
const { result } = await performManualCheck(
'alpha',
makeUpdate({ version: '1.2.4-alpha.202604121830.10' }),
makeUpdate({ version: '2026.4.16-alpha.3' }),
)
expect(result.current.status).toEqual({
state: 'available',
version: '1.2.4-alpha.202604121830.10',
version: '2026.4.16-alpha.3',
displayVersion: 'Alpha 2026.4.16.3',
notes: 'Bug fixes and improvements',
})
@@ -178,11 +179,26 @@ describe('useUpdater', () => {
expect(outcome).toBe('available')
expect(result.current.status).toEqual({
state: 'available',
version: '1.2.0',
version: '2026.4.16',
displayVersion: '2026.4.16',
notes: undefined,
})
})
it('strips stable prerelease suffixes from the display version', async () => {
const { result } = await performManualCheck(
'stable',
makeUpdate({ version: '2026.4.16-stable.1' }),
)
expect(result.current.status).toEqual({
state: 'available',
version: '2026.4.16-stable.1',
displayVersion: '2026.4.16',
notes: 'Bug fixes and improvements',
})
})
it('returns error when the update check fails', async () => {
const { result, outcome } = await performManualCheck(
'stable',
@@ -231,7 +247,7 @@ describe('useUpdater', () => {
checkResult: makeUpdate(),
downloadImpl: async (args) => {
expect(args.releaseChannel).toBe('stable')
expect(args.expectedVersion).toBe('1.2.0')
expect(args.expectedVersion).toBe('2026.4.16')
args.onEvent.onmessage({ event: 'Started', data: { contentLength: 1000 } })
args.onEvent.onmessage({ event: 'Progress', data: { chunkLength: 500 } })
args.onEvent.onmessage({ event: 'Progress', data: { chunkLength: 500 } })
@@ -249,7 +265,11 @@ describe('useUpdater', () => {
await result.current.actions.startDownload()
})
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
expect(result.current.status).toEqual({
state: 'ready',
version: '2026.4.16',
displayVersion: '2026.4.16',
})
})
it('transitions to error when download fails', async () => {

View File

@@ -9,12 +9,18 @@ import {
import { openExternalUrl } from '../utils/url'
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/tolaria/'
const CALENDAR_VERSION_PATTERN = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-(alpha|stable)\.(\d+))?$/
interface UpdateVersionInfo {
version: string
displayVersion: string
}
export type UpdateStatus =
| { state: 'idle' }
| { state: 'available'; version: string; notes: string | undefined }
| { state: 'downloading'; version: string; progress: number }
| { state: 'ready'; version: string }
| ({ state: 'available'; notes: string | undefined } & UpdateVersionInfo)
| ({ state: 'downloading'; progress: number } & UpdateVersionInfo)
| ({ state: 'ready' } & UpdateVersionInfo)
| { state: 'error' }
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
@@ -26,16 +32,41 @@ export interface UpdateActions {
dismiss: () => void
}
function formatReleaseDisplayVersion(version: string): string {
const normalizedVersion = version.trim()
if (!normalizedVersion) return normalizedVersion
const baseVersion = normalizedVersion.split('+')[0]
const match = baseVersion.match(CALENDAR_VERSION_PATTERN)
if (!match) return baseVersion
const [, year, month, day, channel, sequence] = match
const calendarVersion = `${Number(year)}.${Number(month)}.${Number(day)}`
if (channel === 'alpha' && sequence) {
return `Alpha ${calendarVersion}.${Number(sequence)}`
}
return calendarVersion
}
function createVersionInfo(version: string): UpdateVersionInfo {
return {
version,
displayVersion: formatReleaseDisplayVersion(version),
}
}
function toAvailableStatus(update: AppUpdateMetadata): UpdateStatus {
return {
state: 'available',
version: update.version,
...createVersionInfo(update.version),
notes: update.body ?? undefined,
}
}
function createDownloadProgressHandler(
version: string,
versionInfo: UpdateVersionInfo,
setStatus: Dispatch<SetStateAction<UpdateStatus>>,
): (event: AppUpdateDownloadEvent) => void {
let totalBytes = 0
@@ -50,11 +81,11 @@ function createDownloadProgressHandler(
if (event.event === 'Progress') {
downloadedBytes += event.data.chunkLength
const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1) : 0
setStatus({ state: 'downloading', version, progress })
setStatus({ state: 'downloading', ...versionInfo, progress })
return
}
setStatus({ state: 'ready', version })
setStatus({ state: 'ready', ...versionInfo })
}
}
@@ -94,17 +125,18 @@ export function useUpdater(
const update = updateRef.current
if (!update) return
setStatus({ state: 'downloading', version: update.version, progress: 0 })
const versionInfo = createVersionInfo(update.version)
setStatus({ state: 'downloading', ...versionInfo, progress: 0 })
try {
await downloadAndInstallAppUpdate(
releaseChannel,
update.version,
createDownloadProgressHandler(update.version, setStatus),
createDownloadProgressHandler(versionInfo, setStatus),
)
// If Finished wasn't emitted via callback, set ready after await resolves
setStatus((prev) => (prev.state === 'downloading' ? { state: 'ready', version: update.version } : prev))
setStatus((prev) => (prev.state === 'downloading' ? { state: 'ready', ...versionInfo } : prev))
} catch {
console.warn('[updater] Download failed')
setStatus({ state: 'error' })