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
79 changes: 60 additions & 19 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,30 @@
# On a published release every produced asset is attached to that release.
# Installers do NOT register OS autostart -- the app manages its own
# autostart at runtime (in-app AutostartManager).
#
# Asset naming: installers are named ``ExLabWizard_v<release-tag>.<ext>`` (the
# release tag drives the name; a leading ``v`` is normalised so both ``v0.2.0``
# and ``0.2.0`` yield ``ExLabWizard_v0.2.0``). Raw archives keep an OS suffix
# (``-<suffix>``) because macOS and Linux both emit ``.tar.gz`` and would
# otherwise collide on the release.
#
# Pre-releases: the ``published`` activity type fires for pre-releases too, so
# a published pre-release builds and attaches the full installer set -- letting
# you test a release candidate. The startup update notifier still ignores
# pre-releases (it polls ``releases/latest``, which excludes them), so a
# pre-release never prompts operators to upgrade.

name: build

# Build checks run only on pull requests targeting ``main`` plus the
# release-publish path and a manual dispatch hook. We do NOT run on
# every branch push: feature branches are exercised by unit + e2e
# locally and via PR; binary builds are reserved for the merge gate.
#
# ``published`` covers BOTH stable releases and pre-releases (publishing a
# pre-release fires ``published``), so both get the full installer set. We do
# not also subscribe to ``prereleased`` -- that would double-run for
# pre-releases.
on:
pull_request:
branches: ["main"]
Expand Down Expand Up @@ -123,32 +140,54 @@ jobs:
- name: Package artifact
id: package
shell: bash
env:
# Routed through env (not interpolated into the script body) so a
# crafted release tag cannot inject shell -- per the GitHub Actions
# script-injection guidance.
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
version=$(python -c "from exlab_wizard import __version__; print(__version__)")
out_name="ExLab-Wizard-${version}-${{ matrix.artifact_suffix }}"
# Release assets are named by the release tag. Non-release builds
# (workflow_dispatch / PR) have no tag, so fall back to the package
# version to keep a sensibly-named artifact.
if [ "$EVENT_NAME" = "release" ]; then
raw_tag="$RELEASE_TAG"
else
raw_tag="$version"
fi
# Strip a leading v/V so both v0.2.0 and 0.2.0 yield the canonical
# ExLabWizard_v0.2.0 (never ExLabWizard_vv0.2.0).
tag="${raw_tag#v}"
tag="${tag#V}"
release_name="ExLabWizard_v${tag}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "out_name=${out_name}" >> "$GITHUB_OUTPUT"
echo "release_name=${release_name}" >> "$GITHUB_OUTPUT"
# Installers (unique extensions) get the bare ExLabWizard_v<tag> name
# in their dedicated steps. The raw onedir archive keeps an OS suffix:
# macOS and Linux both emit .tar.gz and would otherwise collide.
archive="${release_name}-${{ matrix.artifact_suffix }}"
case "${{ matrix.os }}" in
windows-latest)
cd dist
7z a -tzip "../${out_name}.zip" "ExLab-Wizard"
7z a -tzip "../${archive}.zip" "ExLab-Wizard"
cd ..
;;
macos-*)
# Raw unsigned ``.app`` directory inside a tar; the .dmg is
# produced by a dedicated step below.
cd dist
tar -czf "../${out_name}.tar.gz" "ExLab-Wizard.app"
tar -czf "../${archive}.tar.gz" "ExLab-Wizard.app"
cd ..
;;
ubuntu-latest)
cd dist
tar -czf "../${out_name}.tar.gz" "ExLab-Wizard"
tar -czf "../${archive}.tar.gz" "ExLab-Wizard"
cd ..
;;
esac
ls -lh ExLab-Wizard-* || true
ls -lh ExLabWizard_v* || true

# --- Installer: Windows (Inno Setup .exe) ------------------------------
- name: Install Inno Setup (Windows)
Expand All @@ -170,6 +209,7 @@ jobs:
run: |
set -euo pipefail
version="${{ steps.package.outputs.version }}"
release_name="${{ steps.package.outputs.release_name }}"
# choco installs ISCC.exe but does not add it to the current shell's
# PATH, so resolve it explicitly (known install dir, then a search).
iscc="/c/Program Files (x86)/Inno Setup 6/ISCC.exe"
Expand All @@ -183,9 +223,10 @@ jobs:
# Git-bash rewrites a leading-slash arg like "/DAppVersion=..." into a
# Windows path, so ISCC sees it as a second script filename. Disable
# MSYS path conversion for this call; the .iss path is relative and so
# is left untouched.
MSYS_NO_PATHCONV=1 "$iscc" "/DAppVersion=${version}" packaging/windows/exlab-wizard.iss
ls -lh "ExLab-Wizard-${version}-win-x64-setup.exe"
# is left untouched. AppVersion = the app's own __version__ (installer
# metadata); OutputBaseName = the release-tag-derived asset filename.
MSYS_NO_PATHCONV=1 "$iscc" "/DAppVersion=${version}" "/DOutputBaseName=${release_name}" packaging/windows/exlab-wizard.iss
ls -lh "${release_name}.exe"

