Automate weekly releases and synchronize application versions

This commit is contained in:
Hakan Seven committed 2026-09-05 02:13:20 +03:00
1 parent 28d3aa5be2
commit 13afc1479f
24 files changed
+659 -202

No files matched your search

+5 -1
View File
@@ -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
+79 -7
View File
@@ -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
+83 -93
View File
@@ -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<name>=<value>`
# 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"
+68
View File
@@ -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
+14 -12
View File
@@ -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::<u32>().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}");
}
+25
View File
@@ -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`.
+1 -1
View File
@@ -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 \
+190
View File
@@ -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()
+132
View File
@@ -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", "[email protected]")
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()
+1 -1
View File
@@ -67,7 +67,7 @@ fn port_arg() -> Option<u16> {
}
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) {
+1 -1
View File
@@ -206,7 +206,7 @@ impl OpenCADStudio {
let body = format!(
"<!-- Describe the issue and the steps to reproduce it. -->\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!(
+1 -1
View File
@@ -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::<Vec<_>>(),
"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(),
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -91,7 +91,7 @@ fn get_text(agent: &ureq::Agent, url: &str) -> Result<String, String> {
.get(url)
.header(
"User-Agent",
concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")),
concat!("OpenCADStudio/", env!("OCS_APP_VERSION")),
)
.call()
.map_err(|e| e.to_string())?
+1 -1
View File
@@ -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")));
+45 -71
View File
@@ -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<UpdateInfo> {
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<UpdateInfo> {
}
}
// 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<u64> {
)
}
/// 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<String> {
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
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -430,7 +430,7 @@ fn fetch_remote(url: &str) -> Option<Vec<u8>> {
.get(url)
.header(
"User-Agent",
concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")),
concat!("OpenCADStudio/", env!("OCS_APP_VERSION")),
)
.call()
.ok()?;
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
);
+1 -1
View File
@@ -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"))]