From 901560467f51b5a502546e7830f9dcb76d7f6632 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 8 May 2026 11:18:20 +0200 Subject: [PATCH] fix: harden alpha updater metadata lookup --- .github/workflows/release-stable.yml | 41 +++- .github/workflows/release.yml | 41 +++- docs/ABSTRACTIONS.md | 6 +- docs/GETTING-STARTED.md | 4 +- src-tauri/src/app_updater.rs | 268 +++++++++++++++++++++++---- vite.config.ts | 1 + 6 files changed, 316 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index f6fb7f37..ca69f036 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -521,6 +521,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pages: write steps: - uses: actions/checkout@v4 with: @@ -767,9 +768,13 @@ jobs: cp -R site/.vitepress/dist/. _site/ if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json - PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}" + ALPHA_TAG=$(gh release list --repo ${{ github.repository }} --exclude-drafts --limit 100 --json tagName,isPrerelease --jq '[.[] | select(.isPrerelease and (.tagName | test("^alpha-v[0-9]+\\.[0-9]+\\.[0-9]+-alpha\\.[0-9]+$")))][0].tagName // ""') - curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json + if [ -n "$ALPHA_TAG" ]; then + gh release download --repo ${{ github.repository }} "$ALPHA_TAG" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json + else + echo '{}' > _site/alpha/latest.json + fi gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html @@ -785,3 +790,35 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./_site commit_message: "Update release history for ${{ needs.version.outputs.tag }}" + + - name: Request and verify GitHub Pages rebuild + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BUILD_URL=$(gh api --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "repos/${{ github.repository }}/pages/builds" \ + --jq '.url') + BUILD_ID="${BUILD_URL##*/}" + if [ -z "$BUILD_ID" ]; then + echo "::error::GitHub Pages rebuild request did not return a build id." + exit 1 + fi + + for attempt in {1..30}; do + STATUS=$(gh api "repos/${{ github.repository }}/pages/builds/${BUILD_ID}" --jq '.status') + echo "GitHub Pages build ${BUILD_ID} status: ${STATUS} (attempt ${attempt}/30)" + case "$STATUS" in + built) + exit 0 + ;; + errored) + exit 1 + ;; + esac + sleep 10 + done + + echo "::error::GitHub Pages build ${BUILD_ID} did not finish within 5 minutes." + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9211358..8adfd8fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -577,6 +577,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pages: write steps: - uses: actions/checkout@v4 with: @@ -813,10 +814,14 @@ jobs: cp -R site/.vitepress/dist/. _site/ if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json - PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}" + STABLE_TAG=$(gh release list --repo ${{ github.repository }} --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // ""') gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json - curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json + if [ -n "$STABLE_TAG" ]; then + gh release download --repo ${{ github.repository }} "$STABLE_TAG" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json + else + echo '{}' > _site/stable/latest.json + fi bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html mkdir -p _site/download @@ -831,3 +836,35 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./_site commit_message: "Update release history for ${{ needs.version.outputs.tag }}" + + - name: Request and verify GitHub Pages rebuild + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BUILD_URL=$(gh api --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "repos/${{ github.repository }}/pages/builds" \ + --jq '.url') + BUILD_ID="${BUILD_URL##*/}" + if [ -z "$BUILD_ID" ]; then + echo "::error::GitHub Pages rebuild request did not return a build id." + exit 1 + fi + + for attempt in {1..30}; do + STATUS=$(gh api "repos/${{ github.repository }}/pages/builds/${BUILD_ID}" --jq '.status') + echo "GitHub Pages build ${BUILD_ID} status: ${STATUS} (attempt ${attempt}/30)" + case "$STATUS" in + built) + exit 0 + ;; + errored) + exit 1 + ;; + esac + sleep 10 + done + + echo "::error::GitHub Pages build ${BUILD_ID} did not finish within 5 minutes." + exit 1 diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 4ba33bdd..e1e740b4 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -885,7 +885,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins - **`src/lib/appUpdater.ts`** — Thin wrapper around the Tauri updater commands. Keeps the React hook free of endpoint-selection details. ### 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/app_updater.rs`** — Chooses the correct update endpoint and adapts Tauri updater results into frontend-friendly payloads. Stable uses the public `stable/latest.json` feed. Alpha first resolves the newest non-draft `alpha-vYYYY.M.D-alpha.NNNN` GitHub Release asset named `alpha-latest.json`, then falls back to the public `alpha/latest.json` feed if the release lookup is unavailable. - **`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 @@ -893,6 +893,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins - **`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` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria__macOS_Silicon` and `Tolaria__macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds. -- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. Stable macOS DMG/updater assets use the same `Tolaria__macOS_Silicon` and `Tolaria__macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release. +- **`.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. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, then explicitly requests and verifies a GitHub Pages rebuild after deploying `gh-pages`. macOS release assets use `Tolaria__macOS_Silicon` and `Tolaria__macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds. +- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, then explicitly requests and verifies a GitHub Pages rebuild after deploying `gh-pages`. Stable macOS DMG/updater assets use the same `Tolaria__macOS_Silicon` and `Tolaria__macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release. - **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 9d9d0fc3..bc231419 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -214,7 +214,7 @@ tolaria/ │ │ ├── codex_cli.rs # Codex CLI adapter │ │ ├── pi_cli.rs # Pi CLI adapter │ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal -│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection +│ │ ├── app_updater.rs # Alpha/stable updater metadata resolution │ │ ├── settings.rs # App settings persistence │ │ ├── vault_config.rs # Per-vault UI config │ │ ├── vault_list.rs # Vault list persistence @@ -286,7 +286,7 @@ tolaria/ | `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. | | `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. | | `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. | -| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. | +| `src-tauri/src/app_updater.rs` | Desktop updater bridge — resolves alpha/stable manifests and streams install progress. | ### Editor diff --git a/src-tauri/src/app_updater.rs b/src-tauri/src/app_updater.rs index 99ab94dd..60581432 100644 --- a/src-tauri/src/app_updater.rs +++ b/src-tauri/src/app_updater.rs @@ -1,8 +1,14 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use std::time::Duration; use tauri::{ipc::Channel, AppHandle, Runtime, Url}; use tauri_plugin_updater::UpdaterExt; +const ALPHA_METADATA_ASSET_NAME: &str = "alpha-latest.json"; +const GITHUB_RELEASES_API_URL: &str = + "https://api.github.com/repos/refactoringhq/tolaria/releases?per_page=100"; const RELEASES_BASE_URL: &str = "https://refactoringhq.github.io/tolaria"; +const UPDATER_HTTP_TIMEOUT: Duration = Duration::from_secs(5); +const UPDATER_USER_AGENT: &str = concat!("Tolaria/", env!("CARGO_PKG_VERSION")); #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -33,6 +39,27 @@ enum ReleaseChannel { Stable, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct AlphaReleaseVersion { + year: i32, + month: u32, + day: u32, + sequence: u32, +} + +#[derive(Debug, Clone, Deserialize)] +struct GitHubRelease { + tag_name: String, + draft: bool, + assets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + impl ReleaseChannel { fn from_settings_value(value: Option<&str>) -> Self { match crate::settings::effective_release_channel(value) { @@ -54,13 +81,94 @@ impl ReleaseChannel { } } +impl AlphaReleaseVersion { + fn parse_tag(tag_name: &str) -> Option { + let release = tag_name.strip_prefix("alpha-v")?; + let (date, sequence) = release.split_once("-alpha.")?; + let sequence = sequence.parse().ok()?; + let (year, month, day) = parse_calendar_date(date)?; + chrono::NaiveDate::from_ymd_opt(year, month, day)?; + + Some(Self { + year, + month, + day, + sequence, + }) + } +} + +fn parse_calendar_date(value: &str) -> Option<(i32, u32, u32)> { + let mut parts = value.split('.'); + let year = parts.next()?.parse().ok()?; + let month = parts.next()?.parse().ok()?; + let day = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + + Some((year, month, day)) +} + +fn latest_alpha_release_metadata_url(releases: &[GitHubRelease]) -> Option { + releases + .iter() + .filter(|release| !release.draft) + .filter_map(alpha_release_metadata_candidate) + .max_by_key(|(version, _)| *version) + .map(|(_, url)| url) +} + +fn alpha_release_metadata_candidate(release: &GitHubRelease) -> Option<(AlphaReleaseVersion, Url)> { + let version = AlphaReleaseVersion::parse_tag(&release.tag_name)?; + let asset = release + .assets + .iter() + .find(|asset| asset.name == ALPHA_METADATA_ASSET_NAME)?; + let url = Url::parse(&asset.browser_download_url).ok()?; + + Some((version, url)) +} + +async fn alpha_release_metadata_endpoint() -> Result { + let client = reqwest::Client::builder() + .timeout(UPDATER_HTTP_TIMEOUT) + .user_agent(UPDATER_USER_AGENT) + .build() + .map_err(|e| format!("Failed to create updater metadata client: {e}"))?; + + let releases = client + .get(GITHUB_RELEASES_API_URL) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("Failed to fetch GitHub releases: {e}"))? + .error_for_status() + .map_err(|e| format!("GitHub releases request failed: {e}"))? + .json::>() + .await + .map_err(|e| format!("Failed to parse GitHub releases: {e}"))?; + + latest_alpha_release_metadata_url(&releases) + .ok_or_else(|| "No alpha updater metadata asset found in GitHub releases".to_string()) +} + +async fn updater_endpoint(release_channel: ReleaseChannel) -> Result { + match release_channel { + ReleaseChannel::Stable => release_channel.updater_endpoint(), + ReleaseChannel::Alpha => alpha_release_metadata_endpoint() + .await + .or_else(|_| release_channel.updater_endpoint()), + } +} + fn build_updater( app_handle: &AppHandle, - release_channel: ReleaseChannel, + endpoint: Url, ) -> Result { app_handle .updater_builder() - .endpoints(vec![release_channel.updater_endpoint()?]) + .endpoints(vec![endpoint]) .map_err(|e| format!("Failed to configure updater endpoint: {e}"))? .build() .map_err(|e| format!("Failed to build updater: {e}")) @@ -75,41 +183,24 @@ fn to_update_metadata(update: tauri_plugin_updater::Update) -> AppUpdateMetadata } } -pub async fn check_for_app_update( - app_handle: AppHandle, - release_channel: Option, -) -> Result, String> { - let channel = ReleaseChannel::from_settings_value(release_channel.as_deref()); - let updater = build_updater(&app_handle, channel)?; - let update = updater - .check() - .await - .map_err(|e| format!("Failed to check for updates: {e}"))?; - - Ok(update.map(to_update_metadata)) -} - -pub async fn download_and_install_app_update( - app_handle: AppHandle, - release_channel: Option, - expected_version: String, - on_event: Channel, +fn ensure_expected_update_version( + update: &tauri_plugin_updater::Update, + expected_version: &str, ) -> Result<(), String> { - let channel = ReleaseChannel::from_settings_value(release_channel.as_deref()); - let updater = build_updater(&app_handle, channel)?; - let update = updater - .check() - .await - .map_err(|e| format!("Failed to refresh update metadata: {e}"))? - .ok_or_else(|| "No update is currently available".to_string())?; - - if update.version != expected_version { - return Err(format!( - "Expected update version {}, found {}", - expected_version, update.version - )); + if update.version == expected_version { + return Ok(()); } + Err(format!( + "Expected update version {}, found {}", + expected_version, update.version + )) +} + +async fn install_update( + update: tauri_plugin_updater::Update, + on_event: Channel, +) -> Result<(), String> { let mut started = false; update .download_and_install( @@ -129,9 +220,45 @@ pub async fn download_and_install_app_update( .map_err(|e| format!("Failed to download and install update: {e}")) } +pub async fn check_for_app_update( + app_handle: AppHandle, + release_channel: Option, +) -> Result, String> { + let channel = ReleaseChannel::from_settings_value(release_channel.as_deref()); + let updater = build_updater(&app_handle, updater_endpoint(channel).await?)?; + let update = updater + .check() + .await + .map_err(|e| format!("Failed to check for updates: {e}"))?; + + Ok(update.map(to_update_metadata)) +} + +pub async fn download_and_install_app_update( + app_handle: AppHandle, + release_channel: Option, + expected_version: String, + on_event: Channel, +) -> Result<(), String> { + let channel = ReleaseChannel::from_settings_value(release_channel.as_deref()); + let updater = build_updater(&app_handle, updater_endpoint(channel).await?)?; + let update = updater + .check() + .await + .map_err(|e| format!("Failed to refresh update metadata: {e}"))? + .ok_or_else(|| "No update is currently available".to_string())?; + + ensure_expected_update_version(&update, &expected_version)?; + + install_update(update, on_event).await +} + #[cfg(test)] mod tests { - use super::{AppUpdateDownloadEvent, AppUpdateMetadata, ReleaseChannel}; + use super::{ + latest_alpha_release_metadata_url, AppUpdateDownloadEvent, AppUpdateMetadata, GitHubAsset, + GitHubRelease, ReleaseChannel, + }; use serde_json::json; #[test] @@ -178,6 +305,64 @@ mod tests { ); } + #[test] + fn alpha_release_metadata_url_uses_highest_calendar_sequence_tag() { + let releases = vec![ + github_alpha_release( + "alpha-v2026.5.8-alpha.0007", + "https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0007/alpha-latest.json", + ), + github_alpha_release( + "alpha-v2026.5.8-alpha.0017", + "https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0017/alpha-latest.json", + ), + github_alpha_release( + "alpha-v2026.5.7-alpha.0099", + "https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.7-alpha.0099/alpha-latest.json", + ), + ]; + + assert_eq!( + latest_alpha_release_metadata_url(&releases) + .unwrap() + .as_str(), + "https://github.com/refactoringhq/tolaria/releases/download/alpha-v2026.5.8-alpha.0017/alpha-latest.json" + ); + } + + #[test] + fn alpha_release_metadata_url_ignores_drafts_and_non_alpha_assets() { + let releases = vec![ + GitHubRelease { + tag_name: "alpha-v2026.5.8-alpha.0018".into(), + draft: true, + assets: vec![GitHubAsset { + name: "alpha-latest.json".into(), + browser_download_url: "https://example.com/draft.json".into(), + }], + }, + GitHubRelease { + tag_name: "stable-v2026.5.8".into(), + draft: false, + assets: vec![GitHubAsset { + name: "stable-latest.json".into(), + browser_download_url: "https://example.com/stable.json".into(), + }], + }, + github_alpha_release( + "alpha-v2026.5.8-alpha.0017", + "https://example.com/alpha-latest.json", + ), + ]; + + assert_eq!( + latest_alpha_release_metadata_url(&releases) + .unwrap() + .as_str(), + "https://example.com/alpha-latest.json" + ); + } + #[test] fn update_metadata_serializes_for_frontend_consumers() { let metadata = AppUpdateMetadata { @@ -227,4 +412,15 @@ mod tests { assert_eq!(serde_json::to_value(event).unwrap(), expected); } } + + fn github_alpha_release(tag_name: &str, browser_download_url: &str) -> GitHubRelease { + GitHubRelease { + tag_name: tag_name.into(), + draft: false, + assets: vec![GitHubAsset { + name: "alpha-latest.json".into(), + browser_download_url: browser_download_url.into(), + }], + } + } } diff --git a/vite.config.ts b/vite.config.ts index d6ee208e..1983a59e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -938,6 +938,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], + testTimeout: 10_000, coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'],