From 13afc1479f56b764b0606c3a92b226498892c95d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 02:13:20 +0300 Subject: [PATCH] Automate weekly releases and synchronize application versions --- .github/workflows/control.yml | 6 +- .github/workflows/pages.yml | 86 +++++++++++- .github/workflows/release.yml | 176 ++++++++++++------------- .github/workflows/weekly-release.yml | 68 ++++++++++ build.rs | 26 ++-- docs/releases.md | 25 ++++ scripts/build-web-worker.sh | 2 +- scripts/release.py | 190 +++++++++++++++++++++++++++ scripts/test_release.py | 132 +++++++++++++++++++ src/app/automation.rs | 2 +- src/app/commands/view.rs | 2 +- src/app/control/mod.rs | 2 +- src/app/mod.rs | 6 +- src/app/update/mod.rs | 4 +- src/discussions.rs | 2 +- src/io/recovery.rs | 2 +- src/io/update_check.rs | 116 +++++++--------- src/mcp.rs | 2 +- src/patreon.rs | 2 +- src/plugin/marketplace.rs | 2 +- src/scene/model/image_model.rs | 2 +- src/ui/window/about.rs | 2 +- src/ui/window/update_notice.rs | 2 +- src/videos.rs | 2 +- 24 files changed, 659 insertions(+), 202 deletions(-) create mode 100644 .github/workflows/weekly-release.yml create mode 100644 docs/releases.md create mode 100644 scripts/release.py create mode 100644 scripts/test_release.py diff --git a/.github/workflows/control.yml b/.github/workflows/control.yml index d49fd49c2..5e3fe1517 100644 --- a/.github/workflows/control.yml +++ b/.github/workflows/control.yml @@ -16,7 +16,9 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - name: Validate control contract - run: cargo test --lib app::control::tests + run: | + cargo test --lib app::control::tests + cargo test --lib io::update_check::tests - name: Validate MCP smoke script if: runner.os != 'Windows' run: python3 -m py_compile docs/automation/mcp_smoke.py @@ -24,6 +26,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Validate weekly release automation + run: python3 scripts/test_release.py - uses: dtolnay/rust-toolchain@stable - run: cargo test --lib mcp::tests - run: cargo build diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index baf73cb4a..2ab469196 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,12 +1,17 @@ -# Build the wasm web app and publish it to GitHub Pages on every release -# (issue #45). No threads are used on wasm (rayon runs sequentially), so the -# COOP/COEP headers GitHub Pages can't set are not needed. name: Deploy web (GitHub Pages) run-name: ${{ github.event.release.tag_name || github.ref_name }} Web on: release: types: [published] + workflow_call: + inputs: + tag: + required: true + type: string + commit: + required: true + type: string workflow_dispatch: permissions: @@ -23,8 +28,42 @@ concurrency: jobs: build: runs-on: ubuntu-22.04 + outputs: + tag: ${{ steps.source.outputs.tag }} + commit: ${{ steps.source.outputs.commit }} steps: + - name: Resolve release source + id: source + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }} + RELEASE_COMMIT: ${{ inputs.commit }} + run: | + if [ -z "$RELEASE_TAG" ]; then + RELEASE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName) + fi + COMMIT=$(gh api "repos/$GITHUB_REPOSITORY/commits/$RELEASE_TAG" --jq .sha) + if [ -n "$RELEASE_COMMIT" ] && [ "$RELEASE_COMMIT" != "$COMMIT" ]; then + echo "::error::Release tag and commit do not match" + exit 1 + fi + echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: ${{ steps.source.outputs.commit }} + + - name: Validate release version + env: + RELEASE_TAG: ${{ steps.source.outputs.tag }} + run: | + if [ -f scripts/release.py ]; then + python3 scripts/release.py versions --tag "$RELEASE_TAG" + else + VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) + test "v$VERSION" = "$RELEASE_TAG" + fi - uses: dtolnay/rust-toolchain@stable with: @@ -51,11 +90,19 @@ jobs: tool: wasm-bindgen@${{ steps.wasm-bindgen-version.outputs.version }} - name: Build web bundle - run: trunk build --release --public-url /app/ --dist dist/app --html-output index.html web-app.html + run: trunk build --locked --release --public-url /app/ --dist dist/app --html-output index.html web-app.html - name: Assemble landing page run: sh scripts/assemble-site.sh + - name: Record release version and source + env: + RELEASE_TAG: ${{ steps.source.outputs.tag }} + RELEASE_COMMIT: ${{ steps.source.outputs.commit }} + run: | + jq -n --arg version "${RELEASE_TAG#v}" --arg commit "$RELEASE_COMMIT" \ + '{version: $version, commit: $commit}' > dist/app/release.json + # GitHub Pages runs Jekyll, which ignores files; opt out so the # wasm-bindgen output is served verbatim. - run: touch dist/.nojekyll @@ -248,9 +295,7 @@ jobs: print(f"videos: {len(json.loads(output.read_text(encoding='utf-8')))}") PY - # GitHub's Discussions API is authenticated GraphQL. Generate a public, - # token-free snapshot next to the web app; discussion activity (including - # pin/unpin) triggers this workflow so pinned entries stay at the top. + # Publish a public snapshot of GitHub discussions. - name: Generate discussions.json env: GH_TOKEN: ${{ github.token }} @@ -313,3 +358,30 @@ jobs: steps: - id: deployment uses: actions/deploy-pages@v4 + + - name: Verify deployed release + env: + PAGE_URL: ${{ steps.deployment.outputs.page_url }} + RELEASE_TAG: ${{ needs.build.outputs.tag }} + RELEASE_COMMIT: ${{ needs.build.outputs.commit }} + run: | + python3 - <<'PY' + import json + import os + import time + import urllib.request + + expected = {"version": os.environ["RELEASE_TAG"].removeprefix("v"), "commit": os.environ["RELEASE_COMMIT"]} + url = os.environ["PAGE_URL"].rstrip("/") + "/app/release.json?commit=" + expected["commit"] + for attempt in range(12): + try: + with urllib.request.urlopen(url, timeout=20) as response: + if json.load(response) == expected: + print("Verified web release", expected["version"], expected["commit"]) + break + except (OSError, ValueError) as error: + print(error) + time.sleep(10) + else: + raise SystemExit("Deployed web version does not match the release") + PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88a7dcbfa..25614f3f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,17 @@ name: Release on: release: types: [published] + workflow_call: + inputs: + tag: + required: true + type: string + commit: + required: true + type: string + target: + default: all + type: string workflow_dispatch: inputs: target: @@ -14,6 +25,9 @@ on: - all - snap +env: + RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }} + jobs: build-appimage: runs-on: ubuntu-22.04 @@ -23,6 +37,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit || github.sha }} + + - name: Validate package versions + id: version + shell: bash + run: python3 scripts/release.py versions --tag "$RELEASE_TAG" - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -38,7 +59,7 @@ jobs: - name: Build env: OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} - run: cargo build --release + run: cargo build --locked --release - name: Prepare AppDir run: | @@ -80,13 +101,13 @@ jobs: mv Open*CAD*Studio*.AppImage OpenCADStudio.AppImage - name: Upload AppImage to release - if: github.event_name == 'release' + if: env.RELEASE_TAG != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - ASSET=OpenCADStudio-${{ github.ref_name }}-linux-x86_64.AppImage + ASSET=OpenCADStudio-${{ env.RELEASE_TAG }}-linux-x86_64.AppImage mv OpenCADStudio.AppImage "$ASSET" - gh release upload ${{ github.ref_name }} "$ASSET" --clobber --repo ${{ github.repository }} + gh release upload ${{ env.RELEASE_TAG }} "$ASSET" --clobber --repo ${{ github.repository }} build-snap: needs: build-appimage @@ -104,6 +125,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit || github.sha }} + + - name: Validate package versions + id: version + shell: bash + run: python3 scripts/release.py versions --tag "$RELEASE_TAG" - name: Download shared Linux payload uses: actions/download-artifact@v4 @@ -114,22 +142,8 @@ jobs: - name: Set package version id: package env: - EVENT_NAME: ${{ github.event_name }} - REF_NAME: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} run: | - if [ "$EVENT_NAME" = "release" ]; then - VERSION="${REF_NAME#v}" - if [ "v$VERSION" != "$REF_NAME" ]; then - echo "::error::Release tag must be vX.Y.Z" - exit 1 - fi - else - VERSION="$(sed -n 's/^version = "\([0-9][0-9.]*\)"/\1/p' Cargo.toml | head -1)" - fi - if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::Invalid package version: $VERSION" - exit 1 - fi sed -i "s/^version: .*/version: '$VERSION'/" snap/snapcraft.yaml echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -148,14 +162,14 @@ jobs: echo "snap=$PACKAGE" >> "$GITHUB_OUTPUT" - name: Upload snap to release - if: github.event_name == 'release' + if: env.RELEASE_TAG != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SNAP: ${{ steps.snap_package.outputs.snap }} - run: gh release upload "${{ github.ref_name }}" "$SNAP" --clobber + run: gh release upload "${{ env.RELEASE_TAG }}" "$SNAP" --clobber - name: Upload snap as workflow artifact - if: github.event_name == 'workflow_dispatch' + if: env.RELEASE_TAG == '' uses: actions/upload-artifact@v4 with: name: OpenCADStudio-v${{ steps.package.outputs.version }}-linux-x86_64-snap @@ -163,14 +177,14 @@ jobs: if-no-files-found: error - name: Publish snap to edge - if: github.event_name == 'release' && env.SNAPCRAFT_STORE_CREDENTIALS != '' + if: env.RELEASE_TAG != '' && env.SNAPCRAFT_STORE_CREDENTIALS != '' uses: snapcore/action-publish@v1 with: snap: ${{ steps.snap_package.outputs.snap }} release: edge build-windows: - if: github.event_name == 'release' || inputs.target == 'all' + if: github.event_name == 'release' || inputs.tag != '' || inputs.target == 'all' runs-on: windows-latest permissions: contents: write @@ -178,6 +192,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit || github.sha }} + + - name: Validate package versions + id: version + shell: bash + run: python3 scripts/release.py versions --tag "$RELEASE_TAG" - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -185,11 +206,6 @@ jobs: - name: Convert SVG icon to ICO shell: pwsh run: | - # ImageMagick is pre-installed on windows-latest. The - # auto-resize generates every size Explorer / Add-Remove - # Programs / the Start Menu shortcut may request from a - # single source SVG. Must run BEFORE the build so build.rs - # can embed the icon into the .exe (issue #107). magick assets/logo.svg ` -define icon:auto-resize=16,24,32,48,64,128,256 ` packaging/windows/AppIcon.ico @@ -209,17 +225,10 @@ jobs: env: OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} run: | - cargo build --release - # The MSI bundles the DWG thumbnail provider DLL. That cdylib is a - # workspace member, not an app dependency, so a plain `cargo build` - # never produces it — build the crate as an explicit target. - cargo build --release -p dwg-thumbnailer-win + cargo build --locked --release + cargo build --locked --release -p dwg-thumbnailer-win - name: Sign executable (Azure Trusted Signing) - # Sign the bare .exe *before* the MSI is built so the copy packed - # into the installer carries the signature too. AZURE_CLIENT_SECRET - # authenticates the App Registration; the remaining AZURE_* secrets - # select the Trusted Signing account / certificate profile. uses: azure/artifact-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -236,22 +245,9 @@ jobs: - name: Build MSI installer shell: pwsh run: | - $version = "${{ github.ref_name }}" -replace '^v', '' - # workflow_dispatch runs use the branch name as ref_name, which - # is not a valid MSI ProductVersion — fall back to 0.0.0 so test - # builds still produce an installer. - if ($version -notmatch '^\d+(\.\d+){0,3}$') { $version = "0.0.0" } - # WiX Toolset 3.x is pre-installed on windows-latest; $env:WIX - # points to the install root. candle compiles .wxs → .wixobj - # and light links it into the final .msi. + $version = "${{ steps.version.outputs.msi }}" $candle = Join-Path $env:WIX "bin\candle.exe" $light = Join-Path $env:WIX "bin\light.exe" - # Resolve to absolute paths and pass each `-d=` - # as one quoted argument. PowerShell's native-command argument - # passing strips unquoted `-d…=…` strings on the `=` sign, - # which would otherwise hand candle an empty Source variable - # and `target\release\OpenCADStudio.exe` as a stray input - # filename. $exePath = (Resolve-Path target\release\OpenCADStudio.exe).Path $iconPath = (Resolve-Path packaging\windows\AppIcon.ico).Path # GPL-3 license RTF shown on the installer's EULA page. @@ -294,16 +290,12 @@ jobs: timestamp-digest: SHA256 - name: Upload artifacts to release - if: github.event_name == 'release' + if: env.RELEASE_TAG != '' shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - $tag = "${{ github.ref_name }}" - # Ship both the bare .exe (portable, no install) and the - # .msi (installer with Start Menu shortcut + .dwg / .dxf - # file association). The `-portable` / `-installer` suffix - # makes the distinction obvious on the release page. + $tag = "${{ env.RELEASE_TAG }}" $exe = "OpenCADStudio-$tag-windows-x86_64-portable.exe" $msi = "OpenCADStudio-$tag-windows-x86_64-installer.msi" Move-Item target\release\OpenCADStudio.exe $exe @@ -313,7 +305,7 @@ jobs: --clobber --repo ${{ github.repository }} build-macos: - if: github.event_name == 'release' || inputs.target == 'all' + if: github.event_name == 'release' || inputs.tag != '' || inputs.target == 'all' runs-on: macos-14 # Apple Silicon (arm64) only permissions: contents: write @@ -321,6 +313,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit || github.sha }} + + - name: Validate package versions + id: version + shell: bash + run: python3 scripts/release.py versions --tag "$RELEASE_TAG" - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -336,11 +335,8 @@ jobs: env: OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} run: | - cargo build --release --target aarch64-apple-darwin - # The QuickLook extension links libdwg_thumbnailer.a. The staticlib - # crate-type is only emitted when the crate is built as a target (as a - # plain dependency cargo produces just the rlib), so build it here. - cargo build --release --target aarch64-apple-darwin -p dwg-thumbnailer + cargo build --locked --release --target aarch64-apple-darwin + cargo build --locked --release --target aarch64-apple-darwin -p dwg-thumbnailer - name: Build .icns from SVG run: | @@ -375,14 +371,8 @@ jobs: - name: Build DWG QuickLook thumbnail extension (.appex) run: | - # A QuickLook thumbnail App Extension, built without an Xcode project: - # swiftc compiles the provider, links the Rust core static lib (its C - # ABI) + the system frameworks, and we hand-assemble the .appex bundle. - # `cargo build` already produced libdwg_thumbnailer.a (workspace member, - # crate-type staticlib). The whole app is ad-hoc signed below, which - # deep-signs the embedded extension too. set -e - VERSION="${{ github.ref_name }}"; VERSION="${VERSION#v}"; [ -z "$VERSION" ] && VERSION="0.0.0" + VERSION="${{ steps.version.outputs.cargo }}" EXT=DWGThumbnail.appex rm -rf "$EXT"; mkdir -p "$EXT/Contents/MacOS" swiftc \ @@ -412,27 +402,16 @@ jobs: # QuickLook thumbnail extension. cp -R DWGThumbnail.appex "$APP/Contents/PlugIns/" # Substitute version into Info.plist. - VERSION="${{ github.ref_name }}" - VERSION="${VERSION#v}" - [ -z "$VERSION" ] && VERSION="0.0.0" + VERSION="${{ steps.version.outputs.cargo }}" sed "s/__VERSION__/$VERSION/g" packaging/Info.plist > "$APP/Contents/Info.plist" - name: Ad-hoc code-sign the bundle run: | - # No paid Apple Developer ID is available, so the app cannot be - # notarised. An *ad-hoc* signature (identity "-") at least gives the - # bundle a valid self-signature: this avoids the harshest Gatekeeper - # verdict ("app is damaged, Move to Trash") on a quarantined download - # and is mandatory anyway for arm64 binaries to execute. Sign the - # whole bundle deeply, then verify. codesign --force --deep --sign - --timestamp=none OpenCADStudio.app codesign --verify --strict --verbose=2 OpenCADStudio.app - name: Create .dmg run: | - # hdiutil intermittently fails with "Resource busy" on the hosted - # runner (a stale mount or background indexer holding the image). - # Detach any leftover volume and retry a few times before giving up. set +e for attempt in 1 2 3 4 5; do hdiutil detach "/Volumes/Open CAD Studio" >/dev/null 2>&1 || true @@ -449,24 +428,20 @@ jobs: test -f OpenCADStudio.dmg - name: Upload .dmg to release - if: github.event_name == 'release' + if: env.RELEASE_TAG != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - ASSET=OpenCADStudio-${{ github.ref_name }}-macos-arm64.dmg + ASSET=OpenCADStudio-${{ env.RELEASE_TAG }}-macos-arm64.dmg mv OpenCADStudio.dmg "$ASSET" - gh release upload ${{ github.ref_name }} "$ASSET" --clobber --repo ${{ github.repository }} + gh release upload ${{ env.RELEASE_TAG }} "$ASSET" --clobber --repo ${{ github.repository }} - name: Emit Homebrew cask sha256 - if: github.event_name == 'release' + if: env.RELEASE_TAG != '' run: | - # Print the values needed to bump the Homebrew cask - # (packaging/homebrew/open-cad-studio.rb) for this release, so the - # tap can be updated with a copy-paste instead of computing the - # digest by hand. - VERSION="${{ github.ref_name }}" + VERSION="${{ env.RELEASE_TAG }}" VERSION="${VERSION#v}" - SHA=$(shasum -a 256 OpenCADStudio-${{ github.ref_name }}-macos-arm64.dmg | awk '{print $1}') + SHA=$(shasum -a 256 OpenCADStudio-${{ env.RELEASE_TAG }}-macos-arm64.dmg | awk '{print $1}') { echo "## Homebrew cask bump" echo "Update \`packaging/homebrew/open-cad-studio.rb\`:" @@ -475,3 +450,18 @@ jobs: echo " sha256 \"$SHA\"" echo '```' } >> "$GITHUB_STEP_SUMMARY" + + verify: + needs: [build-appimage, build-snap, build-windows, build-macos] + if: github.event_name == 'release' || inputs.tag != '' + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.commit || github.sha }} + - name: Verify native release assets and notes + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release.py verify-native --tag "$RELEASE_TAG" diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/weekly-release.yml new file mode 100644 index 000000000..f8ef0b1dc --- /dev/null +++ b/.github/workflows/weekly-release.yml @@ -0,0 +1,68 @@ +name: Weekly release + +on: + schedule: + - cron: '0 12 * * 0' + workflow_dispatch: + inputs: + publish: + description: Publish this week's release (otherwise preview notes only) + type: boolean + default: false + +permissions: + contents: write + actions: read + discussions: read + pages: write + id-token: write + +concurrency: + group: weekly-release + cancel-in-progress: false + +jobs: + prepare: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-22.04 + outputs: + ready: ${{ steps.release.outputs.ready }} + tag: ${{ steps.release.outputs.tag }} + commit: ${{ steps.release.outputs.commit }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + - name: Validate release automation + run: python3 scripts/test_release.py + - name: Prepare release and verify published notes + id: release + env: + GH_TOKEN: ${{ github.token }} + PUBLISH: ${{ github.event_name == 'schedule' || inputs.publish }} + run: | + if [ "$PUBLISH" = true ]; then + python3 scripts/release.py prepare --publish + else + python3 scripts/release.py prepare + fi + + # GITHUB_TOKEN releases do not trigger release events, so call both builds. + web: + needs: prepare + if: needs.prepare.outputs.ready == 'true' + uses: ./.github/workflows/pages.yml + with: + tag: ${{ needs.prepare.outputs.tag }} + commit: ${{ needs.prepare.outputs.commit }} + secrets: inherit + + native: + needs: prepare + if: needs.prepare.outputs.ready == 'true' + uses: ./.github/workflows/release.yml + with: + tag: ${{ needs.prepare.outputs.tag }} + commit: ${{ needs.prepare.outputs.commit }} + secrets: inherit diff --git a/build.rs b/build.rs index 15142595f..fd50c581c 100644 --- a/build.rs +++ b/build.rs @@ -1,12 +1,19 @@ -// build.rs — Windows executable icon embedding. -// -// The core ribbon module registry used to be generated here; it is now a -// hand-written static list in src/modules/registry.rs. +// Build metadata and Windows executable resources. #[cfg(windows)] use std::path::Path; fn main() { + let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); + let parts: Vec<&str> = version.split('.').collect(); + let app_version = if parts.len() == 3 && parts[0].len() == 4 + && parts[0].starts_with("20") && parts[2] == "0" + { + format!("{}.{:02}", parts[0], parts[1].parse::().expect("week number")) + } else { + version.clone() + }; + println!("cargo:rustc-env=OCS_APP_VERSION={app_version}"); println!("cargo:rerun-if-changed=.git/HEAD"); if let Ok(head) = std::fs::read_to_string(".git/HEAD") { if let Some(reference) = head.trim().strip_prefix("ref: ") { @@ -57,22 +64,17 @@ fn main() { } ); - // The Patreon token is baked in at compile time via `option_env!` in - // src/patreon.rs. `option_env!` is not tracked by Cargo, so without this a - // token change wouldn't trigger a rebuild — declare the dependency so an - // updated OCS_PATREON_TOKEN re-bakes the binary. (#229-adjacent) println!("cargo:rerun-if-env-changed=OCS_PATREON_TOKEN"); - // Windows: embed AppIcon.ico into the .exe so the executable carries its - // own icon (Explorer, taskbar, Start-menu tile, file associations). The - // .ico is produced from assets/logo.svg by the release workflow before the - // build; when it is absent (local/dev builds) this is skipped. See #107. + // Release builds generate the icon before compiling. #[cfg(windows)] { println!("cargo:rerun-if-changed=packaging/windows/AppIcon.ico"); if Path::new("packaging/windows/AppIcon.ico").exists() { let mut res = winresource::WindowsResource::new(); res.set_icon("packaging/windows/AppIcon.ico"); + res.set("ProductVersion", &app_version); + res.set("FileVersion", &app_version); if let Err(e) = res.compile() { println!("cargo:warning=failed to embed Windows icon: {e}"); } diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 000000000..d08139d6a --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,25 @@ +# Releases + +The **Weekly release** workflow runs every Sunday at 12:00 UTC. GitHub may +queue the run after that time. Weeks without new commits are skipped. + +The release name uses the UTC ISO week year and week, such as `2026.35`. +The workflow updates `Cargo.toml` and `Cargo.lock`, commits `Release v2026.35` +on `main`, and atomically pushes that commit and its `v2026.35` tag. +It creates release notes using the existing section format and verifies the +published title and notes. + +Web and native workflows receive the same tag and commit and run independently. +Web deploys when its own build finishes; native packages are attached as they +finish. The web deployment verifies `/app/release.json`, and the native +workflow verifies all five download files. Native update notices require a +download for the installed platform. + +The app shows `2026.35`, Cargo and macOS use `2026.35.0`, and MSI uses `26.35.0`. +The main window title is `Open CAD Studio 2026.35 - Drawing.dwg`. + +To preview release notes, manually run **Weekly release** on `main` with +**publish** unchecked. Check **publish** to release immediately. Rerunning +within the same week reuses the original tag and commit. To rebuild just a +failed target, use GitHub's **Re-run failed jobs**. Manual web deployment +always uses the latest published release rather than unreleased `main`. diff --git a/scripts/build-web-worker.sh b/scripts/build-web-worker.sh index 0a5e1d44a..7a278ed7d 100644 --- a/scripts/build-web-worker.sh +++ b/scripts/build-web-worker.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -cargo build --release --target wasm32-unknown-unknown --package ocs_web_worker +cargo build --locked --release --target wasm32-unknown-unknown --package ocs_web_worker worker_out="${TRUNK_STAGING_DIR:?}/worker_pkg" mkdir -p "$worker_out" wasm-bindgen \ diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 000000000..96b7ae1c9 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Prepare weekly releases and share package versions across build targets.""" + +import argparse +from datetime import date, datetime, timezone +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile + + +def run(*args): + return subprocess.check_output(args, text=True, encoding="utf-8").rstrip("\n") + + +def gh(*args): + return json.loads(run("gh", *args)) + + +def versions(tag): + version = tag.removeprefix("v") + weekly = re.fullmatch(r"(20\d{2})\.(\d{2})", version) + if weekly: + year, week = map(int, weekly.groups()) + date.fromisocalendar(year, week, 1) + cargo = f"{year}.{week}.0" + msi = f"{year - 2000}.{week}.0" + elif re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version): + cargo = msi = version + major, minor, patch = map(int, version.split(".")) + if major > 255 or minor > 255 or patch > 65535: + raise ValueError(f"Unsupported MSI version: {version}") + else: + raise ValueError(f"Invalid release version: {tag}") + return {"version": version, "cargo": cargo, "msi": msi, "tag": f"v{version}"} + + +def cargo_version(): + return re.search(r'^version = "([^"]+)"', Path("Cargo.toml").read_text(encoding="utf-8"), re.M)[1] + + +def display_version(cargo): + year, week, patch = cargo.split(".") + if len(year) == 4 and year.startswith("20") and patch == "0": + return versions(f"{year}.{int(week):02}")["version"] + return cargo + + +def output(values): + lines = "".join(f"{key}={value}\n" for key, value in values.items()) + print(lines, end="") + if path := os.environ.get("GITHUB_OUTPUT"): + with open(path, "a", encoding="utf-8") as stream: + stream.write(lines) + + +def release_notes(previous, tag, repo, head="HEAD"): + sections = { + "Modeling, drawing and annotation": [], + "Rendering, plotting and file fidelity": [], + "Interface, web and plugins": [], + "Maintenance": [], + } + entries = run("git", "log", "--first-parent", "--format=%s%x1f%b%x1e", f"{previous}..{head}") + for entry in entries.split("\x1e"): + if "\x1f" not in entry: + continue + subject, body = entry.strip("\n").split("\x1f", 1) + if subject.startswith("Merge pull request"): + subject = next((line for line in body.splitlines() if line.strip()), subject) + if subject.startswith(("Merge ", "Release v")): + continue + subject = re.sub(r"^(?:feat|fix|perf|chore|docs)(?:\([^)]*\))?:\s*", "", subject) + text = subject.lower() + if re.search(r"render|plot|print|gpu|buffer|save|file|serializ|vram", text): + section = "Rendering, plotting and file fidelity" + elif re.search(r"ui|web|plugin|ribbon|theme|translat|dropdown|mcp|control|cli", text): + section = "Interface, web and plugins" + elif re.search(r"kernel|curve|solid|surface|extrud|loft|sweep|revol|presspull|snap|hatch|grip|trim|offset|draw|dimension", text): + section = "Modeling, drawing and annotation" + else: + section = "Maintenance" + if subject not in sections[section]: + sections[section].append(subject) + lines = ["## Highlights", "", f"- Weekly {tag.removeprefix('v')} release for web and desktop from the same source revision.", ""] + for title, subjects in sections.items(): + if subjects: + lines += [f"## {title}", ""] + # ponytail: six recent entries per section; full history is linked below. + lines += [f"- {subject}" for subject in subjects[:6]] + lines += [""] + lines += [f"**Full Changelog:** https://github.com/{repo}/compare/{previous}...{tag}", ""] + return "\n".join(lines) + + +def prepare(publish): + if run("git", "status", "--porcelain"): + raise ValueError("Release preparation requires a clean working tree") + repo = os.environ["GITHUB_REPOSITORY"] + current = datetime.now(timezone.utc) + tag = current.strftime("v%G.%V") + version = versions(tag) + latest = gh("release", "view", "--json", "tagName")["tagName"] + if publish and os.environ.get("GITHUB_REF") != "refs/heads/main": + raise ValueError("Weekly releases must run on main") + existing = run("git", "tag", "--list", tag) + if existing: + # A retry always uses the original tag, even when main has advanced. + sha = run("git", "rev-parse", f"{tag}^{{commit}}") + releases = gh("release", "list", "--limit", "100", "--json", "tagName,isDraft") + if any(release["tagName"] == tag for release in releases): + published = gh("release", "view", tag, "--json", "name,isDraft,body") + if published["isDraft"] or not published["body"].strip() or published["name"] != version["version"]: + raise ValueError("Existing weekly release metadata is invalid") + output({"ready": str(publish).lower(), "tag": tag, "commit": sha}) + return + elif run("git", "rev-list", "--count", f"{latest}..HEAD") == "0": + output({"ready": "false"}) + return + notes = release_notes(latest, tag, repo, tag if existing else "HEAD") + print(notes) + if not publish: + output({"ready": "false", **version, "commit": run("git", "rev-parse", "HEAD")}) + return + if not existing: + old = cargo_version() + updates = {} + for path, prefix in ((Path("Cargo.toml"), ""), (Path("Cargo.lock"), 'name = "OpenCADStudio"\n')): + content = path.read_text(encoding="utf-8") + before = f'{prefix}version = "{old}"' + if before not in content: + raise ValueError(f"Cannot update version in {path}") + updates[path] = content.replace(before, f'{prefix}version = "{version["cargo"]}"', 1) + for path, content in updates.items(): + path.write_text(content, encoding="utf-8") + run("git", "config", "user.name", "github-actions[bot]") + run("git", "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com") + run("git", "add", "Cargo.toml", "Cargo.lock") + run("git", "commit", "-m", f"Release {tag}") + run("git", "tag", "-a", tag, "-m", f"Release {tag}") + run("git", "push", "--atomic", "origin", "HEAD:main", f"refs/tags/{tag}") + sha = run("git", "rev-parse", "HEAD") + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", encoding="utf-8") as notes_file: + notes_file.write(notes) + notes_file.flush() + run("gh", "release", "create", tag, "--verify-tag", "--title", version["version"], "--notes-file", notes_file.name, "--latest") + published = gh("release", "view", tag, "--json", "name,body,isDraft") + if published != {"name": version["version"], "body": notes, "isDraft": False}: + raise ValueError("Published release notes do not match the prepared release") + output({"ready": "true", "tag": tag, "commit": sha}) + + +def verify_native(tag): + release = gh("release", "view", tag, "--json", "assets,body,isDraft") + expected = { + f"OpenCADStudio-{tag}-{suffix}" + for suffix in ("linux-x86_64.AppImage", "linux-x86_64.snap", + "windows-x86_64-portable.exe", "windows-x86_64-installer.msi", "macos-arm64.dmg") + } + uploaded = {asset["name"] for asset in release["assets"] if asset["size"] > 0 and asset["state"] == "uploaded"} + if release["isDraft"] or not release["body"].strip() or not expected <= uploaded: + raise ValueError(f"Incomplete native release: {sorted(expected - uploaded)}") + print(f"Verified native release {tag}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + prepare_parser = commands.add_parser("prepare") + prepare_parser.add_argument("--publish", action="store_true") + version_parser = commands.add_parser("versions") + version_parser.add_argument("--tag", default="") + verify_parser = commands.add_parser("verify-native") + verify_parser.add_argument("--tag", required=True) + args = parser.parse_args() + if args.command == "prepare": + prepare(args.publish) + elif args.command == "verify-native": + verify_native(args.tag) + else: + version = versions(args.tag or display_version(cargo_version())) + if args.tag and version["cargo"] != cargo_version(): + raise ValueError("Release tag and Cargo version do not match") + output(version) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_release.py b/scripts/test_release.py new file mode 100644 index 000000000..777a23657 --- /dev/null +++ b/scripts/test_release.py @@ -0,0 +1,132 @@ +"""Run with python3 scripts/test_release.py; uses only local temporary repositories.""" + +from contextlib import redirect_stdout +from datetime import datetime, timezone +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import release + + +class ReleaseTests(unittest.TestCase): + def test_build_metadata(self): + with tempfile.TemporaryDirectory() as temp: + binary = str(Path(temp) / "build-script") + subprocess.run(["rustc", str(Path(__file__).resolve().parents[1] / "build.rs"), "-o", binary], check=True) + for cargo in ("0.9.8", "2026.9.0", "2026.35.0", "2026.53.0", "2027.1.0"): + result = subprocess.check_output([binary], text=True, env={**os.environ, "CARGO_PKG_VERSION": cargo}) + self.assertIn(f"cargo:rustc-env=OCS_APP_VERSION={release.display_version(cargo)}\n", result) + + def test_calendar_versions(self): + self.assertEqual(release.versions("v2026.35"), { + "version": "2026.35", "cargo": "2026.35.0", "msi": "26.35.0", "tag": "v2026.35", + }) + self.assertEqual(release.versions("2026.09")["cargo"], "2026.9.0") + self.assertEqual(release.display_version("2026.9.0"), "2026.09") + self.assertEqual(release.display_version("0.9.8"), "0.9.8") + self.assertEqual(datetime(2027, 1, 3, tzinfo=timezone.utc).strftime("v%G.%V"), "v2026.53") + for value in ("2026.00", "2026.54", "2027.53", "2026.9", "2026.35.0", "bad", "0.09.8"): + with self.assertRaises(ValueError, msg=value): + release.versions(value) + + def test_release_commit_push_and_retry(self): + original_run = release.run + releases = {"v0.9.8": {"name": "v0.9.8", "body": "Previous notes", "isDraft": False}} + latest = "v0.9.8" + fail_create = False + + def run(*args): + nonlocal latest, fail_create + if args[0] != "gh": + return original_run(*args) + if args[1:3] == ("release", "list"): + return json.dumps([{"tagName": tag, "isDraft": False} for tag in releases]) + if args[1:3] == ("release", "create"): + if fail_create: + fail_create = False + raise RuntimeError("Temporary release API failure") + latest = args[3] + releases[latest] = { + "name": args[args.index("--title") + 1], + "body": Path(args[args.index("--notes-file") + 1]).read_text(encoding="utf-8"), + "isDraft": False, + } + return "" + self.assertEqual(args[1:3], ("release", "view")) + if args[3] == "--json": + return json.dumps({"tagName": latest}) + return json.dumps(releases[args[3]]) + + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + root = temp / "checkout" + root.mkdir() + previous_directory = Path.cwd() + os.chdir(root) + try: + original_run("git", "init", "-b", "main") + original_run("git", "config", "user.name", "Release test") + original_run("git", "config", "user.email", "test@example.invalid") + original_run("git", "init", "--bare", "-b", "main", str(temp / "origin.git")) + original_run("git", "remote", "add", "origin", str(temp / "origin.git")) + Path("Cargo.toml").write_text('[package]\nname = "OpenCADStudio"\nversion = "0.9.8"\n') + Path("Cargo.lock").write_text('version = 4\n[[package]]\nname = "OpenCADStudio"\nversion = "0.9.8"\n') + original_run("git", "add", ".") + original_run("git", "commit", "-m", "Initial release") + original_run("git", "tag", "v0.9.8") + original_run("git", "push", "origin", "main", "--tags") + + with patch.object(release, "run", run), patch.object(release, "datetime") as clock, patch.dict(os.environ, { + "GITHUB_REPOSITORY": "owner/repo", "GITHUB_REF": "refs/heads/main", "GITHUB_OUTPUT": str(temp / "output"), + }): + clock.now.return_value = datetime(2026, 8, 30, 12, tzinfo=timezone.utc) + + def prepare(publish): + result = io.StringIO() + with redirect_stdout(result): + release.prepare(publish) + return result.getvalue() + + self.assertIn("ready=false", prepare(True)) + Path("feature").write_text("web release source") + original_run("git", "add", "feature") + original_run("git", "commit", "-m", "Add web release synchronization") + preview = prepare(False) + self.assertIn("Add web release synchronization", preview) + self.assertEqual(release.cargo_version(), "0.9.8") + self.assertEqual(original_run("git", "tag", "--list", "v2026.35"), "") + + self.assertIn("ready=true", prepare(True)) + sha = original_run("git", "rev-parse", "HEAD") + self.assertEqual(release.cargo_version(), "2026.35.0") + self.assertIn('version = "2026.35.0"', Path("Cargo.lock").read_text()) + self.assertEqual(original_run("git", "log", "-1", "--format=%s"), "Release v2026.35") + self.assertEqual(original_run("git", "rev-parse", "origin/main"), sha) + self.assertEqual(original_run("git", "rev-parse", "v2026.35^{commit}"), sha) + self.assertEqual(original_run("git", "status", "--porcelain"), "") + self.assertIn(f"commit={sha}", prepare(True)) + + clock.now.return_value = datetime(2026, 9, 6, 12, tzinfo=timezone.utc) + self.assertIn("ready=false", prepare(True)) + Path("feature").write_text("next change") + original_run("git", "add", "feature") + original_run("git", "commit", "-m", "Fix release retry") + fail_create = True + with self.assertRaises(RuntimeError): + prepare(True) + sha = original_run("git", "rev-parse", "HEAD") + self.assertIn(f"commit={sha}", prepare(True)) + self.assertEqual(original_run("git", "rev-parse", "HEAD"), sha) + self.assertEqual(releases["v2026.36"]["name"], "2026.36") + finally: + os.chdir(previous_directory) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/app/automation.rs b/src/app/automation.rs index 822b6eea3..f2156458d 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -67,7 +67,7 @@ fn port_arg() -> Option { } fn ready() -> Value { - json!({ "ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION") }) + json!({ "ok": true, "ready": true, "version": env!("OCS_APP_VERSION") }) } fn serve_stdio(app: &mut OpenCADStudio) { diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 9310032e4..6d9902e1d 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -206,7 +206,7 @@ impl OpenCADStudio { let body = format!( "\n\n\n\ ---\n- Open CAD Studio: v{}\n- Platform: {}\n", - env!("CARGO_PKG_VERSION"), + env!("OCS_APP_VERSION"), crate::sys::platform_info(), ); let url = format!( diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 90b0717b7..e0c93dfc3 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -177,7 +177,7 @@ impl OpenCADStudio { "name":c.name(),"prompt":c.prompt(),"options":c.options().iter().map(|o|json!({"label":o.label,"keyword":o.keyword})).collect::>(), "entity_pick":c.needs_entity_pick(),"structure_pick":c.needs_structure_point_pick(),"selection":c.is_selection_gathering(),"tangent_pick":c.needs_tangent_pick(),"free_text":c.is_free_text_step() })); - json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("CARGO_PKG_VERSION"), + json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"), "mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled, "document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation, "plugins":plugin_ids(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 516200fd7..30b27554e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3951,9 +3951,9 @@ pub fn run() -> iced::Result { if let Some(tab) = state.tabs.get(state.active_tab) { let dot = if tab.dirty { "● " } else { "" }; let name = tab.tab_display_name(); - format!("{}Open CAD Studio — {}", dot, name) + format!("{}Open CAD Studio {} - {}", dot, env!("OCS_APP_VERSION"), name) } else { - "Open CAD Studio".to_string() + concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() } }) .theme(|state: &OpenCADStudio, _| state.active_theme.clone()) @@ -3985,7 +3985,7 @@ pub fn run_web() -> iced::Result { OpenCADStudio::view_main, ) .subscription(OpenCADStudio::subscription) - .title(|_state: &OpenCADStudio| "Open CAD Studio".to_string()) + .title(|_state: &OpenCADStudio| concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()) .theme(|state: &OpenCADStudio| state.active_theme.clone()) .backend(iced::Backend::Hardware(iced::backend::Api::OpenGL)) .font(iced_aw::ICED_AW_FONT_BYTES) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 23090c06d..bd903e6d5 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6458,7 +6458,7 @@ impl OpenCADStudio { Message::AboutCopyInfo => { let info = format!( "Open CAD Studio v{}\nOS: {}\nArch: {}", - env!("CARGO_PKG_VERSION"), + env!("OCS_APP_VERSION"), crate::ui::window::about::platform_name(), crate::ui::window::about::architecture_name(), ); @@ -6676,7 +6676,7 @@ impl OpenCADStudio { if let Some(error) = &self.plugin_registry_error { return iced::clipboard::write(format!( "Open CAD Studio v{}\nOS: {}\nArchitecture: {}\nRegistry: {}\nError: {}", - env!("CARGO_PKG_VERSION"), + env!("OCS_APP_VERSION"), std::env::consts::OS, std::env::consts::ARCH, crate::plugin::marketplace::REGISTRY_URL, diff --git a/src/discussions.rs b/src/discussions.rs index 23dc730e6..795019787 100644 --- a/src/discussions.rs +++ b/src/discussions.rs @@ -91,7 +91,7 @@ fn get_text(agent: &ureq::Agent, url: &str) -> Result { .get(url) .header( "User-Agent", - concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")), + concat!("OpenCADStudio/", env!("OCS_APP_VERSION")), ) .call() .map_err(|e| e.to_string())? diff --git a/src/io/recovery.rs b/src/io/recovery.rs index c99f5a55a..3daac49ad 100644 --- a/src/io/recovery.rs +++ b/src/io/recovery.rs @@ -254,7 +254,7 @@ impl RecoveryReport { lines.push("Open CAD Studio drawing recovery report".to_string()); lines.push(format!("Report schema: {}", REPORT_SCHEMA_VERSION)); lines.push(format!("Report ID: {}", self.report_id)); - lines.push(format!("Application version: {}", env!("CARGO_PKG_VERSION"))); + lines.push(format!("Application version: {}", env!("OCS_APP_VERSION"))); lines.push(format!("Application revision: {}", env!("OCS_GIT_REV"))); lines.push(format!("Build profile: {}", env!("OCS_BUILD_PROFILE"))); lines.push(format!("Build features: {}", env!("OCS_BUILD_FEATURES"))); diff --git a/src/io/update_check.rs b/src/io/update_check.rs index d34e6bc44..cc24c402a 100644 --- a/src/io/update_check.rs +++ b/src/io/update_check.rs @@ -1,10 +1,4 @@ -// One-shot update check. -// -// `check_for_update()` runs on a background thread (joined inside an -// async wrapper, mirroring how the rest of `crate::io` wraps blocking -// work for iced's `Task::perform`). It hits the GitHub releases API and -// returns `Some(UpdateInfo)` when a newer release is available, or -// `None` when up to date / on network failure / on parse error. +// Check for a newer published native release. #[cfg(not(target_arch = "wasm32"))] const RELEASES_API: &str = @@ -12,11 +6,7 @@ const RELEASES_API: &str = pub const RELEASES_PAGE: &str = "https://github.com/HakanSeven12/OpenCADStudio/releases/latest"; -/// Minimum age before a freshly-published release is offered to the user. -/// GitHub Actions takes ~15 min to build and attach the platform binaries -/// after a tag is pushed, so suppressing notifications for the first hour -/// prevents users from clicking through to a release page whose -/// asset list is still empty. +/// Give release assets time to propagate before offering an update. #[cfg(not(target_arch = "wasm32"))] const MIN_RELEASE_AGE_SECS: u64 = 60 * 60; @@ -51,23 +41,37 @@ fn fetch_latest_if_outdated() -> Option { let agent = crate::network::agent(std::time::Duration::from_secs(5)); let body = agent .get(RELEASES_API) - .header("User-Agent", concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION"))) + .header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION"))) .header("Accept", "application/vnd.github+json") .call() .ok()? .body_mut() .read_to_string() .ok()?; - let latest = extract_string_field(&body, "tag_name")? + let metadata: serde_json::Value = serde_json::from_str(&body).ok()?; + let suffix = if cfg!(target_os = "windows") { + "-windows-x86_64-portable.exe" + } else if cfg!(target_os = "macos") { + "-macos-arm64.dmg" + } else { + "-linux-x86_64.AppImage" + }; + if !metadata.get("assets")?.as_array()?.iter().any(|asset| { + asset.get("name").and_then(|name| name.as_str()).is_some_and(|name| name.ends_with(suffix)) + && asset.get("size").and_then(|size| size.as_u64()).unwrap_or(0) > 0 + }) { + return None; + } + let latest = metadata.get("tag_name")?.as_str()? .trim_start_matches('v') .to_string(); - if latest == env!("CARGO_PKG_VERSION") { + if !is_newer(&latest, env!("OCS_APP_VERSION")) { return None; } // Suppress the notification until the release is old enough for the // Actions build to have published binaries. - if let Some(published) = extract_string_field(&body, "published_at") - .as_deref() + if let Some(published) = metadata.get("published_at") + .and_then(|value| value.as_str()) .and_then(parse_iso8601_utc) { let now = std::time::SystemTime::now() @@ -79,7 +83,7 @@ fn fetch_latest_if_outdated() -> Option { } } // Release notes are optional; treat missing as empty. - let notes = extract_string_field(&body, "body").unwrap_or_default(); + let notes = metadata.get("body").and_then(|value| value.as_str()).unwrap_or_default().to_string(); Some(UpdateInfo { version: latest, body: notes }) } @@ -129,61 +133,31 @@ fn parse_iso8601_utc(s: &str) -> Option { ) } -/// Minimal extractor for a top-level string field in the releases JSON. -/// Avoids pulling in `serde_json` for two fields. Handles standard JSON -/// string escapes (`\"`, `\\`, `\n`, `\r`, `\t`, `\/`) which are all the -/// GitHub release body uses in practice. #[cfg(not(target_arch = "wasm32"))] -fn extract_string_field(body: &str, field: &str) -> Option { - let key = format!("\"{}\":\"", field); - let start = body.find(&key)? + key.len(); - // Walk to the closing unescaped `"`, JSON-unescaping as we go. - let mut out = String::new(); - let bytes = body.as_bytes(); - let mut i = start; - while i < bytes.len() { - let b = bytes[i]; - if b == b'"' { - return Some(out); - } - if b == b'\\' && i + 1 < bytes.len() { - match bytes[i + 1] { - b'"' => out.push('"'), - b'\\' => out.push('\\'), - b'/' => out.push('/'), - b'n' => out.push('\n'), - b'r' => out.push('\r'), - b't' => out.push('\t'), - b'u' => { - // \uXXXX — decode 4 hex digits. Surrogate pairs (BMP only - // for now) are uncommon in release notes; skip on parse - // failure. - if i + 5 < bytes.len() { - let hex = std::str::from_utf8(&bytes[i + 2..i + 6]).ok()?; - if let Ok(code) = u32::from_str_radix(hex, 16) { - if let Some(c) = char::from_u32(code) { - out.push(c); - } - } - i += 6; - continue; - } - return None; - } - other => { - // Unknown escape — keep the literal pair, GitHub doesn't emit these. - out.push('\\'); - out.push(other as char); - } +fn is_newer(latest: &str, installed: &str) -> bool { + let parse = |version: &str| { + let version = version.trim_start_matches('v'); + if let Some((year, week)) = version.split_once('.') { + if year.len() == 4 && year.starts_with("20") && !week.contains('.') { + return Some(semver::Version::new(year.parse().ok()?, week.parse().ok()?, 0)); } - i += 2; - continue; } - // UTF-8 multi-byte chars: push the whole code-point so we don't - // bisect a sequence. - let ch = body[i..].chars().next()?; - out.push(ch); - i += ch.len_utf8(); + semver::Version::parse(version).ok() + }; + matches!((parse(latest), parse(installed)), (Some(latest), Some(installed)) if latest > installed) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn calendar_releases_compare_numerically() { + assert!(is_newer("2026.35", "0.9.8")); + assert!(is_newer("2026.10", "2026.09")); + assert!(is_newer("2027.01", "2026.53")); + assert!(!is_newer("2026.35", "2026.35")); + assert!(!is_newer("0.9.8", "2026.35")); + assert!(!is_newer("invalid", "2026.35")); } - None } diff --git a/src/mcp.rs b/src/mcp.rs index 529bb4e97..20bafd840 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -424,7 +424,7 @@ fn response(id: Value, result: Value) -> Value { } fn server_info() -> Value { - json!({"name":"OpenCADStudio","title":"Open CAD Studio","version":env!("CARGO_PKG_VERSION")}) + json!({"name":"OpenCADStudio","title":"Open CAD Studio","version":env!("OCS_APP_VERSION")}) } fn modern_request(params: &Value) -> bool { diff --git a/src/patreon.rs b/src/patreon.rs index e3a4c18d0..81af396d0 100644 --- a/src/patreon.rs +++ b/src/patreon.rs @@ -43,7 +43,7 @@ pub fn merge_manual(mut patrons: Vec<(String, i64)>) -> Vec<(String, i64)> { } #[cfg(not(target_arch = "wasm32"))] -const UA: &str = concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")); +const UA: &str = concat!("OpenCADStudio/", env!("OCS_APP_VERSION")); /// Fetch patrons with a successful payment in the last month from the Patreon /// API as `(display name, amount in USD cents)`, highest payment first. `Err` diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index 1cd316e8e..aabc4322a 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -107,7 +107,7 @@ fn agent() -> ureq::Agent { crate::network::agent(std::time::Duration::from_secs(15)) } -const UA: &str = concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")); +const UA: &str = concat!("OpenCADStudio/", env!("OCS_APP_VERSION")); /// Fetch releases without consuming GitHub API quota. The public Atom feed /// supplies tags and the expanded-assets endpoint supplies download links. diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index a5dedaedb..5a7cd9c48 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -430,7 +430,7 @@ fn fetch_remote(url: &str) -> Option> { .get(url) .header( "User-Agent", - concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")), + concat!("OpenCADStudio/", env!("OCS_APP_VERSION")), ) .call() .ok()?; diff --git a/src/ui/window/about.rs b/src/ui/window/about.rs index 6b7c8e87e..81ccc2921 100644 --- a/src/ui/window/about.rs +++ b/src/ui/window/about.rs @@ -76,7 +76,7 @@ pub(crate) fn architecture_name() -> &'static str { pub fn view_window( sizing: crate::ui::modal::ModalSizing, ) -> Element<'static, Message> { - let version = format!("v{}", env!("CARGO_PKG_VERSION")); + let version = format!("v{}", env!("OCS_APP_VERSION")); let content_width = if matches!(sizing.width, Length::Fill) { Fill } else { diff --git a/src/ui/window/update_notice.rs b/src/ui/window/update_notice.rs index ec4519e27..2aa020118 100644 --- a/src/ui/window/update_notice.rs +++ b/src/ui/window/update_notice.rs @@ -156,7 +156,7 @@ pub fn view_window<'a>( // row layout. The arrow between them is purely decorative. let installed = version_card( t!("Installed"), - format!("v{}", env!("CARGO_PKG_VERSION")), + format!("v{}", env!("OCS_APP_VERSION")), false, sizing.width, ); diff --git a/src/videos.rs b/src/videos.rs index 50f1d2f2a..ee00edb48 100644 --- a/src/videos.rs +++ b/src/videos.rs @@ -26,7 +26,7 @@ pub fn watch_url(id: &str) -> String { } #[cfg(not(target_arch = "wasm32"))] -const UA: &str = concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")); +const UA: &str = concat!("OpenCADStudio/", env!("OCS_APP_VERSION")); /// Directory of the on-disk thumbnail / listing cache. #[cfg(not(target_arch = "wasm32"))]