diff --git a/.gitea/workflows/build-sidecar.yml b/.gitea/workflows/build-sidecar.yml index 50a3924..2fca6c2 100644 --- a/.gitea/workflows/build-sidecar.yml +++ b/.gitea/workflows/build-sidecar.yml @@ -3,17 +3,89 @@ name: Build Sidecars on: push: branches: [main] + paths: ['python/**'] workflow_dispatch: jobs: + bump-sidecar-version: + name: Bump sidecar version and tag + if: "!contains(github.event.head_commit.message, '[skip ci]')" + runs-on: ubuntu-latest + outputs: + version: ${{ steps.bump.outputs.version }} + tag: ${{ steps.bump.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "Gitea Actions" + git config user.email "actions@gitea.local" + + - name: Bump sidecar patch version + id: bump + run: | + # Read current version from python/pyproject.toml + CURRENT=$(grep '^version = ' python/pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Current sidecar version: ${CURRENT}" + + # Increment patch number + MAJOR=$(echo "${CURRENT}" | cut -d. -f1) + MINOR=$(echo "${CURRENT}" | cut -d. -f2) + PATCH=$(echo "${CURRENT}" | cut -d. -f3) + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" + echo "New sidecar version: ${NEW_VERSION}" + + # Update python/pyproject.toml + sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" python/pyproject.toml + + echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT + echo "tag=sidecar-v${NEW_VERSION}" >> $GITHUB_OUTPUT + + - name: Commit and tag + env: + BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }} + run: | + NEW_VERSION="${{ steps.bump.outputs.version }}" + TAG="${{ steps.bump.outputs.tag }}" + git add python/pyproject.toml + git commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]" + git tag "${TAG}" + + # Push using token for authentication + REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|") + git push "${REMOTE_URL}" HEAD:main + git push "${REMOTE_URL}" "${TAG}" + + - name: Create Gitea release + env: + BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }} + run: | + REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + TAG="${{ steps.bump.outputs.tag }}" + VERSION="${{ steps.bump.outputs.version }}" + RELEASE_NAME="Sidecar v${VERSION}" + + curl -s -X POST \ + -H "Authorization: token ${BUILD_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"${TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated sidecar build.\", \"draft\": false, \"prerelease\": false}" \ + "${REPO_API}/releases" + echo "Created release: ${RELEASE_NAME}" + build-sidecar-linux: name: Build Sidecar (Linux) - if: "!contains(github.event.head_commit.message, '[skip ci]')" + needs: bump-sidecar-version runs-on: ubuntu-latest env: PYTHON_VERSION: "3.11" steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.bump-sidecar-version.outputs.tag }} - name: Install uv run: | @@ -48,23 +120,23 @@ jobs: run: | cd python/dist/voice-to-notes-sidecar && zip -r ../../../sidecar-linux-x86_64-cpu.zip . - - name: Wait for release and upload + - name: Upload to sidecar release env: BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }} run: | sudo apt-get install -y jq REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + TAG="${{ needs.bump-sidecar-version.outputs.tag }}" - # Fetch the latest release tag (retry up to 30 times with 10s delay) - echo "Waiting for latest release to be available..." + # Find the sidecar release by tag (retry up to 30 times with 10s delay) + echo "Waiting for sidecar release ${TAG} to be available..." for i in $(seq 1 30); do RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \ - "${REPO_API}/releases/latest") + "${REPO_API}/releases/tags/${TAG}") RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty') - TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty') - if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ] && [ -n "${TAG}" ] && [ "${TAG}" != "null" ]; then - echo "Found release: ${TAG} (ID: ${RELEASE_ID})" + if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then + echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})" break fi @@ -73,7 +145,7 @@ jobs: done if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then - echo "ERROR: Failed to find latest release after 30 attempts." + echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts." exit 1 fi @@ -99,12 +171,14 @@ jobs: build-sidecar-windows: name: Build Sidecar (Windows) - if: "!contains(github.event.head_commit.message, '[skip ci]')" + needs: bump-sidecar-version runs-on: windows-latest env: PYTHON_VERSION: "3.11" steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.bump-sidecar-version.outputs.tag }} - name: Install uv shell: powershell @@ -153,27 +227,26 @@ jobs: run: | 7z a -tzip -mx=5 sidecar-windows-x86_64-cpu.zip .\python\dist\voice-to-notes-sidecar\* - - name: Wait for release and upload + - name: Upload to sidecar release shell: powershell env: BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }} run: | $REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" $Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" } + $TAG = "${{ needs.bump-sidecar-version.outputs.tag }}" - # Fetch the latest release (retry up to 30 times with 10s delay) - Write-Host "Waiting for latest release to be available..." + # Find the sidecar release by tag (retry up to 30 times with 10s delay) + Write-Host "Waiting for sidecar release ${TAG} to be available..." $RELEASE_ID = $null - $TAG = $null for ($i = 1; $i -le 30; $i++) { try { - $release = Invoke-RestMethod -Uri "${REPO_API}/releases/latest" -Headers $Headers -ErrorAction Stop + $release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop $RELEASE_ID = $release.id - $TAG = $release.tag_name - if ($RELEASE_ID -and $TAG) { - Write-Host "Found release: ${TAG} (ID: ${RELEASE_ID})" + if ($RELEASE_ID) { + Write-Host "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})" break } } catch { @@ -185,7 +258,7 @@ jobs: } if (-not $RELEASE_ID) { - Write-Host "ERROR: Failed to find latest release after 30 attempts." + Write-Host "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts." exit 1 } @@ -219,12 +292,14 @@ jobs: build-sidecar-macos: name: Build Sidecar (macOS) - if: "!contains(github.event.head_commit.message, '[skip ci]')" + needs: bump-sidecar-version runs-on: macos-latest env: PYTHON_VERSION: "3.11" steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.bump-sidecar-version.outputs.tag }} - name: Install uv run: | @@ -249,24 +324,24 @@ jobs: run: | cd python/dist/voice-to-notes-sidecar && zip -r ../../../sidecar-macos-aarch64-cpu.zip . - - name: Wait for release and upload + - name: Upload to sidecar release env: BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }} run: | which jq || brew install jq REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + TAG="${{ needs.bump-sidecar-version.outputs.tag }}" - # Fetch the latest release tag (retry up to 30 times with 10s delay) - echo "Waiting for latest release to be available..." + # Find the sidecar release by tag (retry up to 30 times with 10s delay) + echo "Waiting for sidecar release ${TAG} to be available..." for i in $(seq 1 30); do RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \ - "${REPO_API}/releases/latest") + "${REPO_API}/releases/tags/${TAG}") RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty') - TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name // empty') - if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ] && [ -n "${TAG}" ] && [ "${TAG}" != "null" ]; then - echo "Found release: ${TAG} (ID: ${RELEASE_ID})" + if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then + echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})" break fi @@ -275,7 +350,7 @@ jobs: done if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then - echo "ERROR: Failed to find latest release after 30 attempts." + echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts." exit 1 fi diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index fd65c6e..82570d1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -47,9 +47,6 @@ jobs: # Update src-tauri/Cargo.toml (match version = "x.y.z" in [package] section) sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" src-tauri/Cargo.toml - # Update python/pyproject.toml - sed -i "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" python/pyproject.toml - echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT echo "tag=v${NEW_VERSION}" >> $GITHUB_OUTPUT @@ -58,7 +55,7 @@ jobs: BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }} run: | NEW_VERSION="${{ steps.bump.outputs.new_version }}" - git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml python/pyproject.toml + git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]" git tag "v${NEW_VERSION}" diff --git a/python/pyproject.toml b/python/pyproject.toml index 0f51fd8..a7fc608 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "voice-to-notes" -version = "0.2.12" +version = "1.0.0" description = "Python sidecar for Voice to Notes — transcription, diarization, and AI services" requires-python = ">=3.11" license = "MIT" diff --git a/src-tauri/src/commands/sidecar.rs b/src-tauri/src/commands/sidecar.rs index 457f25b..a7efadc 100644 --- a/src-tauri/src/commands/sidecar.rs +++ b/src-tauri/src/commands/sidecar.rs @@ -20,19 +20,81 @@ pub struct UpdateInfo { pub latest_version: String, } -/// Check if the sidecar binary exists for the current version. +/// Read the locally installed sidecar version from `sidecar-version.txt`. +/// Returns `None` if the file doesn't exist or can't be read. +fn read_local_sidecar_version() -> Option { + let data_dir = DATA_DIR.get()?; + let version_file = data_dir.join("sidecar-version.txt"); + std::fs::read_to_string(version_file) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// Write the sidecar version to `sidecar-version.txt` after a successful download. +fn write_local_sidecar_version(version: &str) -> Result<(), String> { + let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?; + let version_file = data_dir.join("sidecar-version.txt"); + std::fs::write(&version_file, version) + .map_err(|e| format!("Failed to write sidecar version file: {}", e)) +} + +/// Fetch releases from the Gitea API and find the latest sidecar release +/// (one whose tag_name starts with "sidecar-v"). +async fn fetch_latest_sidecar_release( + client: &reqwest::Client, +) -> Result { + let releases_url = format!("{}/releases?limit=20", REPO_API); + let resp = client + .get(&releases_url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch releases: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Failed to fetch releases: HTTP {}", resp.status())); + } + + let releases = resp + .json::>() + .await + .map_err(|e| format!("Failed to parse releases JSON: {}", e))?; + + releases + .into_iter() + .find(|r| { + r["tag_name"] + .as_str() + .map_or(false, |t| t.starts_with("sidecar-v")) + }) + .ok_or_else(|| "No sidecar release found".to_string()) +} + +/// Extract the version string from a sidecar tag name (e.g. "sidecar-v1.0.1" -> "1.0.1"). +fn version_from_sidecar_tag(tag: &str) -> &str { + tag.strip_prefix("sidecar-v").unwrap_or(tag) +} + +/// Check if the sidecar binary exists for the currently installed version. #[tauri::command] pub fn check_sidecar() -> bool { let data_dir = match DATA_DIR.get() { Some(d) => d, None => return false, }; + + let version = match read_local_sidecar_version() { + Some(v) => v, + None => return false, + }; + let binary_name = if cfg!(target_os = "windows") { "voice-to-notes-sidecar.exe" } else { "voice-to-notes-sidecar" }; - let version = env!("CARGO_PKG_VERSION"); + let extract_dir = data_dir.join(format!("sidecar-{}", version)); extract_dir.join(binary_name).exists() } @@ -61,44 +123,35 @@ fn platform_arch() -> &'static str { #[tauri::command] pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<(), String> { let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?; - let version = env!("CARGO_PKG_VERSION"); let os = platform_os(); let arch = platform_arch(); let asset_name = format!("sidecar-{}-{}-{}.zip", os, arch, variant); - // Fetch release info from Gitea API to get the download URL - let release_url = format!("{}/releases/tags/v{}", REPO_API, version); + // Fetch the latest sidecar release from Gitea API let client = reqwest::Client::new(); - let release_resp = client - .get(&release_url) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| format!("Failed to fetch release info: {}", e))?; + let sidecar_release = fetch_latest_sidecar_release(&client).await?; - if !release_resp.status().is_success() { - return Err(format!( - "Failed to fetch release info: HTTP {}", - release_resp.status() - )); - } - - let release_json = release_resp - .json::() - .await - .map_err(|e| format!("Failed to parse release JSON: {}", e))?; + let tag = sidecar_release["tag_name"] + .as_str() + .ok_or("No tag_name in sidecar release")?; + let sidecar_version = version_from_sidecar_tag(tag).to_string(); // Find the matching asset - let assets = release_json["assets"] + let assets = sidecar_release["assets"] .as_array() - .ok_or("No assets found in release")?; + .ok_or("No assets found in sidecar release")?; let download_url = assets .iter() .find(|a| a["name"].as_str() == Some(&asset_name)) .and_then(|a| a["browser_download_url"].as_str()) - .ok_or_else(|| format!("Asset '{}' not found in release v{}", asset_name, version))? + .ok_or_else(|| { + format!( + "Asset '{}' not found in sidecar release {}", + asset_name, tag + ) + })? .to_string(); // Stream download with progress events @@ -121,8 +174,7 @@ pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<(), Str .map_err(|e| format!("Failed to create zip file: {}", e))?; while let Some(chunk) = stream.next().await { - let chunk: bytes::Bytes = - chunk.map_err(|e| format!("Download stream error: {}", e))?; + let chunk: bytes::Bytes = chunk.map_err(|e| format!("Download stream error: {}", e))?; file.write_all(&chunk) .map_err(|e| format!("Failed to write chunk: {}", e))?; downloaded += chunk.len() as u64; @@ -142,7 +194,7 @@ pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<(), Str } // Extract the downloaded zip - let extract_dir = data_dir.join(format!("sidecar-{}", version)); + let extract_dir = data_dir.join(format!("sidecar-{}", sidecar_version)); SidecarManager::extract_zip(&zip_path, &extract_dir)?; // Make the binary executable on Unix @@ -157,9 +209,12 @@ pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<(), Str } } + // Write the sidecar version file + write_local_sidecar_version(&sidecar_version)?; + // Clean up the zip file and old sidecar versions let _ = std::fs::remove_file(&zip_path); - SidecarManager::cleanup_old_sidecars(data_dir, version); + SidecarManager::cleanup_old_sidecars(data_dir, &sidecar_version); Ok(()) } @@ -172,36 +227,19 @@ pub async fn check_sidecar_update() -> Result, String> { return Ok(None); } - let current_version = env!("CARGO_PKG_VERSION").to_string(); + let current_version = match read_local_sidecar_version() { + Some(v) => v, + None => return Ok(None), + }; - // Fetch latest release from Gitea API - let latest_url = format!("{}/releases/latest", REPO_API); + // Fetch latest sidecar release from Gitea API let client = reqwest::Client::new(); - let resp = client - .get(&latest_url) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| format!("Failed to fetch latest release: {}", e))?; + let sidecar_release = fetch_latest_sidecar_release(&client).await?; - if !resp.status().is_success() { - return Err(format!( - "Failed to fetch latest release: HTTP {}", - resp.status() - )); - } - - let release_json = resp - .json::() - .await - .map_err(|e| format!("Failed to parse release JSON: {}", e))?; - - let latest_tag = release_json["tag_name"] + let latest_tag = sidecar_release["tag_name"] .as_str() - .ok_or("No tag_name in release")?; - - // Strip leading 'v' if present - let latest_version = latest_tag.strip_prefix('v').unwrap_or(latest_tag); + .ok_or("No tag_name in sidecar release")?; + let latest_version = version_from_sidecar_tag(latest_tag); if latest_version != current_version { Ok(Some(UpdateInfo { diff --git a/src-tauri/src/sidecar/mod.rs b/src-tauri/src/sidecar/mod.rs index e8b137a..f145dca 100644 --- a/src-tauri/src/sidecar/mod.rs +++ b/src-tauri/src/sidecar/mod.rs @@ -56,10 +56,33 @@ impl SidecarManager { cfg!(debug_assertions) || std::env::var("VOICE_TO_NOTES_DEV").is_ok() } + /// Read the locally installed sidecar version from `sidecar-version.txt`. + fn read_sidecar_version() -> Result { + let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?; + let version_file = data_dir.join("sidecar-version.txt"); + std::fs::read_to_string(&version_file) + .map_err(|_| { + "Sidecar not installed: sidecar-version.txt not found. Please download the sidecar." + .to_string() + }) + .map(|v| v.trim().to_string()) + .and_then(|v| { + if v.is_empty() { + Err( + "Sidecar version file is empty. Please re-download the sidecar." + .to_string(), + ) + } else { + Ok(v) + } + }) + } + /// Resolve the frozen sidecar binary path (production mode). /// - /// First checks if the sidecar is already extracted to the app data directory. - /// If not, looks for `sidecar.zip` in the Tauri resource directory and extracts it. + /// Reads the installed sidecar version from `sidecar-version.txt` and + /// looks for the binary in the corresponding `sidecar-{version}` directory. + /// If the version file doesn't exist, the sidecar hasn't been downloaded yet. fn resolve_sidecar_path() -> Result { let binary_name = if cfg!(target_os = "windows") { "voice-to-notes-sidecar.exe" @@ -67,16 +90,15 @@ impl SidecarManager { "voice-to-notes-sidecar" }; - // Versioned extraction directory prevents stale sidecar after app updates let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?; - let current_version = env!("CARGO_PKG_VERSION"); + let current_version = Self::read_sidecar_version()?; let extract_dir = data_dir.join(format!("sidecar-{}", current_version)); let binary_path = extract_dir.join(binary_name); // Already extracted — use it directly if binary_path.exists() { - Self::cleanup_old_sidecars(data_dir, current_version); + Self::cleanup_old_sidecars(data_dir, ¤t_version); return Ok(binary_path); } @@ -102,7 +124,7 @@ impl SidecarManager { } } - Self::cleanup_old_sidecars(data_dir, current_version); + Self::cleanup_old_sidecars(data_dir, ¤t_version); Ok(binary_path) }