From e5c82a04220590802072c66a1910f724e12794f7 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Tue, 21 Jul 2026 13:52:18 -0500 Subject: [PATCH] fix(updater): remove the Developer channel; document source installs for devs The "Developer" update channel selected a rolling latest-main build that CI republished on every push under the fixed `prerelease` tag. Because that release was recreated (delete+recreate) on every push, it defeated GitHub's release ordering: beta testers on builds predating the Beta channel's ROLLING_TAG exclusion (v1.21.0-beta-2 and earlier) were offered dev builds. The maintainer's decision is to remove the channel; developers track main via a source install instead of a per-commit installer rebuild. Changes: - updater.py: drop `developer` from UPDATE_CHANNELS (back to release/prerelease); remove the developer branch from pick_release. Add `developer`->`prerelease` to the channel remap map so any stored `developer` option (the maintainer's, at minimum) degrades gracefully to Beta -- it never errors and never lands on index 0 by accident. KEEP the ROLLING_TAG constant and the Beta branch's explicit rolling-tag exclusion as defense in depth (documented): it protects current clients if a rolling-style release ever reappears. - all_options.py: remove the third "Developer" radio (two radios now); the positional radio<->channel contract stays in lockstep with UPDATE_CHANNELS. - CI: remove the release-prerelease job, its concurrency group, and the rolling-release design comment block. Installers now build only for tags (dropped the `on: push: branches: [main]` trigger) -- building installers per main commit served only the removed rolling release, so it was pure waste; the pytest `tests` workflow still covers every main push, and tag/dispatch builds keep the frozen self-test coverage. - Docs: README/USER_GUIDE/CHANGELOG/WHATS_NEW back to two channels; add a developer source-install section (clone + `pip install -e .` + `git pull`, and the `PyReconstruct --update`/`--switch` CLI path) that replaces the channel. - Tests: update channel tests to the two-channel reality; keep/extend the legacy-remap (now incl. `developer`->prerelease) and rolling-tag-exclusion tests; add graceful-degradation coverage for a stored `developer` config. Full suite: 1232 passed, 5 xfailed (QT_QPA_PLATFORM=offscreen). Co-Authored-By: Claude Fable 5 --- .github/workflows/build-installers.yml | 105 +++--------------- CHANGELOG.md | 23 +++- .../modules/backend/updater/updater.py | 55 +++++---- .../modules/datatypes/default_settings.py | 2 +- .../modules/gui/dialog/all_options.py | 3 +- README.md | 26 +++-- WHATS_NEW.md | 4 + docs/USER_GUIDE.md | 36 ++++-- packaging/README.md | 6 +- tests/test_prune_prereleases.py | 7 +- tests/test_update_channel_option.py | 39 ++++--- tests/test_updater.py | 94 ++++++++-------- 12 files changed, 195 insertions(+), 205 deletions(-) diff --git a/.github/workflows/build-installers.yml b/.github/workflows/build-installers.yml index 563c9fd2..02618904 100644 --- a/.github/workflows/build-installers.yml +++ b/.github/workflows/build-installers.yml @@ -1,54 +1,29 @@ name: build-installers # Builds unsigned one-click installers and publishes them to THIS repo's GitHub -# Releases: a tag vX.Y.Z -> Release; a push to main -> rolling 'prerelease' -# build. The in-app updater (updater.GITHUB_REPO) reads these. Runs on the +# Releases for a cut release: a tag vX.Y.Z -> stable Release; a pre-release tag +# (vX.Y.Z-beta-N / vX.Y.ZrcN / vX.Y.ZaN) -> GitHub pre-release (the Beta +# channel). The in-app updater (updater.GITHUB_REPO) reads these. Runs on the # fork as-is (no hardcoded owner). Signing is deferred (proof of concept). +# +# Installers are built ONLY for a tag (or a manual workflow_dispatch), NOT on +# every push to main. The former rolling "latest main" build that republished a +# 'prerelease' release on each push -- and the in-app Developer channel it fed -- +# has been removed: it caused a field regression (old clients predating the Beta +# channel's ROLLING_TAG exclusion were offered dev builds when the rolling build +# recreated its release out of order). Developers now track main via a source +# install instead of a per-commit installer rebuild (see the README's +# developer-install section). The pytest suite still runs on every main push via +# the separate `tests` workflow (.github/workflows/test.yml). on: push: - branches: [main] tags: ["v*.*.*"] workflow_dispatch: permissions: contents: write # create / update GitHub Releases -# --- Concurrency design (rolling-release race) ------------------------------- -# Two rapid pushes to main used to run fully concurrent workflows that each -# delete+recreate the rolling 'prerelease' release. If the OLDER commit's job -# finished last, the Developer channel served the older build (and the .devN -# freshness number then read as a downgrade, stranding users until the next -# push). The fix is split deliberately across two phases, NOT a single -# workflow-level `cancel-in-progress`: -# -# * BUILD phase (jobs.build): ref-scoped group + cancel-in-progress:true. -# A newer push to main cancels the older main BUILD before it can publish, -# so the superseded run's release-prerelease is skipped (its `needs: build` -# result becomes `cancelled`, failing the success/failure gate below) and -# never touches the release -> the newest main build wins, and CI isn't -# wasted. Because the group keys on ${{ github.ref }}, every tag is its own -# lane: tag builds never cancel each other and are never cancelled by a -# main push (requirement: tag releases must never be cancelled). -# -# * PUBLISH phase (jobs.release-prerelease): a NON-ref-scoped group with -# cancel-in-progress:FALSE. The delete+recreate of the 'prerelease' release -# is two steps, NOT atomic; a cancellation landing between them would leave -# the channel deleted-but-not-recreated (no release at all -- the one state -# we must never reach). cancel-in-progress:false ensures a publish that has -# started is never CANCELLED between the two steps, so concurrency can no -# longer strand the channel (delete is always followed by its recreate). -# (A hard FAILURE of the recreate step could still strand it -- a -# pre-existing property of delete-then-recreate, out of scope here and not -# something concurrency can fix.) Newest-wins still holds: a -# running publish finishes, and among *pending* publishes GitHub keeps only -# the newest (older pending ones are dropped), so the last write is the -# newest build. softprops/action-gh-release@v2 updates the existing -# 'prerelease' release (creating it if absent), so each recreate yields a -# complete release. Only main pushes enter this job (see its `if:`), so -# tag publishes never queue behind it. -# ----------------------------------------------------------------------------- - jobs: build: concurrency: @@ -57,7 +32,7 @@ jobs: # applies per job instance, so without the matrix key all three legs of # ONE run share a group and cancel each other (this broke every build # after it was first introduced ref-only -- two legs cancelled, release - # skipped). Per-leg groups keep the intended behavior: a newer main push + # skipped). Per-leg groups keep the intended behavior: a re-pushed tag # cancels the older run's same-platform leg only. group: build-installers-build-${{ github.ref }}-${{ matrix.plat }}-${{ matrix.arch }} cancel-in-progress: true @@ -263,55 +238,3 @@ jobs: echo "pruning superseded pre-release: $t" gh release delete "$t" --repo "$GITHUB_REPOSITORY" --cleanup-tag --yes done - - release-prerelease: - needs: build - concurrency: - # Serialize rolling-release publishes and NEVER cancel one mid-flight: the - # delete+recreate below is not atomic, so cancellation between the steps - # would strand the Developer channel with no release. See the design note - # above the jobs block. Group is intentionally NOT ref-scoped (all main - # publishes share one lane) and cancel-in-progress is false. - group: build-installers-rolling-release - cancel-in-progress: false - # RE-ENABLED 2026-07-17: this rolling "latest main" build now feeds the in-app - # **Developer** update channel (updater selects it by the fixed `prerelease` tag; - # the Beta channel explicitly excludes that tag, so it can no longer confuse the - # curated pre-release stream — the 2026-06-29 pause reason). The `release` job's - # "Classify release" step already marks pre-release tags correctly, satisfying the - # pause note's precondition. See pyrecon-notes/DECISIONS.md. - if: ${{ !cancelled() && github.ref == 'refs/heads/main' && (needs.build.result == 'success' || needs.build.result == 'failure') }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@v4 - with: - path: dist - merge-multiple: true - - name: Generate checksums - run: cd dist && for f in *; do [ -f "$f" ] && sha256sum "$f" > "$f.sha256"; done - - name: Reset the rolling 'prerelease' build (drop stale assets) - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release delete prerelease --yes --cleanup-tag --repo "$GITHUB_REPOSITORY" || true - # belt-and-suspenders: if the release survived a failed delete, purge its assets - if gh release view prerelease --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release view prerelease --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name' \ - | xargs -r -I{} gh release delete-asset prerelease {} --yes --repo "$GITHUB_REPOSITORY" - fi - - uses: softprops/action-gh-release@v2 - with: - # The reused 'prerelease' tag backs the in-app updater's DEVELOPER - # channel (the rolling latest-main build), NOT the Beta channel: Beta - # (updater channel 'prerelease') serves curated semver pre-releases and - # explicitly excludes this tag. Keep the tag name 'prerelease' -- the - # updater's pick_release selects the rolling build by that exact tag - # (updater.ROLLING_TAG). Only the human-facing release title says - # "Developer". - tag_name: prerelease - name: "Developer (latest main, expect breakage)" - prerelease: true - target_commitish: main - files: dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe4f63f..b13b5825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,26 @@ All notable changes to this distribution of PyReconstruct are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [semantic versioning](https://semver.org/). -Builds come on three channels: **Stable** (final releases, tagged `vX.Y.Z`), -**Beta** (curated pre-releases, tagged e.g. `vX.Y.Z-beta-N` / `vX.Y.ZrcN`), and -**Developer** (the rolling build republished on every push to `main`). Entries -under [Unreleased] have landed on `main` (and are therefore already on the -Developer channel) but are not yet tagged; they reach the Beta channel once cut -as a pre-release tag, ahead of the next stable release. +Builds come on two channels: **Stable** (final releases, tagged `vX.Y.Z`) and +**Beta** (curated pre-releases, tagged e.g. `vX.Y.Z-beta-N` / `vX.Y.ZrcN`). +Entries under [Unreleased] have landed on `main` but are not yet tagged; they +reach the Beta channel once cut as a pre-release tag, ahead of the next stable +release. To run unreleased `main` before it is tagged, use a source install (see +the README's *From source (developers)* section). ## [Unreleased] +### Removed +- **Developer update channel.** The in-app "Developer" channel and the rolling + latest-`main` build that fed it (republished on every push to `main` under the + fixed `prerelease` tag) are removed. The rolling build's recreate-on-every-push + defeated GitHub's release ordering, so beta testers on builds predating the Beta + channel's rolling-tag exclusion (v1.21.0-beta-2 and earlier) could be offered + dev builds. Developers now track `main` with a source install instead + (README ▸ *From source (developers)*). A stored `developer` channel option + degrades gracefully to Beta, and the updater keeps its rolling-tag exclusion as + defense in depth. Reverts the channel added in #66. + ## [1.21.0-beta-4] — 2026-07-18 ### Fixed diff --git a/PyReconstruct/modules/backend/updater/updater.py b/PyReconstruct/modules/backend/updater/updater.py index 9409c442..747a87da 100644 --- a/PyReconstruct/modules/backend/updater/updater.py +++ b/PyReconstruct/modules/backend/updater/updater.py @@ -33,21 +33,32 @@ # 'PyReconstruct---...' -> capture . _ASSET_VERSION_RE = re.compile(r"PyReconstruct-(?P.+?)-(?:Windows|macOS|Linux)\b") -# The rolling "latest main" build is republished under this fixed GitHub tag on -# every push to main (see .github/workflows/build-installers.yml). Unlike a cut -# release, this tag is REUSED build-to-build. The Developer channel selects it by -# this tag; the Beta (prerelease) channel explicitly excludes it so the rolling -# build can never shadow a curated semver pre-release. +# A rolling "latest main" build was once republished under this fixed GitHub tag +# on every push to main. That build (and the "Developer" update channel that +# selected it) has been retired -- developers now run source installs to track +# main (see the developer-install docs in the README). The constant is KEPT +# deliberately: the Beta (prerelease) channel still excludes this tag as defense +# in depth. Old clients (v1.21.0-beta-2 and earlier) predate this exclusion and +# relied on release ordering, which a recreate-on-every-push rolling build +# defeated -- that was the field regression that prompted the removal. If a +# rolling-style release under this tag ever reappears, the exclusion guarantees a +# current client's Beta channel can never be shadowed by it. ROLLING_TAG = "prerelease" # Channel values in the order their radios appear in Series > Options > Updates. # Position is the contract between the dialog's radios and the stored value; keep # this tuple and the radio order in all_options.py in lockstep. -UPDATE_CHANNELS = ("release", "prerelease", "developer") +UPDATE_CHANNELS = ("release", "prerelease") -# Legacy channel values from installs predating the rename (stable->release, -# edge->prerelease). No legacy value maps to the newer 'developer' channel. -_LEGACY_CHANNELS = {"stable": "release", "edge": "prerelease"} +# Channel values that no longer exist as radios but may still be stored in an +# install's options -- remapped to a current channel so an old config opens the +# dialog on a valid radio and picks a valid release (never crashes or silently +# lands on index 0). Sources: +# stable/edge -> the pre-rename names (stable->release, edge->prerelease). +# developer -> the removed Developer channel; the maintainer (at minimum) has +# it stored, so it must remap to Beta (prerelease), the closest +# surviving channel, rather than falling back to Stable. +_LEGACY_CHANNELS = {"stable": "release", "edge": "prerelease", "developer": "prerelease"} def normalize_channel(channel): @@ -119,25 +130,23 @@ def pick_release(releases, channel): release -> newest non-prerelease, non-draft release. prerelease -> newest release flagged ``prerelease`` (drafts excluded), - EXCLUDING the rolling ``ROLLING_TAG`` build. The pipeline + EXCLUDING any release under ``ROLLING_TAG``. The pipeline publishes curated semver pre-releases (v1.30.0-alpha.N -> -rc -> final), each flagged ``prerelease=true`` by CI, so the - newest such release is the current pre-release. Excluding the - rolling tag explicitly (rather than relying on newest-first - ordering) guarantees the rolling build can never shadow a - curated semver pre-release, even if GitHub's ordering shifts. - developer -> the rolling "latest main" build, selected by ``ROLLING_TAG``. - Its tag is reused build-to-build, so it is identified by tag, - not by version; freshness is decided later from the asset's - version (the CI-baked ``.devN`` commit distance). + newest such release is the current pre-release. The rolling + Developer build that once used ``ROLLING_TAG`` is gone, but the + exclusion stays as defense in depth: excluding that tag + explicitly (rather than relying on newest-first ordering) + guarantees a rolling-style release can never shadow a curated + semver pre-release, even if GitHub's ordering shifts or such a + release reappears. + + The removed ``developer`` channel (and legacy ``stable``/``edge`` values) + are remapped by :func:`normalize_channel` before this dispatch, so a stored + ``developer`` option resolves to the ``prerelease`` branch here. """ channel = normalize_channel(channel) rels = [r for r in (releases or []) if not r.get("draft")] - if channel == "developer": - for r in rels: - if r.get("tag_name") == ROLLING_TAG: - return r - return None if channel == "prerelease": for r in rels: if r.get("prerelease") and r.get("tag_name") != ROLLING_TAG: diff --git a/PyReconstruct/modules/datatypes/default_settings.py b/PyReconstruct/modules/datatypes/default_settings.py index 08dd5df9..9a5fb7c7 100644 --- a/PyReconstruct/modules/datatypes/default_settings.py +++ b/PyReconstruct/modules/datatypes/default_settings.py @@ -151,7 +151,7 @@ def get_username() -> str: "theme": "default", # MFO # updates - "update_channel": "release", # "release" | "prerelease" | "developer" # MFO + "update_channel": "release", # "release" | "prerelease" # MFO "update_branch": "main", # source/dev installs only # MFO "update_check_on_startup": False, # frozen builds: opt-in background check # MFO diff --git a/PyReconstruct/modules/gui/dialog/all_options.py b/PyReconstruct/modules/gui/dialog/all_options.py index 3f879250..a6400069 100644 --- a/PyReconstruct/modules/gui/dialog/all_options.py +++ b/PyReconstruct/modules/gui/dialog/all_options.py @@ -464,8 +464,7 @@ def setOption(response): ["Update channel:"], [("radio", ("Stable (recommended)", channel_idx == 0), - ("Beta (early features, may be unstable)", channel_idx == 1), - ("Developer (every change, expect breakage)", channel_idx == 2))], + ("Beta (early features, may be unstable)", channel_idx == 1))], [("check", ("Check for updates on startup", self.series.getOption("update_check_on_startup", use_defaults)))], ] diff --git a/README.md b/README.md index 8482227b..05f0915e 100644 --- a/README.md +++ b/README.md @@ -53,27 +53,33 @@ Python required: The frozen Windows and macOS builds can update themselves from within the app via **Help ▸ Check for updates**, on the **Stable** channel (stable, tagged -`vX.Y.Z`), the **Beta** channel (experimental; the latest pre-release build, e.g. -release candidates like `vX.Y.ZrcN`), or the **Developer** channel (the rolling -build from the latest `main`, for developers and testers — expect breakage). -Updates are downloaded from +`vX.Y.Z`) or the **Beta** channel (experimental; the latest pre-release build, +e.g. release candidates like `vX.Y.ZrcN`). Updates are downloaded from GitHub Releases and verified against a published SHA-256 checksum before they are applied. An optional once-per-day check on startup is available too, off by default. ### From source (developers) -In a Python 3.11 environment (the project pins `>=3.11,<3.12`): +To track the latest commits on `main` (this replaces the old in-app "Developer" +update channel), run a source install rather than a frozen build. In a Python +3.11 environment — a `venv` or a conda env (the project pins `>=3.11,<3.12`): ``` -pip install git+https://github.com/dustenhubbard/PyReconstruct +git clone https://github.com/dustenhubbard/PyReconstruct +cd PyReconstruct +pip install -e . # editable: your working tree IS the running code PyReconstruct ``` -For a full development setup, see the upstream -[Developers guide](https://github.com/SynapseWeb/PyReconstruct/wiki/Developers) -(it lives on the upstream wiki; clone the fork if you are developing against this -distribution). +Pull to move to the newest code (`git pull`); because the install is editable, +the pulled source runs immediately with no reinstall. You can also update from +inside the app — **Help ▸ Check for updates** on a source install reinstalls the +branch set under **Series ▸ Options ▸ Updates** (default `main`) with `pip` — or +from the command line with `PyReconstruct --update` (`PyReconstruct --switch +` to change branch first). See [CONTRIBUTING.md](CONTRIBUTING.md) for the +full development setup (the conda `pyrecon_dev` environment, tests, and code +layout). ## Quickstart diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 5a67fbd7..b6746157 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -4,6 +4,10 @@ Short, plain-language highlights shown in PyReconstruct's "What's new" dialog after you install or update. For the complete, detailed list of changes, see the full release notes on GitHub (linked from the dialog). +## [Unreleased] + +- **The "Developer" update channel has been removed.** Updates now come on two channels, Stable and Beta, and Beta remains the right home for testers. If you were on Developer, PyReconstruct now keeps you on Beta automatically, so you do not need to change anything. To follow the very latest code between betas, install from source and run "git pull" (see the README). + ## [1.21.0-beta-4] — 2026-07-18 - **A trace's new color now shows immediately, everywhere.** Changing a trace's color could leave the trace drawn in its old color (while its selection highlight already showed the new one) until the view was fully redrawn. Thanks to Lyndsey for spotting and reporting this one! diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3590a6ee..9127e4fc 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -97,9 +97,21 @@ PyReconstruct ``` `pip` pulls in the runtime dependencies (PySide6, VTK, vedo, NumPy, SciPy, -scikit-image, shapely, trimesh, zarr, and others). For a full development setup -(conda environment, tests, code layout), see -[CONTRIBUTING.md](../CONTRIBUTING.md). +scikit-image, shapely, trimesh, zarr, and others). + +To **track the latest unreleased code on `main`** (what the retired in-app +"Developer" channel used to offer), clone the repository and install it editable, +then `git pull` whenever you want the newest commits: + +``` +git clone https://github.com/dustenhubbard/PyReconstruct +cd PyReconstruct +pip install -e . # editable: the checkout IS the running code +git pull # later, to move to the newest main; no reinstall needed +``` + +For a full development setup (the conda `pyrecon_dev` environment, tests, code +layout), see [CONTRIBUTING.md](../CONTRIBUTING.md). ### Launching the app @@ -122,19 +134,18 @@ by re-running `install.sh` — see [Installing PyReconstruct](#1-installing-pyre ### Update channels -PyReconstruct offers three update channels, selected under **Series ▸ Options…** +PyReconstruct offers two update channels, selected under **Series ▸ Options…** (`Shift+O`) in the **Updates** section: - **Stable (recommended)** — stable builds, tagged `vX.Y.Z`. - **Beta (early features, may be unstable)** — the latest pre-release build (release candidates, tagged like `vX.Y.ZrcN`); newer features, less testing. -- **Developer (every change, expect breakage)** — the rolling build rebuilt from the - latest `main` after every change; the newest code, untested and often broken. For - developers and testers only. -The default channel is **Stable**. +The default channel is **Stable**. (Developers who want the newest unreleased code +run a source install instead of a frozen build — see +[Source / `pip` installs](#source--pip-installs) below.) -> 📸 *Screenshot: Series ▸ Options ▸ Updates, showing the Stable / Beta / Developer radio buttons and the "Check for updates on startup" checkbox.* +> 📸 *Screenshot: Series ▸ Options ▸ Updates, showing the Stable / Beta radio buttons and the "Check for updates on startup" checkbox.* ### Checking for updates @@ -164,6 +175,13 @@ PyReconstruct from a chosen Git branch with `pip` and then restarts. The branch configured under **Series ▸ Options ▸ Updates** (the **Branch:** field, default `main`). The channel/installer machinery above applies only to packaged builds. +The same reinstall is available from the command line: `PyReconstruct --update` +(reinstall the current branch) or `PyReconstruct --switch ` (change branch, +then reinstall). If you installed editable (`pip install -e .`), a plain `git pull` +is usually all you need — the working tree is what runs. A source install on `main` +is the supported way to follow every commit now that the in-app Developer channel +has been removed. + --- ## 3. Core concepts diff --git a/packaging/README.md b/packaging/README.md index 1e40bbf0..42712be2 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -98,5 +98,7 @@ PyReconstruct--macOS-arm64.dmg PyReconstruct--macOS-x86_64.dmg ``` -`` is the setuptools-scm string: `1.20.0` on a tag, `1.20.1.devN+gHASH` -for the rolling `prerelease` (main) build. +`` is the setuptools-scm string: `1.20.0` on a clean `vX.Y.Z` tag, or a +normalized pre-release like `1.21.0rc1` on a pre-release tag. (Installers are +built only for tags now; the former rolling `prerelease` main build was retired +along with the in-app Developer channel.) diff --git a/tests/test_prune_prereleases.py b/tests/test_prune_prereleases.py index 3fb210d6..373deab1 100644 --- a/tests/test_prune_prereleases.py +++ b/tests/test_prune_prereleases.py @@ -37,9 +37,10 @@ # Must survive: the stable itself, other version lines (stable or pre-release), # and v1.21.10rc1 -- a prefix-matching bug would lump it into the v1.21.0 line. -# "prerelease" is the rolling latest-main build's REUSED tag, which now backs the -# updater's Developer channel: it is not a vX.Y.Z line and must never be pruned, -# or that channel would lose its only release when a stable ships. +# "prerelease" was the rolling latest-main build's REUSED tag (that build and the +# Developer channel it fed are now removed). It is not a vX.Y.Z line, so the prune +# must never select it -- kept here as a regression guard that a stray non-version +# tag is never swept up by a stable release's prune. SURVIVORS = [ "v1.21.0", "v1.20.5rc1", diff --git a/tests/test_update_channel_option.py b/tests/test_update_channel_option.py index cb51394d..24d24f3c 100644 --- a/tests/test_update_channel_option.py +++ b/tests/test_update_channel_option.py @@ -1,10 +1,11 @@ -"""The Series > Options > Updates section (frozen builds) offers three update -channels -- Stable / Beta / Developer -- as a radio group that maps BY POSITION -to the stored ``update_channel`` value ("release" / "prerelease" / "developer"). +"""The Series > Options > Updates section (frozen builds) offers two update +channels -- Stable / Beta -- as a radio group that maps BY POSITION to the +stored ``update_channel`` value ("release" / "prerelease"). These pin the round-trip both ways through the ACTUAL dialog widgets: 1. Opening the dialog checks the radio matching the stored channel (including - legacy values "stable"/"edge" from older installs). + values remapped by normalize_channel: legacy "stable"/"edge" from older + installs, and "developer" from the removed third channel -> Beta). 2. Applying the dialog writes the channel value for the checked radio. The positional mapping is provided by updater.channel_radio_index / radio_response_channel; this exercises them through the real QuickDialog radio. @@ -30,7 +31,6 @@ RADIO_LABELS = [ "Stable (recommended)", "Beta (early features, may be unstable)", - "Developer (every change, expect breakage)", ] @@ -68,9 +68,9 @@ def _radio_response(dlg): @pytest.mark.parametrize("channel,checked_idx", [ ("release", 0), ("prerelease", 1), - ("developer", 2), - ("stable", 0), # legacy -> Stable - ("edge", 1), # legacy -> Beta + ("stable", 0), # legacy -> Stable + ("edge", 1), # legacy -> Beta + ("developer", 1), # removed third channel -> Beta (not index-0 by accident) ]) def test_dialog_opens_with_stored_channel_checked(qapp, tmp_path, monkeypatch, channel, checked_idx): series = _series(tmp_path) @@ -78,7 +78,7 @@ def test_dialog_opens_with_stored_channel_checked(qapp, tmp_path, monkeypatch, c dlg = _frozen_dialog(monkeypatch, series) try: radio = _radio_response(dlg) - assert [checked for _, checked in radio] == [i == checked_idx for i in range(3)] + assert [checked for _, checked in radio] == [i == checked_idx for i in range(2)] finally: dlg.deleteLater() @@ -86,7 +86,6 @@ def test_dialog_opens_with_stored_channel_checked(qapp, tmp_path, monkeypatch, c @pytest.mark.parametrize("checked_idx,channel", [ (0, "release"), (1, "prerelease"), - (2, "developer"), ]) def test_dialog_setter_persists_selected_channel(qapp, tmp_path, monkeypatch, checked_idx, channel): series = _series(tmp_path) @@ -96,7 +95,7 @@ def test_dialog_setter_persists_selected_channel(qapp, tmp_path, monkeypatch, ch assert w.accept(close=False) # capture real responses first responses = list(w.responses) # select exactly one radio, leaving the startup-check row (index 1) intact - responses[0] = [(RADIO_LABELS[i], i == checked_idx) for i in range(3)] + responses[0] = [(RADIO_LABELS[i], i == checked_idx) for i in range(2)] w.responses = tuple(responses) w.set() # runs the dialog's setOption closure assert series.getOption("update_channel") == channel @@ -114,10 +113,24 @@ def test_dialog_preserves_startup_check_alongside_channel(qapp, tmp_path, monkey w = dlg.all_widgets["updates"] assert w.accept(close=False) responses = list(w.responses) - responses[0] = [(RADIO_LABELS[i], i == 2) for i in range(3)] # Developer + responses[0] = [(RADIO_LABELS[i], i == 1) for i in range(2)] # Beta w.responses = tuple(responses) w.set() - assert series.getOption("update_channel") == "developer" + assert series.getOption("update_channel") == "prerelease" assert series.getOption("update_check_on_startup") is True finally: dlg.deleteLater() + + +def test_dialog_stored_developer_channel_opens_on_beta(qapp, tmp_path, monkeypatch): + """Graceful degradation for the removed Developer channel: an install that + still stores "developer" must open with Beta checked (index 1), never crash + and never silently fall to Stable (index 0).""" + series = _series(tmp_path) + series.setOption("update_channel", "developer") + dlg = _frozen_dialog(monkeypatch, series) + try: + radio = _radio_response(dlg) + assert [checked for _, checked in radio] == [False, True] + finally: + dlg.deleteLater() diff --git a/tests/test_updater.py b/tests/test_updater.py index 30d2fa45..03e29dcc 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -45,7 +45,11 @@ def test_pick_release_prerelease_falls_back_to_newest_prerelease(): rels = [_rel("v1.20.0"), _rel("v1.21.0rc1", prerelease=True)] assert U.pick_release(rels, "prerelease")["tag_name"] == "v1.21.0rc1" -@pytest.mark.parametrize("legacy,canonical", [("stable", "release"), ("edge", "prerelease")]) +@pytest.mark.parametrize("legacy,canonical", [ + ("stable", "release"), + ("edge", "prerelease"), + ("developer", "prerelease"), # removed channel remaps to Beta (graceful degradation) +]) def test_pick_release_maps_legacy_channels(legacy, canonical): assert U.pick_release(RELEASES, legacy) == U.pick_release(RELEASES, canonical) @@ -58,19 +62,24 @@ def test_pick_release_empty(): assert U.pick_release(None, "prerelease") is None -# ---- pick_release: Developer channel + Beta/rolling separation -------------- -def test_pick_release_developer_picks_rolling_tag(): - # The Developer channel selects the rolling build by its reused tag, not by - # version (the tag is identical build-to-build). - assert U.pick_release(RELEASES, "developer")["tag_name"] == U.ROLLING_TAG - -def test_pick_release_developer_none_when_no_rolling_release(): - rels = [_rel("v1.20.0"), _rel("v1.21.0rc1", prerelease=True)] - assert U.pick_release(rels, "developer") is None +# ---- pick_release: removed Developer channel remaps; Beta/rolling separation - +def test_pick_release_stored_developer_channel_behaves_like_prerelease(): + # The Developer channel was removed. A config still storing "developer" (the + # maintainer's, at minimum) must degrade to Beta: normalize_channel remaps it + # to "prerelease", so it takes the prerelease branch -- picking the curated + # semver pre-release and NOT the excluded rolling tag, never erroring. + rels = [ + _rel("prerelease", prerelease=True), + _rel("v1.20.4-rc.1", prerelease=True), + _rel("v1.20.0"), + ] + assert U.pick_release(rels, "developer")["tag_name"] == "v1.20.4-rc.1" -def test_pick_release_developer_ignores_draft_rolling(): - # a draft rolling release (e.g. mid-publish) must not be offered - rels = [_rel("prerelease", prerelease=True, draft=True), _rel("v1.20.0")] +def test_pick_release_stored_developer_never_returns_rolling_tag(): + # Belt-and-suspenders: even when the ONLY prerelease-flagged release is the + # rolling tag, a stored "developer" must offer nothing (like Beta), never the + # rolling build that the removed channel used to serve. + rels = [_rel("prerelease", prerelease=True), _rel("v1.20.0")] assert U.pick_release(rels, "developer") is None def test_pick_release_prerelease_excludes_rolling_when_rolling_is_newest(): @@ -100,21 +109,22 @@ def test_pick_release_prerelease_none_when_only_rolling_prerelease(): # ---- channel value <-> radio helpers ---------------------------------------- @pytest.mark.parametrize("channel,idx", [ - ("release", 0), ("prerelease", 1), ("developer", 2), + ("release", 0), ("prerelease", 1), ("stable", 0), ("edge", 1), # legacy values normalize + ("developer", 1), # removed channel -> Beta radio, not index 0 (None, 0), ("bogus", 0), ("", 0), # unknown/missing -> Stable (the default) ]) def test_channel_radio_index(channel, idx): assert U.channel_radio_index(channel) == idx -@pytest.mark.parametrize("checked_idx,channel", [(0, "release"), (1, "prerelease"), (2, "developer")]) +@pytest.mark.parametrize("checked_idx,channel", [(0, "release"), (1, "prerelease")]) def test_radio_response_channel(checked_idx, channel): - resp = [["Stable", False], ["Beta", False], ["Developer", False]] + resp = [["Stable", False], ["Beta", False]] resp[checked_idx][1] = True assert U.radio_response_channel(resp) == channel def test_radio_response_channel_defaults_release_when_none_checked(): - resp = [("Stable", False), ("Beta", False), ("Developer", False)] + resp = [("Stable", False), ("Beta", False)] assert U.radio_response_channel(resp) == "release" def test_channel_index_and_response_roundtrip(): @@ -129,41 +139,35 @@ def test_normalize_channel(): assert U.normalize_channel("edge") == "prerelease" assert U.normalize_channel("release") == "release" assert U.normalize_channel("prerelease") == "prerelease" - assert U.normalize_channel("developer") == "developer" + # The removed Developer channel remaps to Beta so a stored "developer" option + # never lands on a nonexistent channel (graceful degradation). + assert U.normalize_channel("developer") == "prerelease" -# ---- check_for_update: Developer freshness on the reused rolling tag --------- -def _dev_releases(dev_asset_name): - """A release set whose rolling 'prerelease' build ships the given asset.""" - return [ - _rel(U.ROLLING_TAG, prerelease=True, assets=[dev_asset_name]), +# ---- check_for_update: end-to-end resolution on the Beta channel ------------- +def test_check_for_update_prerelease_offers_curated_prerelease(monkeypatch): + # Beta resolves the newest curated semver pre-release (excluding the rolling + # tag) and reads it as an available update against an older local build. + monkeypatch.setattr(II, "platform_asset_tag", lambda: "Windows-x86_64") + monkeypatch.setattr(II, "current_version", lambda: Version("1.20.0")) + rels = [ + _rel("prerelease", prerelease=True, assets=["PyReconstruct-9.9.9-Windows-x86_64.exe"]), + _rel("v1.21.0rc1", prerelease=True, assets=["PyReconstruct-1.21.0rc1-Windows-x86_64.exe"]), _rel("v1.20.0", assets=["PyReconstruct-1.20.0-Windows-x86_64.exe"]), ] - -def test_check_for_update_developer_offers_when_main_moved(monkeypatch): - # local build is a dev build; main has advanced (higher .devN) -> offer it. - monkeypatch.setattr(II, "platform_asset_tag", lambda: "Windows-x86_64") - monkeypatch.setattr(II, "current_version", lambda: Version("1.21.0.dev12+g1111111.d20260101")) - rels = _dev_releases("PyReconstruct-1.21.0.dev13-Windows-x86_64.exe") - info = U.check_for_update("developer", releases=rels) - assert info["release"]["tag_name"] == U.ROLLING_TAG + info = U.check_for_update("prerelease", releases=rels) + assert info["release"]["tag_name"] == "v1.21.0rc1" # NOT the rolling tag assert info["status"] == "newer" -def test_check_for_update_developer_no_reoffer_when_already_up_to_date(monkeypatch): - # After updating, the local build carries the SAME public .devN as the rolling - # asset (only the +local dirty suffix differs). The reused tag must NOT loop: - # compare_versions strips +local, so remote reads 'same', not 'newer'. +def test_check_for_update_no_asset_when_no_curated_prerelease(monkeypatch): + # Only the rolling tag is prerelease-flagged -> Beta offers nothing. monkeypatch.setattr(II, "platform_asset_tag", lambda: "Windows-x86_64") - monkeypatch.setattr(II, "current_version", lambda: Version("1.21.0.dev13+gABCDEF0.d20260102")) - rels = _dev_releases("PyReconstruct-1.21.0.dev13-Windows-x86_64.exe") - info = U.check_for_update("developer", releases=rels) - assert info["status"] == "same" - -def test_check_for_update_developer_no_asset_when_no_rolling(monkeypatch): - monkeypatch.setattr(II, "platform_asset_tag", lambda: "Windows-x86_64") - monkeypatch.setattr(II, "current_version", lambda: Version("1.21.0.dev13")) - rels = [_rel("v1.20.0", assets=["PyReconstruct-1.20.0-Windows-x86_64.exe"])] - info = U.check_for_update("developer", releases=rels) + monkeypatch.setattr(II, "current_version", lambda: Version("1.20.0")) + rels = [ + _rel("prerelease", prerelease=True, assets=["PyReconstruct-9.9.9-Windows-x86_64.exe"]), + _rel("v1.20.0", assets=["PyReconstruct-1.20.0-Windows-x86_64.exe"]), + ] + info = U.check_for_update("prerelease", releases=rels) assert info["release"] is None and info["asset"] is None