# --- Installer: macOS (.dmg) -------------------------------------------
- name: Install create-dmg (macOS)
Expand All @@ -197,8 +238,8 @@ jobs:
shell: bash
run: |
set -uo pipefail
version="${{ steps.package.outputs.version }}"
dmg="ExLab-Wizard-${version}-mac-arm64.dmg"
release_name="${{ steps.package.outputs.release_name }}"
dmg="${release_name}.dmg"
rm -f "$dmg"
# create-dmg can be flaky on headless CI; retry once, then fall
# back to a plain hdiutil image so packaging never hard-fails.
Expand Down Expand Up @@ -235,8 +276,8 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN: "1"
run: |
set -euo pipefail
version="${{ steps.package.outputs.version }}"
out="ExLab-Wizard-${version}-linux-x64.AppImage"
release_name="${{ steps.package.outputs.release_name }}"
out="${release_name}.AppImage"

curl -fSL -o appimagetool \
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
Expand Down Expand Up @@ -267,11 +308,11 @@ jobs:
with:
name: exlab-wizard-${{ matrix.artifact_suffix }}
path: |
ExLab-Wizard-*.zip
ExLab-Wizard-*.tar.gz
ExLab-Wizard-*-setup.exe
ExLab-Wizard-*.dmg
ExLab-Wizard-*.AppImage
ExLabWizard_v*.zip
ExLabWizard_v*.tar.gz
ExLabWizard_v*.exe
ExLabWizard_v*.dmg
ExLabWizard_v*.AppImage
if-no-files-found: error
retention-days: 30

Expand All @@ -285,4 +326,4 @@ jobs:
tag_name: ${{ github.event.release.tag_name }}
fail_on_unmatched_files: true
files: |
ExLab-Wizard-*
ExLabWizard_v*
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,21 @@ runtime-only install drop the extra: `uv sync` / `pip install -e .`.

### Pre-built binary

