Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 14 additions & 91 deletions .github/workflows/build-installers.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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/*
23 changes: 17 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 32 additions & 23 deletions PyReconstruct/modules/backend/updater/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,32 @@
# 'PyReconstruct-<version>-<Platform>-<arch>...' -> capture <version>.
_ASSET_VERSION_RE = re.compile(r"PyReconstruct-(?P<ver>.+?)-(?: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):
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion PyReconstruct/modules/datatypes/default_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions PyReconstruct/modules/gui/dialog/all_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))],
]
Expand Down
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<branch>` to change branch first). See [CONTRIBUTING.md](CONTRIBUTING.md) for the
full development setup (the conda `pyrecon_dev` environment, tests, and code
layout).

## Quickstart

Expand Down
4 changes: 4 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
Loading
Loading