diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 2795813..ac8b5f3 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -43,7 +43,18 @@ name: Build App (Preview) # prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a # release; the point of a preview is the build you are testing now. # -# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub. +# A preview release is not meant to reach GitHub. `build-app.yml`'s inline +# mirror never sees one (it only runs for its own `push`-triggered release), +# but `backfill-releases.yml` pulls every Gitea release unfiltered and would +# faithfully forward a preview's `prerelease: true` if it were ever dispatched +# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs` +# is real defence, not a no-op, even though the `preview-` tag shape +# (never valid semver) already blocks it independently. (The previous +# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read +# `gitea.event.release.*` fields that are only ever populated by a `release` +# trigger, so it could never have actually run; deleted rather than fixed, +# since build-app.yml's inline mirror already does what it was meant to do +# for real releases. See triple-c#32.) env: GITEA_URL: ${{ gitea.server_url }} @@ -70,12 +81,23 @@ jobs: outputs: version: ${{ steps.version.outputs.VERSION }} sha: ${{ steps.version.outputs.SHA }} + # Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`). + # The bundle version fields never see this — see "Set app version" in + # each build job — but it is baked into the binary as + # `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it. + # An installed preview otherwise reports the same bare number a + # production build would, indistinguishable in the About panel and to + # `check_for_updates`. See triple-c#32. + suffix: ${{ steps.version.outputs.SUFFIX }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Fetch all tags + run: git fetch --tags + - name: Compute preview version id: version run: | @@ -86,21 +108,60 @@ jobs: # is testing and not something to hang a tag on. echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - # The patch number is computed exactly as build-app.yml does it, so a - # preview is labelled with the version the release it previews would - # carry. This used to be hard-coded `.0`, which made every preview - # installer claim to be x.y.0 no matter what it contained. - LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true) - if [ -n "$LATEST_TAG" ]; then - PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD") - echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)" + # The patch number must be the same "one past the highest patch + # already used" build-app.yml computes for a real release — not a + # distance from the latest tag. It used to be + # `git rev-list --count ..HEAD`, which build-app.yml's + # own history section documents as broken for exactly this reason: + # it resets to zero on every tag cut, so previews went *backwards* + # (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped + # a preview number from later colliding with a real release's. + # + # Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac` + # / `-win` suffixed ones a partially-published release can leave + # behind) means a preview built right before a release computes the + # exact number that release is about to take — e.g. `0.4.13` for + # both. That makes the two numerically *equal*, not "preview less + # than release" — plain semver ordering does not make a + # `-preview.` suffix sort lower on its own here, because + # `check_for_updates` compares against the bare, stripped + # `CARGO_PKG_VERSION`, never the suffixed display string. What + # closes the loop is `update_commands.rs`'s `is_preview_build` + # check, which relaxes that one comparison to `>=` specifically so + # "a release exists at my own number" reads as an update. See + # triple-c#32. + HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \ + | grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \ + | sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \ + | sort -n | tail -1 || true) + + # Mirrors build-app.yml's own `EXISTING` guard: this workflow is + # also `workflow_dispatch`-able on `main`, not just PR-triggered, so + # HEAD can be a commit a release was already cut from. Without this, + # dispatching a preview there would compute `HIGHEST + 1` — one past + # that release — and produce exactly the "preview outranks + # production" failure triple-c#32 was filed over, just reintroduced + # through the manual-dispatch door instead of the automatic one. + EXISTING=$(git tag --points-at HEAD \ + | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \ + | sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \ + | sort -n | tail -1 || true) + + if [ -n "$EXISTING" ]; then + echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it" + PATCH="${EXISTING}" + elif [ -n "$HIGHEST" ]; then + echo "Highest patch already used on this line: ${HIGHEST}" + PATCH=$((HIGHEST + 1)) else echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0" PATCH=0 fi - VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}" + SUFFIX="preview.${SHORT_SHA}" + VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}" echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT echo "Computed preview version: ${VERSION}" # One release, created once. The three build jobs run concurrently, so @@ -249,6 +310,13 @@ jobs: - name: Build Tauri app working-directory: ./app + env: + # Baked into the binary via `option_env!` in `get_app_version()` — + # the bundle version above stays bare (WiX/MSI's ProductVersion has + # no room for a suffix), so this is the only place a preview build + # can still tell itself apart from a production one. See + # triple-c#32. + TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} run: | export PATH="$HOME/.cargo/bin:$PATH" npx tauri build @@ -361,6 +429,9 @@ jobs: - name: Build Tauri app (universal) working-directory: ./app + env: + # See the matching comment on the Linux job's "Build Tauri app" step. + TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} run: | export PATH="$HOME/.cargo/bin:$PATH" npx tauri build --target universal-apple-darwin @@ -489,6 +560,8 @@ jobs: working-directory: ./app env: TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}" + # See the matching comment on the Linux job's "Build Tauri app" step. + TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" cargo tauri build diff --git a/.gitea/workflows/sync-release.yml b/.gitea/workflows/sync-release.yml deleted file mode 100644 index 093a408..0000000 --- a/.gitea/workflows/sync-release.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Sync Release to GitHub - -on: - workflow_dispatch: - -jobs: - sync-release: - runs-on: ubuntu-latest - steps: - - name: Mirror release to GitHub - env: - GH_PAT: ${{ secrets.GH_PAT }} - GITHUB_REPO: shadowdao/triple-c - RELEASE_TAG: ${{ gitea.event.release.tag_name }} - RELEASE_NAME: ${{ gitea.event.release.name }} - RELEASE_BODY: ${{ gitea.event.release.body }} - IS_PRERELEASE: ${{ gitea.event.release.prerelease }} - IS_DRAFT: ${{ gitea.event.release.draft }} - run: | - set -e - - echo "==> Creating release $RELEASE_TAG on GitHub..." - - RESPONSE=$(curl -sf -X POST \ - -H "Authorization: Bearer $GH_PAT" \ - -H "Accept: application/vnd.github+json" \ - -H "Content-Type: application/json" \ - https://api.github.com/repos/$GITHUB_REPO/releases \ - -d "{ - \"tag_name\": \"$RELEASE_TAG\", - \"name\": \"$RELEASE_NAME\", - \"body\": $(echo "$RELEASE_BODY" | jq -Rs .), - \"draft\": $IS_DRAFT, - \"prerelease\": $IS_PRERELEASE - }") - - UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//') - echo "Release created. Upload URL: $UPLOAD_URL" - - echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do - ASSET_NAME=$(echo "$asset" | jq -r '.name') - ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url') - - echo "==> Downloading asset: $ASSET_NAME" - curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL" - - echo "==> Uploading $ASSET_NAME to GitHub..." - ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME") - curl -sf -X POST \ - -H "Authorization: Bearer $GH_PAT" \ - -H "Accept: application/vnd.github+json" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@/tmp/$ASSET_NAME" \ - "$UPLOAD_URL?name=$ENCODED_NAME" - - echo " Uploaded: $ASSET_NAME" - done - - echo "==> Release sync complete." \ No newline at end of file diff --git a/TECHNICAL.md b/TECHNICAL.md index acf2db1..26513e8 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -412,11 +412,10 @@ triple-c/ │ ├── .gitea/ │ └── workflows/ -│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows) +│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline │ ├── build-app-preview.yml # Preview builds │ ├── build.yml # Build container image (multi-arch) │ ├── build-stt.yml # Build the STT image -│ ├── sync-release.yml # Mirror releases to GitHub │ ├── backfill-releases.yml # Bulk copy releases to GitHub │ └── cleanup-releases.yml # Prune old releases │ diff --git a/app/src-tauri/src/commands/update_commands.rs b/app/src-tauri/src/commands/update_commands.rs index dc2d79c..c3d571a 100644 --- a/app/src-tauri/src/commands/update_commands.rs +++ b/app/src-tauri/src/commands/update_commands.rs @@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str = const GHCR_TOKEN_URL: &str = "https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull"; +/// The build-time preview suffix, if one was baked in and isn't blank. +/// +/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`) +/// is never given a `-preview.` suffix — `build-app-preview.yml` strips +/// it before patching those files, because the Windows MSI's `ProductVersion` +/// is a fixed-width numeric field with no room for one, and nothing here can +/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX` +/// is the workaround: set as a build-time env var in the preview workflow +/// only, so `option_env!` bakes it into the binary without the bundle version +/// ever seeing it. A production build sets nothing, so `option_env!` reads +/// `None` here — see triple-c#32. +/// +/// The single source of truth for "is this a preview build": both +/// `get_app_version()` (what the About panel shows) and `check_for_updates()` +/// (whether a same-numbered release counts as an update — see `pick_update`) +/// read this rather than each calling `option_env!` themselves, so the two +/// can never silently disagree about which build this is. +fn preview_build_suffix() -> Option<&'static str> { + option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty()) +} + +fn format_app_version(base: &str, build_suffix: Option<&str>) -> String { + match build_suffix { + Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix), + _ => base.to_string(), + } +} + #[tauri::command] pub fn get_app_version() -> String { - env!("CARGO_PKG_VERSION").to_string() + format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix()) } #[tauri::command] @@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result, String> { &[".AppImage", ".deb", ".rpm"] }; - // Filter releases that have at least one asset matching the current platform - let platform_releases: Vec<&GitHubRelease> = releases - .iter() - .filter(|r| { - r.assets.iter().any(|a| { - platform_extensions.iter().any(|ext| a.name.ends_with(ext)) - }) - }) - .collect(); + // `current_version` above is always the bare, stripped `CARGO_PKG_VERSION` + // — the preview workflow patches `Cargo.toml` with that before compiling, + // never the `-preview.`-suffixed one `get_app_version()` reports — + // so a preview build and the release it precedes compile to the identical + // numeric tuple by construction (see `build-app-preview.yml`'s "highest + // tag used, +1" computation). A strict `>` therefore never fires for the + // one release a preview most needs to be offered. `is_preview_build` + // relaxes that one comparison to `>=` so "there is a real release at my + // own number" reads as an update, without touching the production case + // — see `pick_update`. + let is_preview_build = preview_build_suffix().is_some(); - // Find the latest release with a higher semver version - let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None; - for release in &platform_releases { - if let Some(ver) = parse_semver_from_tag(&release.tag_name) { - if ver > current_semver { - if best.is_none() || ver > best.unwrap().1 { - best = Some((release, ver)); - } - } - } - } - - match best { - Some((release, _)) => { + match pick_update(&releases, current_semver, platform_extensions, is_preview_build) { + Some(release) => { // Only include assets matching the current platform let assets = release .assets @@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result, String> { } } +/// Pick the newest available update out of a release list, or `None` if +/// nothing beats `current_semver`. Pure and synchronous — split out of +/// `check_for_updates` so the prerelease/platform/version filtering can be +/// tested without a live HTTP call. +/// +/// Three filters, all of which must pass: not a prerelease (see the long +/// comment on `GitHubRelease::prerelease`), at least one asset for this +/// platform, and a tag that parses as semver *and* beats what is running. A +/// tag that does not parse — `preview-` (the shape +/// `build-app-preview.yml` actually creates release tags with), most +/// realistically — is skipped rather than erroring, the same as it always +/// has been; nothing here changes what an update tag is expected to look +/// like, only what channel it is allowed to come from. +/// +/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's +/// `current_semver` is the bare number it was compiled with, which is by +/// construction identical to the release it precedes — see the comment at +/// `check_for_updates`'s call site — so a strict `>` would never fire for +/// exactly the release a preview install most needs to be told about. +fn pick_update<'a>( + releases: &'a [GitHubRelease], + current_semver: (u32, u32, u32), + platform_extensions: &[&str], + is_preview_build: bool, +) -> Option<&'a GitHubRelease> { + releases + .iter() + .filter(|r| !r.prerelease) + .filter(|r| { + r.assets + .iter() + .any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext))) + }) + .filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver))) + .filter(|(_, ver)| { + if is_preview_build { + *ver >= current_semver + } else { + *ver > current_semver + } + }) + .max_by_key(|(_, ver)| *ver) + .map(|(r, _)| r) +} + /// Parse a semver string like "0.2.5" -> (0, 2, 5) fn parse_semver(version: &str) -> Option<(u32, u32, u32)> { let clean = version.trim_start_matches('v'); @@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option { Some(format!("{}.{}.{}", major, minor, patch)) } +#[cfg(test)] +mod tests { + use super::*; + use crate::models::GitHubAsset; + + // ── format_app_version ────────────────────────────────────────────── + + #[test] + fn a_production_build_reports_the_bare_version() { + assert_eq!(format_app_version("0.4.12", None), "0.4.12"); + // An empty env var (set but blank) must not print a trailing dash. + assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12"); + } + + #[test] + fn a_preview_build_reports_its_suffix() { + assert_eq!( + format_app_version("0.4.12", Some("preview.a1b2c3d")), + "0.4.12-preview.a1b2c3d" + ); + } + + // ── pick_update ────────────────────────────────────────────────────── + + fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease { + GitHubRelease { + tag_name: tag.to_string(), + html_url: format!("https://example.invalid/{}", tag), + body: String::new(), + assets: asset_names + .iter() + .map(|name| GitHubAsset { + name: name.to_string(), + browser_download_url: String::new(), + size: 0, + }) + .collect(), + published_at: "2026-01-01T00:00:00Z".to_string(), + prerelease, + } + } + + const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"]; + + #[test] + fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() { + let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])]; + assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none()); + } + + #[test] + fn a_release_with_no_asset_for_this_platform_is_skipped() { + let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])]; + assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none()); + } + + #[test] + fn a_release_that_is_not_newer_is_not_offered() { + let releases = vec![release("v0.4.10", false, &["app.AppImage"])]; + assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none()); + } + + #[test] + fn an_untagged_or_unparseable_release_is_skipped_not_fatal() { + // A `-preview.` tag is exactly the shape this must not choke on + // or mistake for an update — it simply never parses as a bare semver. + let releases = vec![ + release("preview-a1b2c3d", false, &["app.AppImage"]), + release("v0.4.12", false, &["app.AppImage"]), + ]; + let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap(); + assert_eq!(best.tag_name, "v0.4.12"); + } + + #[test] + fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() { + let releases = vec![ + release("v0.4.11", false, &["app.AppImage"]), + release("v0.4.13", false, &["app.AppImage"]), + release("v0.4.12", false, &["app.AppImage"]), + ]; + let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap(); + assert_eq!(best.tag_name, "v0.4.13"); + } + + // ── is_preview_build (>= instead of >) ───────────────────────────────── + + /// The exact scenario triple-c#32 was filed to fix: a preview compiled as + /// `0.4.12-preview.` (bare `CARGO_PKG_VERSION` "0.4.12") must be + /// offered the `v0.4.12` release that follows it, even though the two + /// compute to the identical numeric tuple. + #[test] + fn a_preview_build_is_offered_the_release_it_precedes() { + let releases = vec![release("v0.4.12", false, &["app.AppImage"])]; + assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none()); + let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap(); + assert_eq!(best.tag_name, "v0.4.12"); + } + + #[test] + fn a_preview_build_is_not_offered_an_older_release() { + let releases = vec![release("v0.4.11", false, &["app.AppImage"])]; + assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none()); + } + + #[test] + fn a_production_build_still_requires_strictly_newer() { + // A production build must never treat "equal" as an update — that + // would perpetually re-offer the version already running. + let releases = vec![release("v0.4.12", false, &["app.AppImage"])]; + assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none()); + } +} + /// Check whether a newer container image is available in the registry. /// /// Compares the local image digest with the remote registry digest using the diff --git a/app/src-tauri/src/models/update_info.rs b/app/src-tauri/src/models/update_info.rs index e9b444e..17069d8 100644 --- a/app/src-tauri/src/models/update_info.rs +++ b/app/src-tauri/src/models/update_info.rs @@ -26,6 +26,24 @@ pub struct GitHubRelease { pub body: String, pub assets: Vec, pub published_at: String, + /// Whether GitHub itself has this release marked as a prerelease. + /// `#[serde(default)]` rather than required: every response GitHub sends + /// carries this, but nothing here should refuse to parse the rest of a + /// release over one missing field. Defaults to `false` (offered) rather + /// than `true` (excluded) — a missing field only happens if GitHub's API + /// shape changes, and "API changed, therefore updates silently stop + /// working forever" is the worse failure of the two. + /// + /// `build-app.yml`'s own mirror never publishes a prerelease, but + /// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release + /// unfiltered, `prerelease` included. A preview release's `preview-` + /// tag already fails semver parsing on its own, so this field is not what + /// stops *that* case — it is what stops the case tag-parsing can't catch: + /// a normally-tagged release (`v0.4.13`) that someone marks as a + /// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then + /// mirrors as-is. Real defence for that case, not a no-op. + #[serde(default)] + pub prerelease: bool, } /// GitHub API asset response (internal).