diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 09cf00f..8043742 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,18 @@ # 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.`` (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 +# (``-``) 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 @@ -37,6 +49,11 @@ name: build # 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"] @@ -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 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) @@ -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" @@ -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) @@ -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. @@ -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" @@ -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 @@ -285,4 +326,4 @@ jobs: tag_name: ${{ github.event.release.tag_name }} fail_on_unmatched_files: true files: | - ExLab-Wizard-* + ExLabWizard_v* diff --git a/README.md b/README.md index e9de35f..8eea354 100644 --- a/README.md +++ b/README.md @@ -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` 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 diff --git a/packaging/windows/exlab-wizard.iss b/packaging/windows/exlab-wizard.iss index 58ab455..a17887b 100644 --- a/packaging/windows/exlab-wizard.iss +++ b/packaging/windows/exlab-wizard.iss @@ -11,13 +11,21 @@ ; Run-key entries -- registering autostart here would double-register. ; ; Build (from repo root): -; iscc /DAppVersion= packaging\windows\exlab-wizard.iss +; iscc /DAppVersion= /DOutputBaseName=ExLabWizard_v \ +; 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; 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 @@ -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") diff --git a/src/exlab_wizard/update_check/checker.py b/src/exlab_wizard/update_check/checker.py index 3bc0f00..b5a56da 100644 --- a/src/exlab_wizard/update_check/checker.py +++ b/src/exlab_wizard/update_check/checker.py @@ -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 diff --git a/tests/integration/test_nas_sync.py b/tests/integration/test_nas_sync.py index 262383a..1a2a3d8 100644 --- a/tests/integration/test_nas_sync.py +++ b/tests/integration/test_nas_sync.py @@ -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() diff --git a/tests/unit/sync/test_nas_client.py b/tests/unit/sync/test_nas_client.py index ab4deb1..f13656a 100644 --- a/tests/unit/sync/test_nas_client.py +++ b/tests/unit/sync/test_nas_client.py @@ -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() diff --git a/tests/unit/update_check/test_checker.py b/tests/unit/update_check/test_checker.py index c38ee09..e8e02d6 100644 --- a/tests/unit/update_check/test_checker.py +++ b/tests/unit/update_check/test_checker.py @@ -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