Skip to content

Commit 1303dbe

Browse files
committed
Release v26.32.3 : One-click auto update feature
1 parent 6feab2d commit 1303dbe

11 files changed

Lines changed: 643 additions & 224 deletions

File tree

.github/workflows/release.yml

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: Build and publish release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v[0-9]*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build-and-release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
19+
- uses: actions/setup-python@v5
20+
with:
21+
python-version: "3.12"
22+
23+
- name: Verify release tag matches package version
24+
env:
25+
RELEASE_TAG: ${{ github.ref_name }}
26+
run: |
27+
python - <<'PY'
28+
import os
29+
import re
30+
from pathlib import Path
31+
32+
tag = os.environ["RELEASE_TAG"]
33+
match = re.fullmatch(r"v(\d+\.\d+\.\d+)", tag)
34+
source = Path("spectroview/__init__.py").read_text(encoding="utf-8")
35+
version = re.search(r'^VERSION\s*=\s*["\']([^"\']+)["\']', source, re.M)
36+
if not match or version is None or version.group(1) != match.group(1):
37+
raise SystemExit(
38+
"Release tags must be vMAJOR.MINOR.PATCH and match "
39+
"spectroview.VERSION."
40+
)
41+
PY
42+
43+
- name: Build and validate distributions
44+
run: |
45+
python -m pip install --upgrade build twine
46+
python -m build
47+
python -m twine check dist/*
48+
49+
- uses: actions/upload-artifact@v4
50+
with:
51+
name: python-distributions
52+
path: dist/
53+
if-no-files-found: error
54+
55+
- name: Create GitHub release and attach distributions
56+
env:
57+
GH_TOKEN: ${{ github.token }}
58+
run: |
59+
gh release create "$GITHUB_REF_NAME" dist/* \
60+
--repo "$GITHUB_REPOSITORY" \
61+
--title "SPECTROview $GITHUB_REF_NAME" \
62+
--notes "The installable wheel is attached below and is used by SPECTROview's automatic updater." \
63+
--generate-notes \
64+
--verify-tag
65+
66+
publish-pypi:
67+
needs: build-and-release
68+
runs-on: ubuntu-latest
69+
environment:
70+
name: pypi
71+
url: https://pypi.org/p/spectroview
72+
permissions:
73+
id-token: write
74+
steps:
75+
- uses: actions/download-artifact@v4
76+
with:
77+
name: python-distributions
78+
path: dist/
79+
80+
- name: Publish distributions to PyPI
81+
uses: pypa/gh-action-pypi-publish@release/v1

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ app/.DS_Store
1717
.exe
1818
env_spectroview/
1919
.vscode/
20-
.github/workflows/
2120

2221
# MkDocs build output
2322
site/

docs/developer/index.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -370,15 +370,15 @@ self.v_maps_workspace.vm.switch_to_graphs_tab.connect(
370370

371371
### Overview
372372

373-
`SPECTROview` ships a lightweight, opt-out update notification system that queries the GitHub Releases API in the background and displays a dismissable banner when a newer version is found.
373+
`SPECTROview` ships a lightweight, opt-out update system that queries the GitHub Releases API in the background and displays a dismissable banner when a newer version is found. The banner can download the wheel attached to that exact release, verify its GitHub SHA-256 digest when available, close the application, install the wheel with pip, and relaunch it.
374374

375375
No extra dependency is required — only Python's built-in `urllib`.
376376

377377
### Key Files
378378

379379
| File | Role |
380380
|------|------|
381-
| `model/m_update_checker.py` | `QThread` worker — performs the HTTP request and emits `update_available` |
381+
| `model/m_update_checker.py` | `QThread` workers for release checks and wheel downloads; creates the detached pip-install/restart helper |
382382
| `view/components/v_update_banner.py` | Slim 36 px banner widget inserted at position 0 of the central layout |
383383
| `model/m_settings.py` | Stores `enabled`, `skipped_version`, and `last_check_date` in `QSettings` |
384384
| `main.py` | Starts the thread via `QTimer.singleShot(2000, ...)` from `showEvent` |
@@ -392,15 +392,23 @@ sequenceDiagram
392392
participant Worker as UpdateCheckerWorker
393393
participant GitHub as api.github.com
394394
participant Banner as VUpdateBanner
395+
participant Helper as Update helper
396+
actor User
395397
396398
Main->>Timer: showEvent → singleShot(2000)
397399
Timer->>Worker: _start_update_check() → worker.start()
398400
Worker->>GitHub: GET /repos/CEA-MetroCarac/SPECTROview/releases/latest
399-
GitHub-->>Worker: JSON {tag_name, html_url, body}
401+
GitHub-->>Worker: JSON {tag_name, html_url, body, assets[]}
400402
Worker->>Worker: compare versions
401403
alt newer version found
402-
Worker-->>Main: update_available(tag, notes, url)
404+
Worker-->>Main: update_available(tag, notes, url, wheel URL, SHA-256)
403405
Main->>Banner: insertWidget(0, VUpdateBanner(...))
406+
User->>Banner: Update
407+
Banner->>GitHub: Download release wheel in QThread
408+
GitHub-->>Banner: Wheel bytes
409+
Banner->>Helper: Start detached updater and close app
410+
Helper->>Helper: pip install --upgrade wheel
411+
Helper->>Main: Relaunch installed SPECTROview
404412
end
405413
Worker-->>Main: check_finished → set_last_check_date(today)
406414
```
@@ -412,13 +420,21 @@ sequenceDiagram
412420
| **`QThread` instead of `QNetworkAccessManager`** | Pure Python `urllib` avoids Qt networking module complexity; thread is simpler to test |
413421
| **2-second startup delay** | Ensures the UI is fully painted before the network request starts |
414422
| **Once-per-day throttle** | Avoids redundant requests; the date is persisted via `QSettings` |
415-
| **Silent failure** | `URLError`, `OSError`, `json.JSONDecodeError` are all caught — offline machines see no error |
423+
| **Silent check failure** | `URLError`, `OSError`, `json.JSONDecodeError` are caught — offline machines see no error |
416424
| **Version comparison via tuples** | `_parse_version('v26.29.0') → (26, 29, 0)` handles `v`-prefixed tags and non-numeric parts gracefully |
425+
| **Release asset source** | GitHub provides the version, release notes, wheel URL, and integrity digest in one public API response; PyPI remains the normal package-install channel |
426+
| **Detached helper** | The helper waits for Qt to exit, installs with the launching interpreter, then clears `PYTHONPATH`/`PYTHONHOME` and restarts from a temporary working directory so a checkout cannot shadow the freshly installed package |
417427
| **Skip vs Dismiss** | *Skip* persists the exact tag — the banner re-appears for the next release. *Dismiss* hides only for the session |
418428

419429
### Adding / Modifying the Checker
420430

421-
To change the API endpoint (e.g., to query PyPI instead), edit `GITHUB_API_URL` in `m_update_checker.py` and adjust the JSON key extraction in `UpdateCheckerWorker.run()`.
431+
Release automation lives in `.github/workflows/release.yml`. Pushing a `vMAJOR.MINOR.PATCH` tag whose value matches `spectroview.VERSION` builds the wheel and source distribution, attaches them to a GitHub release, and publishes them to PyPI through the `pypi` environment's Trusted Publisher.
432+
433+
Before the first automated PyPI publication, register a Trusted Publisher for
434+
`CEA-MetroCarac/SPECTROview` in the PyPI project's **Publishing** settings with
435+
workflow filename `release.yml` and environment `pypi`. This lets GitHub Actions
436+
obtain a short-lived publishing token without storing a long-lived PyPI API token
437+
in the repository.
422438

423439
To add a "disable updates" toggle to the Settings dialog, bind `MSettings.set_check_for_updates()` to a `QCheckBox` in `v_settings.py` — the `_start_update_check()` method in `main.py` already reads this flag before starting the thread.
424440

spectroview/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# The app uses PySide6 throughout, but superqt (used for QLabeledDoubleRangeSlider)
77
os.environ.setdefault("QT_API", "pyside6")
88

9-
VERSION = "26.32.2"
9+
VERSION = "26.35.1"
1010

1111

1212
TEXT_EXPIRE = (

spectroview/main.py

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
from spectroview.model.m_file_converter import MFileConverter
2020
from spectroview.model.m_spc import SpcReader
2121
from spectroview.model.m_settings import MSettings
22-
from spectroview.model.m_update_checker import UpdateCheckerWorker
22+
from spectroview.model.m_update_checker import (
23+
UpdateCheckerWorker,
24+
UpdateDownloadWorker,
25+
UpdateInstallationError,
26+
get_update_python_executable,
27+
install_update_and_restart,
28+
)
2329

2430
from spectroview.viewmodel.vm_settings import VMSettings
2531

@@ -86,6 +92,7 @@ def init_ui(self):
8692

8793
# ── Update notification banner (created lazily when an update is detected) ──
8894
self._update_banner = None
95+
self._update_download_worker = None
8996

9097
# Main Tab Widget
9198
self.tabWidget = QTabWidget(central)
@@ -671,7 +678,9 @@ def _start_update_check(self):
671678
self._checker.update_available.connect(self._on_update_available)
672679
self._checker.start()
673680

674-
def _on_update_available(self, tag: str, notes: str, html_url: str):
681+
def _on_update_available(
682+
self, tag: str, notes: str, html_url: str, wheel_url: str, wheel_sha256: str
683+
):
675684
"""Show the update banner when a newer version is found on GitHub."""
676685
# Never show if the user already skipped this exact version
677686
if self.settings.get_skipped_version() == tag:
@@ -680,23 +689,33 @@ def _on_update_available(self, tag: str, notes: str, html_url: str):
680689
if self._update_banner is not None:
681690
return # already showing
682691

692+
self._show_update_banner(tag, html_url, wheel_url, wheel_sha256)
693+
694+
def _show_update_banner(
695+
self, tag: str, html_url: str, wheel_url: str, wheel_sha256: str
696+
) -> None:
697+
"""Create and insert the single update banner above the workspace tabs."""
683698
banner = VUpdateBanner(
684699
tag=tag,
685700
html_url=html_url,
686701
on_skip=self.settings.set_skipped_version,
687702
on_dismiss=self._hide_banner,
703+
on_update=self._download_and_install_update,
704+
wheel_url=wheel_url,
705+
wheel_sha256=wheel_sha256,
688706
parent=self.centralWidget(),
689707
)
690-
# Apply current theme
691708
banner.apply_theme(self.settings.get_theme())
692-
693-
# Insert banner into the layout at position 0 (above tab widget)
694709
self.centralWidget().layout().insertWidget(0, banner)
695710
self._update_banner = banner
696711

697712
def _hide_banner(self):
698-
"""Reset the banner reference (the widget removes itself via deleteLater)."""
713+
"""Remove and dispose of the current update banner."""
714+
banner = self._update_banner
699715
self._update_banner = None
716+
if banner is not None:
717+
self.centralWidget().layout().removeWidget(banner)
718+
banner.deleteLater()
700719

701720
def _manual_update_check(self):
702721
"""User clicked 'Check for updates' in the menu bar — always runs (no throttle)."""
@@ -707,23 +726,71 @@ def _manual_update_check(self):
707726
self._manual_checker.check_finished.connect(self._on_manual_check_done)
708727
self._manual_checker.start()
709728

710-
def _on_manual_update_found(self, tag: str, notes: str, html_url: str):
729+
def _on_manual_update_found(
730+
self, tag: str, notes: str, html_url: str, wheel_url: str, wheel_sha256: str
731+
):
711732
"""A newer version was found during a user-initiated check."""
712733
self._manual_check_found_update = True
713734
# Show banner even if user previously skipped this version
714735
if self._update_banner is not None:
715736
return
716737

717-
banner = VUpdateBanner(
718-
tag=tag,
719-
html_url=html_url,
720-
on_skip=self.settings.set_skipped_version,
721-
on_dismiss=self._hide_banner,
722-
parent=self.centralWidget(),
738+
self._show_update_banner(tag, html_url, wheel_url, wheel_sha256)
739+
740+
def _download_and_install_update(
741+
self, _tag: str, wheel_url: str, wheel_sha256: str
742+
) -> None:
743+
"""Download the announced release wheel, then hand installation to the helper."""
744+
if self._update_download_worker is not None:
745+
return
746+
try:
747+
get_update_python_executable()
748+
except UpdateInstallationError as error:
749+
QMessageBox.warning(self, "Automatic update unavailable", str(error))
750+
return
751+
752+
if self._update_banner is not None:
753+
self._update_banner.set_download_progress(-1)
754+
worker = UpdateDownloadWorker(wheel_url, wheel_sha256, self)
755+
worker.progress_changed.connect(self._on_update_download_progress)
756+
worker.download_finished.connect(self._on_update_download_finished)
757+
worker.download_failed.connect(self._on_update_download_failed)
758+
self._update_download_worker = worker
759+
worker.start()
760+
761+
def _on_update_download_progress(self, percent: int) -> None:
762+
if self._update_banner is not None:
763+
self._update_banner.set_download_progress(percent)
764+
765+
def _on_update_download_finished(self, wheel_path: str) -> None:
766+
"""Schedule installation after the Qt process exits, then close this instance."""
767+
self._release_update_download_worker()
768+
try:
769+
install_update_and_restart(Path(wheel_path))
770+
except (OSError, UpdateInstallationError) as error:
771+
Path(wheel_path).unlink(missing_ok=True)
772+
self._show_update_download_error(str(error))
773+
return
774+
self.close()
775+
776+
def _on_update_download_failed(self, error: str) -> None:
777+
self._release_update_download_worker()
778+
self._show_update_download_error(error)
779+
780+
def _release_update_download_worker(self) -> None:
781+
worker = self._update_download_worker
782+
self._update_download_worker = None
783+
if worker is not None:
784+
worker.deleteLater()
785+
786+
def _show_update_download_error(self, error: str) -> None:
787+
if self._update_banner is not None:
788+
self._update_banner.set_update_error()
789+
QMessageBox.warning(
790+
self,
791+
"Update download failed",
792+
f"SPECTROview could not download the update. Please try again or install it manually.\n\n{error}",
723793
)
724-
banner.apply_theme(self.settings.get_theme())
725-
self.centralWidget().layout().insertWidget(0, banner)
726-
self._update_banner = banner
727794

728795
def _on_manual_check_done(self):
729796
"""Show 'up to date' message if the manual check found nothing new."""

0 commit comments

Comments
 (0)