From b71e15c2c02306229b737e113fbcf5612f0a1265 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 27 Aug 2026 10:11:35 -0700 Subject: [PATCH 1/3] Make preview versions monotonic and distinguishable from production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-app-preview.yml computed its patch number as `git rev-list --count ..HEAD` — the exact formula build-app.yml itself documents as broken and replaced (#26): a distance from whichever tag sorts highest, not a counter, so it resets to zero on every release and previews went backwards (0.4.62 -> 0.4.0) the moment one landed. Ported the same "one past the highest patch already used" computation build-app.yml uses for real releases, reading the same tags (including -mac/-win suffixes), so a preview built right before a release now computes the exact number that release is about to take — semver already orders `0.4.12-preview. < 0.4.12`, so a preview user is offered the release the moment it ships instead of being silently pinned forever. The installed preview's reported version was also indistinguishable from production: the bundle's own version field strips the `-preview.` suffix before touching tauri.conf.json/Cargo.toml/package.json, since the Windows MSI's ProductVersion has no room for one. Rather than risk that (unverifiable without an actual Windows build), preview builds now bake the suffix into the binary separately via a TRIPLE_C_BUILD_SUFFIX build-time env var, and get_app_version() appends it when present — a production build sets nothing, so this is a no-op there. Also: added `prerelease` to `GitHubRelease` and filter on it in check_for_updates (currently a no-op against real data — nothing mirrored to GitHub is ever prerelease:true — but the updater is no longer structurally incapable of enforcing a channel split if one is ever made explicit). And deleted sync-release.yml: workflow_dispatch-only, reading gitea.event.release.* fields a manual dispatch never populates, so it could never have actually run; build-app.yml's inline mirror already does the same job. Refactored check_for_updates' filtering into a pure, testable pick_update helper (this file had no tests before), and added tests for it and the new get_app_version suffix handling. Fixes #32. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ --- .gitea/workflows/build-app-preview.yml | 66 +++++-- .gitea/workflows/sync-release.yml | 59 ------- TECHNICAL.md | 3 +- app/src-tauri/src/commands/update_commands.rs | 163 +++++++++++++++--- app/src-tauri/src/models/update_info.rs | 9 + 5 files changed, 204 insertions(+), 96 deletions(-) delete mode 100644 .gitea/workflows/sync-release.yml diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 2795813..70ffc2c 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -43,7 +43,13 @@ 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. +# Nothing here reaches GitHub: `build-app.yml` is the only workflow that +# mirrors a release there, and it does so inline, gated on `prerelease: false` +# — a preview release is never a candidate. (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 its job. See triple-c#32.) env: GITEA_URL: ${{ gitea.server_url }} @@ -70,12 +76,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 +103,38 @@ 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 — so semver already + # orders `0.4.12-preview. < 0.4.12`, and a preview user is + # offered the real release the moment it ships. 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) + + if [ -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 +283,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 +402,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 +533,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..8ead9ce 100644 --- a/app/src-tauri/src/commands/update_commands.rs +++ b/app/src-tauri/src/commands/update_commands.rs @@ -16,9 +16,27 @@ const REGISTRY_API_BASE: &str = const GHCR_TOKEN_URL: &str = "https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull"; +/// `CARGO_PKG_VERSION`, plus a build-time suffix when one was baked in. +/// +/// 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` and this is a no-op — see triple-c#32. +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"), option_env!("TRIPLE_C_BUILD_SUFFIX")) } #[tauri::command] @@ -51,30 +69,8 @@ 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(); - - // 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) { + Some(release) => { // Only include assets matching the current platform let assets = release .assets @@ -105,6 +101,37 @@ 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* is newer than what is +/// running. A tag that does not parse — a `-preview.` suffix, 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. +fn pick_update<'a>( + releases: &'a [GitHubRelease], + current_semver: (u32, u32, u32), + platform_extensions: &[&str], +) -> 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)| *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 +158,92 @@ 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).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).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).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).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).unwrap(); + assert_eq!(best.tag_name, "v0.4.13"); + } +} + /// 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..0bcc0f9 100644 --- a/app/src-tauri/src/models/update_info.rs +++ b/app/src-tauri/src/models/update_info.rs @@ -26,6 +26,15 @@ 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. No production release is ever + /// mirrored with this `true` today — see `check_for_updates`, which + /// filters on it explicitly rather than relying on that being an + /// accident of what happens to get mirrored. + #[serde(default)] + pub prerelease: bool, } /// GitHub API asset response (internal). -- 2.52.0 From 945883bb9ddfdbcf593d3d7283a14e6ba0ddf216 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 27 Aug 2026 10:24:24 -0700 Subject: [PATCH 2/3] Actually offer a preview the release it precedes, and fix two more gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Opus review of the previous commit found its headline claim didn't hold: a preview and the release it precedes compute to the identical numeric version by construction, but check_for_updates compared with a strict `>` against the bare CARGO_PKG_VERSION (never the suffixed display string), so `(0,4,13) > (0,4,13)` is false and the release was never offered. Plain semver ordering doesn't make a `-preview.` suffix sort below the same numeric release on its own here, since the comparison never sees the suffix at all. pick_update now takes is_preview_build, derived from whether TRIPLE_C_BUILD_SUFFIX was baked in, and relaxes that one comparison to `>=` — so "a release exists at my own number" reads as an update. A production build still requires strictly newer. Also: ported build-app.yml's `git tag --points-at HEAD` guard into the preview version computation. Without it, workflow_dispatch (which this workflow allows on main, not just PR builds) run on a commit a release was already cut from would compute one past that release — reintroducing "preview outranks production" through the manual-dispatch door. And corrected two comments that claimed the prerelease filter was currently a no-op: backfill-releases.yml mirrors every Gitea release to GitHub unfiltered, prerelease flag included, so it's real defence-in-depth against a dispatched backfill leaking a preview release, not a no-op. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ --- .gitea/workflows/build-app-preview.yml | 45 ++++++++--- app/src-tauri/src/commands/update_commands.rs | 77 ++++++++++++++++--- app/src-tauri/src/models/update_info.rs | 16 +++- 3 files changed, 113 insertions(+), 25 deletions(-) diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 70ffc2c..ac8b5f3 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -43,13 +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. # -# Nothing here reaches GitHub: `build-app.yml` is the only workflow that -# mirrors a release there, and it does so inline, gated on `prerelease: false` -# — a preview release is never a candidate. (The previous mechanism here, -# `sync-release.yml`, was `workflow_dispatch`-only and read +# 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 its job. See triple-c#32.) +# 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 }} @@ -115,15 +120,37 @@ jobs: # 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 — so semver already - # orders `0.4.12-preview. < 0.4.12`, and a preview user is - # offered the real release the moment it ships. See triple-c#32. + # 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) - if [ -n "$HIGHEST" ]; then + # 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 diff --git a/app/src-tauri/src/commands/update_commands.rs b/app/src-tauri/src/commands/update_commands.rs index 8ead9ce..3f99e64 100644 --- a/app/src-tauri/src/commands/update_commands.rs +++ b/app/src-tauri/src/commands/update_commands.rs @@ -69,7 +69,19 @@ pub async fn check_for_updates() -> Result, String> { &[".AppImage", ".deb", ".rpm"] }; - match pick_update(&releases, current_semver, platform_extensions) { + // `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 = option_env!("TRIPLE_C_BUILD_SUFFIX").is_some_and(|s| !s.is_empty()); + + match pick_update(&releases, current_semver, platform_extensions, is_preview_build) { Some(release) => { // Only include assets matching the current platform let assets = release @@ -108,15 +120,22 @@ pub async fn check_for_updates() -> Result, String> { /// /// 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* is newer than what is -/// running. A tag that does not parse — a `-preview.` suffix, 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. +/// platform, and a tag that parses as semver *and* beats what is running. A +/// tag that does not parse — a `-preview.` suffix, 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() @@ -127,7 +146,13 @@ fn pick_update<'a>( .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)| *ver > current_semver) + .filter(|(_, ver)| { + if is_preview_build { + *ver >= current_semver + } else { + *ver > current_semver + } + }) .max_by_key(|(_, ver)| *ver) .map(|(r, _)| r) } @@ -205,19 +230,19 @@ mod tests { #[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).is_none()); + 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).is_none()); + 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).is_none()); + assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none()); } #[test] @@ -228,7 +253,7 @@ mod tests { release("preview-a1b2c3d", false, &["app.AppImage"]), release("v0.4.12", false, &["app.AppImage"]), ]; - let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS).unwrap(); + let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap(); assert_eq!(best.tag_name, "v0.4.12"); } @@ -239,9 +264,37 @@ mod tests { release("v0.4.13", false, &["app.AppImage"]), release("v0.4.12", false, &["app.AppImage"]), ]; - let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS).unwrap(); + 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. diff --git a/app/src-tauri/src/models/update_info.rs b/app/src-tauri/src/models/update_info.rs index 0bcc0f9..478bf3b 100644 --- a/app/src-tauri/src/models/update_info.rs +++ b/app/src-tauri/src/models/update_info.rs @@ -29,10 +29,18 @@ pub struct GitHubRelease { /// 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. No production release is ever - /// mirrored with this `true` today — see `check_for_updates`, which - /// filters on it explicitly rather than relying on that being an - /// accident of what happens to get mirrored. + /// 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 — so if it were ever dispatched + /// while a preview release existed, this field is what stops + /// `check_for_updates` from offering it (the `preview-` tag shape + /// already fails semver parsing independently, but this is real + /// defence-in-depth, not a no-op). #[serde(default)] pub prerelease: bool, } -- 2.52.0 From 049232099b8ddc14cd07c6918c94b04107366ba6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 27 Aug 2026 10:34:56 -0700 Subject: [PATCH 3/3] Dedupe the preview-build predicate, fix two comment inaccuracies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final review pass gave this a clean bill of health overall but named three small things: - get_app_version() and check_for_updates() each read option_env!("TRIPLE_C_BUILD_SUFFIX") independently with slightly different idioms — if one were ever edited alone, the About panel and the update check could silently disagree about whether this is a preview build. Extracted preview_build_suffix() as the single place that reads and classifies it. - pick_update's doc comment described the unparseable-tag case as a `-preview.` suffix; the actual tag build-app-preview.yml creates is `preview-` (no version, no dot) — already correct in the neighboring GitHubRelease::prerelease comment, just not here. - That same prerelease comment claimed defence against a preview release leaking through backfill-releases.yml, but a preview's tag already fails semver parsing on its own — this field's actual job is the case parsing can't catch: a normally-tagged release someone flags prerelease on Gitea (a hotfix candidate, an RC) that a backfill would otherwise mirror as-is. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ --- app/src-tauri/src/commands/update_commands.rs | 27 +++++++++++++------ app/src-tauri/src/models/update_info.rs | 11 ++++---- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/app/src-tauri/src/commands/update_commands.rs b/app/src-tauri/src/commands/update_commands.rs index 3f99e64..c3d571a 100644 --- a/app/src-tauri/src/commands/update_commands.rs +++ b/app/src-tauri/src/commands/update_commands.rs @@ -16,7 +16,7 @@ const REGISTRY_API_BASE: &str = const GHCR_TOKEN_URL: &str = "https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull"; -/// `CARGO_PKG_VERSION`, plus a build-time suffix when one was baked in. +/// 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 @@ -26,7 +26,17 @@ const GHCR_TOKEN_URL: &str = /// 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` and this is a no-op — see triple-c#32. +/// `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), @@ -36,7 +46,7 @@ fn format_app_version(base: &str, build_suffix: Option<&str>) -> String { #[tauri::command] pub fn get_app_version() -> String { - format_app_version(env!("CARGO_PKG_VERSION"), option_env!("TRIPLE_C_BUILD_SUFFIX")) + format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix()) } #[tauri::command] @@ -79,7 +89,7 @@ pub async fn check_for_updates() -> Result, String> { // 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 = option_env!("TRIPLE_C_BUILD_SUFFIX").is_some_and(|s| !s.is_empty()); + let is_preview_build = preview_build_suffix().is_some(); match pick_update(&releases, current_semver, platform_extensions, is_preview_build) { Some(release) => { @@ -121,10 +131,11 @@ pub async fn check_for_updates() -> Result, String> { /// 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 — a `-preview.` suffix, 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. +/// 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 diff --git a/app/src-tauri/src/models/update_info.rs b/app/src-tauri/src/models/update_info.rs index 478bf3b..17069d8 100644 --- a/app/src-tauri/src/models/update_info.rs +++ b/app/src-tauri/src/models/update_info.rs @@ -36,11 +36,12 @@ pub struct GitHubRelease { /// /// `build-app.yml`'s own mirror never publishes a prerelease, but /// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release - /// unfiltered, `prerelease` included — so if it were ever dispatched - /// while a preview release existed, this field is what stops - /// `check_for_updates` from offering it (the `preview-` tag shape - /// already fails semver parsing independently, but this is real - /// defence-in-depth, not a no-op). + /// 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, } -- 2.52.0