Every tagged release publishes a GitHub Release that carries per-OS installers —
a Windows `.exe` installer, a macOS `.dmg`, and a Linux `.AppImage` — plus raw
archives for each platform under [Releases](../../releases). Download the
installer (or archive) for your platform from the
Every published GitHub Release carries per-OS installers, named
`ExLabWizard_v<version>` with the platform-appropriate extension — a Windows
`.exe`, a macOS `.dmg`, and a Linux `.AppImage` — plus raw per-platform archives
(for offline / USB installs) under [Releases](../../releases). Download the
installer for your platform from the
[latest release](https://github.com/exfab/ExLabWizard/releases/latest) and run it.

On startup ExLab-Wizard checks GitHub for a newer release; when one is found it
raises an OS notification and the tray menu gains a "Check for updates…" item
that opens the releases page. This probe can be disabled with
`update_check.enabled: false` in `config.yaml`.
**Pre-releases** also get the full installer set, so you can test a release
candidate before it ships.

On startup ExLab-Wizard checks GitHub for a newer **stable** release
(pre-releases are ignored, so testing an RC never nags everyone to "upgrade").
When a newer stable release is found it raises an OS notification and the tray
menu gains a "Check for updates…" item that opens the releases page. This probe
can be disabled with `update_check.enabled: false` in `config.yaml`.

## Running ExLab-Wizard

Expand Down
12 changes: 10 additions & 2 deletions packaging/windows/exlab-wizard.iss
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@
; Run-key entries -- registering autostart here would double-register.
;
; Build (from repo root):
; iscc /DAppVersion=<version> packaging\windows\exlab-wizard.iss
; iscc /DAppVersion=<version> /DOutputBaseName=ExLabWizard_v<tag> \
; packaging\windows\exlab-wizard.iss
; CI downloads MicrosoftEdgeWebview2Setup.exe next to this .iss beforehand.

#ifndef AppVersion
#define AppVersion "0.0.0"
#endif

; The release-asset file name (no extension). CI passes
; /DOutputBaseName=ExLabWizard_v<tag>; a manual build falls back to a name
; derived from AppVersion so a bare ``iscc`` invocation still works.
#ifndef OutputBaseName
#define OutputBaseName "ExLabWizard_v" + AppVersion
#endif

[Setup]
AppId={{B3F1C2A4-7E2D-4C6A-9F0B-AAAAEXLAB0001}}
AppName=ExLab-Wizard
Expand All @@ -33,7 +41,7 @@ ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
; Emit the installer at the repo root so the CI upload/release globs find it.
OutputDir=..\..
OutputBaseFilename=ExLab-Wizard-{#AppVersion}-win-x64-setup
OutputBaseFilename={#OutputBaseName}
WizardStyle=modern
; Use the app icon for the installer chrome only when it is actually present.
#if FileExists(AddBackslash(SourcePath) + "..\..\assets\icons\ExLabWizard.ico")
Expand Down
7 changes: 7 additions & 0 deletions src/exlab_wizard/update_check/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ async def fetch_latest_tag(client: httpx.AsyncClient | None = None) -> tuple[str
response = await http.get(API_LATEST_URL, headers=_GITHUB_HEADERS)
response.raise_for_status()
data = response.json()
# ``releases/latest`` returns the most recent non-prerelease, non-draft
# release, so a pre-release never reaches an operator as an update
# prompt. Re-check the flags defensively in case the endpoint or its
# payload shape ever changes -- a pre-release must never notify.
if data.get("prerelease") or data.get("draft"):
logger.info("update_check.skipped_prerelease", extra={"tag": data.get("tag_name")})
return None
return data["tag_name"], data["html_url"]
except httpx.HTTPError as exc:
# Offline / DNS / rate-limited (403) / 5xx -- expected on constrained
Expand Down
16 changes: 13 additions & 3 deletions tests/integration/test_nas_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,20 @@ async def test_full_happy_path_via_stub_rclone(
# the post-cleanup status is ``"cleaned"``; ``"synced"`` is the
# transient state set by ``_mark_synced`` before cleanup runs.
# Both are valid happy-path outcomes; cleanup may have already
# fired by the time we read the file.
# fired by the time we read the file. The status is stamped by a
# separate async step that lags the queue-row transition, so poll
# the file rather than reading it the instant the row goes terminal
# -- a loaded runner otherwise observes the pre-stamp ``pending``.
creation_path = run_dir / CACHE_DIR_NAME / CREATION_JSON_NAME
decoded = msgspec_json.decode(creation_path.read_bytes(), type=CreationJson)
assert decoded.sync_status in {"synced", "cleaned"}

async def _synced_or_cleaned() -> bool:
decoded = msgspec_json.decode(creation_path.read_bytes(), type=CreationJson)
return decoded.sync_status in {"synced", "cleaned"}

await wait_until(
_synced_or_cleaned,
message="creation.json sync_status never became 'synced'/'cleaned'",
)
finally:
await client.close()

Expand Down
12 changes: 10 additions & 2 deletions tests/unit/sync/test_nas_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,17 @@ async def test_worker_drives_to_verified_and_marks_synced(
SyncJobState.CLEANED,
},
)
# ``creation.json``'s ``sync_status`` is stamped by ``_mark_synced``, a
# separate async step that lags the queue-row transition. Poll the file
# itself rather than reading it the instant the row goes terminal --
# otherwise a loaded runner observes the pre-stamp ``pending`` value.
creation_path = run_dir / CACHE_DIR_NAME / CREATION_JSON_NAME
decoded = msgspec_json.decode(creation_path.read_bytes(), type=CreationJson)
assert decoded.sync_status == "synced"

async def _synced() -> bool:
decoded = msgspec_json.decode(creation_path.read_bytes(), type=CreationJson)
return decoded.sync_status == "synced"

await wait_until(_synced, message="creation.json sync_status never became 'synced'")
finally:
await client.close()

Expand Down
32 changes: 32 additions & 0 deletions tests/unit/update_check/test_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,35 @@ async def latest() -> JSONResponse:
result = await fetch_latest_tag(client)

assert result is None


async def test_fetch_skips_prerelease_payload() -> None:
"""A ``prerelease: true`` payload yields ``None`` -- pre-releases never prompt.

``releases/latest`` already excludes pre-releases, but the checker also
guards on the flag defensively; this pins that behaviour.
"""
app = FastAPI()

@app.get(_LATEST_PATH)
async def latest() -> dict[str, object]:
return {"tag_name": "v9.9.9-rc1", "html_url": _HTML_URL, "prerelease": True}

async with _client(app) as client:
result = await fetch_latest_tag(client)

assert result is None


async def test_fetch_skips_draft_payload() -> None:
"""A ``draft: true`` payload yields ``None``."""
app = FastAPI()

@app.get(_LATEST_PATH)
async def latest() -> dict[str, object]:
return {"tag_name": "v9.9.9", "html_url": _HTML_URL, "draft": True}

async with _client(app) as client:
result = await fetch_latest_tag(client)

assert result is None
Loading