From 15aabd0cd17b92ebdbbc1cb8fdd27f19890d05bf Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 9 Sep 2026 14:07:50 +0000 Subject: [PATCH 1/2] fix(updater): isolate research update trust and normalize versions Git-Session-Id: 3454 --- .github/workflows/release.yml | 39 ++- scripts/package/UPDATER.md | 74 ++++++ scripts/package/configure_tauri_release.py | 81 ++++++ scripts/package/generate_latest_json.py | 28 +- scripts/tests/test_configure_tauri_release.py | 242 ++++++++++++++++++ scripts/tests/test_generate_latest_json.py | 21 ++ 6 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 scripts/package/UPDATER.md create mode 100644 scripts/package/configure_tauri_release.py create mode 100644 scripts/tests/test_configure_tauri_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c2bbab02..00659308d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -292,6 +292,9 @@ jobs: - name: Run profile patcher and installer collection tests run: python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q + - name: Test updater channel isolation and release versions + run: python3 -m pytest scripts/tests/test_generate_latest_json.py scripts/tests/test_configure_tauri_release.py -q + build-qt: name: Build Qt artifacts if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' @@ -1067,6 +1070,27 @@ jobs: preset="$(python3 scripts/emit_research_category_preset.py)" echo "AW_PRESET_CATEGORY_SETS=${preset}" >> "$GITHUB_ENV" + # aw-tauri/Makefile enables bundle.createUpdaterArtifacts through a CLI + # --config override when TAURI_SIGNING_PRIVATE_KEY is set. The JSON file + # alone therefore does not describe whether signed bundles are produced. + - name: Configure Tauri release version and update channel + run: | + args=() + if [ "$AW_RESEARCH_EDITION" = true ]; then + args+=(--research) + if [[ "$GITHUB_REF" == refs/tags/v* || "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + args+=(--require-signing-key) + fi + fi + python3 scripts/package/configure_tauri_release.py \ + --config aw-tauri/src-tauri/tauri.conf.json \ + --version "$VERSION_NO_V" "${args[@]}" + env: + TAURI_UPDATER_PUBLIC_KEY_RESEARCH: ${{ vars.TAURI_UPDATER_PUBLIC_KEY_RESEARCH }} + # Select the secret NAME, never `research_secret || standard_secret`: + # an absent research secret must not fall back to standard signing. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }} + - name: Build uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 with: @@ -1081,10 +1105,9 @@ jobs: make build SKIP_WEBUI=${{ matrix.skip_webui }} SKIP_SERVER_RUST=${{ matrix.skip_rust }} pip freeze env: - # Signs aw-tauri bundles and emits .sig files for the updater when - # createUpdaterArtifacts is enabled in aw-tauri/src-tauri/tauri.conf.json. - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + # aw-tauri/Makefile enables createUpdaterArtifacts when this key is set. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'] }} - name: Run tests uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 @@ -1128,8 +1151,8 @@ jobs: source venv/bin/activate || source venv/Scripts/activate make --directory=aw-tauri build env: - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'] }} - name: Import macOS signing certificate if: runner.os == 'macOS' && (startsWith(github.ref, 'refs/tags/v') || env.AW_RESEARCH_EDITION == 'true') @@ -1402,8 +1425,8 @@ jobs: # are set by Actions) rather than interpolated into the shell script, so # a crafted v* tag cannot inject commands into this contents-write job. # - # Editions are partitioned by filename so they cannot share an updater - # endpoint: standard writes latest.json, research writes latest-research.json. + # Keep versioned release assets separate. Research clients use the + # independent research-updates branch; these assets do not publish that feed. - name: Generate updater manifest run: | set -euo pipefail diff --git a/scripts/package/UPDATER.md b/scripts/package/UPDATER.md new file mode 100644 index 000000000..c075a561a --- /dev/null +++ b/scripts/package/UPDATER.md @@ -0,0 +1,74 @@ +# Tauri release configuration + +`release.yml` runs `configure_tauri_release.py` before compiling aw-tauri. +It writes the release version to `aw-tauri/src-tauri/tauri.conf.json`, which +Tauri embeds as its package version. `generate_latest_json.py` uses the same +`tauri_version()` conversion for the manifest: `v0.14.0b5` becomes +`0.14.0-beta.5`. Original AW spellings remain in asset filenames and tag URLs. +Development builds also receive a SemVer version, with a `dev.g` suffix. + +The standard build keeps its checked-in updater endpoint and public key. +Research builds use only: + +```text +https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json +``` + +Configure these repository settings before a research tag or manual build: + +| Setting | Purpose | +| --- | --- | +| Variable `TAURI_UPDATER_PUBLIC_KEY_RESEARCH` | Base64-encoded minisign public-key file emitted by Tauri's signer | +| Secret `TAURI_SIGNING_PRIVATE_KEY_RESEARCH` | Research-only signing key in Tauri's expected format | +| Secret `TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH` | Password for that key (empty for an unencrypted key) | + +The workflow selects secret **names** by edition. A missing research secret +never falls back to a standard secret. The configurator rejects a research +public key containing the standard key material, even if its comment or key +ID differs. Research tag/manual builds fail if either key is missing. +Secretless research PR smoke builds clear both updater endpoints and the +public key. They cannot consume the standard channel. + +`aw-tauri/Makefile` enables `bundle.createUpdaterArtifacts` by passing a +`--config` override to the Tauri build when `TAURI_SIGNING_PRIVATE_KEY` is +present. That setting is therefore absent from the checked-in JSON even +though release builds produce signatures. Both the initial build and the +research-profile rebuild receive the edition's selected signing key. + +## Release acceptance still required + +This configuration does not create the `research-updates` branch, publish +its manifest, verify that a private key matches the configured public key, +or change any already installed client. Before offering an update: + +- Provision the channel and verify its public URL. Publish only a complete, + verified manifest after its versioned release is public; use a normal + fast-forward commit with an expected parent and a monotonic version check + so a late release job cannot overwrite a newer feed. +- Verify actual bundle signatures with the research key and reject standard + signatures. Config/fixture checks alone are insufficient. +- Verify the built app's version, equal-version no-update, one newer-version + upgrade, and no repeated offer after restart. Both versions must preserve + the research profile, privacy defaults, install identity, and bundled + watchers/aw-sync. Native updater bundles and full AW packages require a + payload-equivalence check. +- Replace or verifiably contain any already distributed research app that + embedded the standard endpoint/key **before publishing a valid standard + stable manifest**. A future configuration cannot repair an installed app. + +The pipeline creates a draft versioned release. Its `latest-research.json` +asset is distinct from the live branch feed; uploading the asset does not +advance that feed. Do not publish the draft until these gates are satisfied. + +## Focused checks + +Initialize the pinned `aw-tauri` submodule, then run: + +```sh +python3 -m pytest scripts/tests/test_generate_latest_json.py scripts/tests/test_configure_tauri_release.py -q +``` + +These tests execute both CLIs against the pinned config, exercise missing and +same-key rejection, preserve standard trust, and check both directions of +asset partitioning. Dummy signatures in these fixtures do not prove signing +or installation. diff --git a/scripts/package/configure_tauri_release.py b/scripts/package/configure_tauri_release.py new file mode 100644 index 000000000..19b7e498b --- /dev/null +++ b/scripts/package/configure_tauri_release.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Set the compiled updater version and isolate Research Edition update trust.""" + +import argparse +import base64 +import binascii +import json +import os +from pathlib import Path + +from generate_latest_json import tauri_version + +RESEARCH_ENDPOINT = ( + "https://raw.githubusercontent.com/ActivityWatch/activitywatch/" + "research-updates/latest-research.json" +) +STANDARD_ENDPOINT = "https://github.com/ActivityWatch/activitywatch/releases/latest/download/latest.json" + + +def public_key(encoded: str) -> bytes: + """Read Tauri's base64-wrapped minisign public key, ignoring its comment.""" + try: + lines = base64.b64decode(encoded, validate=True).decode("ascii").splitlines() + if len(lines) != 2 or not lines[0].startswith("untrusted comment: "): + raise ValueError("invalid public key envelope") + packet = base64.b64decode(lines[1], validate=True) + if len(packet) != 42 or packet[:2] != b"Ed": + raise ValueError("invalid public key packet") + return packet[10:] + except (ValueError, UnicodeError, binascii.Error) as exc: + raise ValueError("Expected a base64-encoded minisign public key") from exc + + +def configure( + path: Path, version: str, research: bool, require_signing_key: bool +) -> None: + config = json.loads(path.read_text(encoding="utf-8")) + config["version"] = tauri_version(version) + if research: + updater = config["plugins"]["updater"] + if updater["endpoints"] != [STANDARD_ENDPOINT]: + raise ValueError( + "Unexpected source updater endpoint; review before patching" + ) + research_key = os.environ.get("TAURI_UPDATER_PUBLIC_KEY_RESEARCH", "").strip() + signing_key = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "").strip() + if require_signing_key and (not research_key or not signing_key): + raise ValueError( + "Research releases require their own public and signing keys" + ) + if research_key: + if public_key(research_key) == public_key(updater["pubkey"]): + raise ValueError( + "Research updater key must differ from the standard key" + ) + updater["pubkey"] = research_key + updater["endpoints"] = [RESEARCH_ENDPOINT] + else: + if signing_key: + raise ValueError("Research signing key supplied without its public key") + # Secretless PR smoke builds must never retain standard update trust. + updater["pubkey"] = "" + updater["endpoints"] = [] + path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--research", action="store_true") + parser.add_argument("--require-signing-key", action="store_true") + args = parser.parse_args() + try: + configure(args.config, args.version, args.research, args.require_signing_key) + except (ValueError, KeyError) as exc: + parser.exit(1, f"ERROR: {exc}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/package/generate_latest_json.py b/scripts/package/generate_latest_json.py index 447ee24cf..b953248f4 100755 --- a/scripts/package/generate_latest_json.py +++ b/scripts/package/generate_latest_json.py @@ -33,6 +33,32 @@ def normalize_version(version: str) -> str: return version +def tauri_version(version: str) -> str: + """Convert AW release/dev labels to the SemVer embedded by Tauri. + + Asset filenames and GitHub tags keep their original AW version spelling. + """ + version = normalize_version(version) + number = r"(?:0|[1-9][0-9]*)" + match = re.fullmatch( + rf"(?P{number}\.{number}\.{number})" + rf"(?:(?P
a|b|rc)(?P{number}))?"
+        r"(?:\.dev-(?P[0-9a-f]+|unknown))?",
+        version,
+    )
+    if not match:
+        raise ValueError(f"Unsupported ActivityWatch version: {version!r}")
+    suffix = []
+    if match["pre"]:
+        suffix.extend(
+            ({"a": "alpha", "b": "beta", "rc": "rc"}[match["pre"]], match["serial"])
+        )
+    if match["dev"]:
+        # Prefix hashes so an all-digit hash with a leading zero stays valid.
+        suffix.extend(("dev", "g" + match["dev"]))
+    return match["base"] + ("-" + ".".join(suffix) if suffix else "")
+
+
 def infer_edition(tag: str, edition=None) -> str:
     if edition:
         if edition not in EDITIONS:
@@ -142,7 +168,7 @@ def main(argv=None):
             f"{os.path.basename(args.output)}"
         )
 
-    manifest = build_manifest(version, args.notes, platforms)
+    manifest = build_manifest(tauri_version(version), args.notes, platforms)
 
     with open(args.output, "w") as f:
         json.dump(manifest, f, indent=2)
diff --git a/scripts/tests/test_configure_tauri_release.py b/scripts/tests/test_configure_tauri_release.py
new file mode 100644
index 000000000..a818d4037
--- /dev/null
+++ b/scripts/tests/test_configure_tauri_release.py
@@ -0,0 +1,242 @@
+import base64
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import textwrap
+
+import pytest
+
+ROOT = Path(__file__).parents[2]
+CONFIGURE = ROOT / "scripts/package/configure_tauri_release.py"
+GENERATOR = ROOT / "scripts/package/generate_latest_json.py"
+RESEARCH_ENDPOINT = (
+    "https://raw.githubusercontent.com/ActivityWatch/activitywatch/"
+    "research-updates/latest-research.json"
+)
+
+
+def key(material=b"r" * 32, comment="fixture", key_id=b"i" * 8):
+    packet = base64.b64encode(b"Ed" + key_id + material).decode()
+    return base64.b64encode(
+        f"untrusted comment: {comment}\n{packet}\n".encode()
+    ).decode()
+
+
+@pytest.fixture
+def config(tmp_path):
+    # Exercise the real pinned Tauri config, including its standard public key.
+    source = ROOT / "aw-tauri/src-tauri/tauri.conf.json"
+    assert source.exists(), "Initialize the aw-tauri submodule before these tests"
+    path = tmp_path / "tauri.conf.json"
+    path.write_bytes(source.read_bytes())
+    return path
+
+
+def run_configure(config, *args, public="", private="", version="v0.14.0b5-research"):
+    env = dict(
+        os.environ,
+        TAURI_UPDATER_PUBLIC_KEY_RESEARCH=public,
+        TAURI_SIGNING_PRIVATE_KEY=private,
+    )
+    return subprocess.run(
+        [
+            sys.executable,
+            str(CONFIGURE),
+            "--config",
+            str(config),
+            "--version",
+            version,
+            *args,
+        ],
+        env=env,
+        text=True,
+        capture_output=True,
+    )
+
+
+def test_standard_version_changes_without_changing_update_trust(config):
+    before = json.loads(config.read_text())
+    result = run_configure(config, version="v0.14.0rc2")
+    assert result.returncode == 0, result.stderr
+    after = json.loads(config.read_text())
+    assert after["version"] == "0.14.0-rc.2"
+    before["version"] = after["version"]
+    assert after == before
+
+
+def test_research_release_replaces_endpoint_and_key(config):
+    result = run_configure(
+        config,
+        "--research",
+        "--require-signing-key",
+        public=key(),
+        private="fixture-secret-presence-only",
+    )
+    assert result.returncode == 0, result.stderr
+    after = json.loads(config.read_text())
+    assert after["version"] == "0.14.0-beta.5"
+    assert after["plugins"]["updater"]["endpoints"] == [RESEARCH_ENDPOINT]
+    assert after["plugins"]["updater"]["pubkey"] == key()
+
+
+def test_secretless_research_smoke_build_has_no_update_trust(config):
+    result = run_configure(config, "--research")
+    assert result.returncode == 0, result.stderr
+    updater = json.loads(config.read_text())["plugins"]["updater"]
+    assert updater["endpoints"] == []
+    assert updater["pubkey"] == ""
+
+
+@pytest.mark.parametrize("public,private", [("", ""), (key(), ""), ("", "secret")])
+def test_release_missing_either_key_fails_without_modifying_config(
+    config, public, private
+):
+    before = config.read_bytes()
+    result = run_configure(
+        config, "--research", "--require-signing-key", public=public, private=private
+    )
+    assert result.returncode != 0
+    assert "require their own public and signing keys" in result.stderr
+    assert config.read_bytes() == before
+
+
+@pytest.mark.parametrize("change_envelope", [False, True])
+def test_same_standard_key_rejected_even_with_changed_comment_and_id(
+    config, change_envelope
+):
+    before = config.read_bytes()
+    standard = json.loads(before)["plugins"]["updater"]["pubkey"]
+    if change_envelope:
+        packet = base64.b64decode(base64.b64decode(standard).decode().splitlines()[1])
+        standard = key(packet[10:], comment="research", key_id=b"j" * 8)
+    result = run_configure(config, "--research", public=standard)
+    assert result.returncode != 0
+    assert "must differ" in result.stderr
+    assert config.read_bytes() == before
+
+
+@pytest.mark.parametrize("bad_key", ["bad-base64", "dGVzdA==", key(b"short")])
+def test_malformed_public_key_rejected(config, bad_key):
+    before = config.read_bytes()
+    result = run_configure(config, "--research", public=bad_key)
+    assert result.returncode != 0
+    assert config.read_bytes() == before
+
+
+def test_endpoint_drift_fails_closed(config):
+    data = json.loads(config.read_text())
+    data["plugins"]["updater"]["endpoints"].append("https://example.com/latest.json")
+    config.write_text(json.dumps(data))
+    before = config.read_bytes()
+    result = run_configure(config, "--research", public=key())
+    assert result.returncode != 0
+    assert "Unexpected source updater endpoint" in result.stderr
+    assert config.read_bytes() == before
+
+
+@pytest.mark.parametrize(
+    "event,ref,research,success",
+    [
+        ("push", "refs/tags/v0.14.0b5-research", "true", False),
+        ("workflow_dispatch", "refs/heads/master", "true", False),
+        ("pull_request", "refs/pull/1/merge", "true", True),
+        ("push", "refs/heads/master", "true", True),
+        ("push", "refs/tags/v0.14.0", "false", True),
+    ],
+)
+def test_workflow_requires_keys_only_for_publishable_research_builds(
+    config,
+    event,
+    ref,
+    research,
+    success,
+):
+    workflow = (ROOT / ".github/workflows/release.yml").read_text()
+    step = workflow.split(
+        "      - name: Configure Tauri release version and update channel\n"
+    )[1]
+    script = textwrap.dedent(
+        step.split("        run: |\n")[1].split("        env:\n")[0]
+    )
+    work = config.parent / "build"
+    shutil.copytree(ROOT / "scripts/package", work / "scripts/package")
+    target = work / "aw-tauri/src-tauri/tauri.conf.json"
+    target.parent.mkdir(parents=True)
+    shutil.copyfile(config, target)
+    env = dict(
+        os.environ,
+        AW_RESEARCH_EDITION=research,
+        GITHUB_REF=ref,
+        GITHUB_EVENT_NAME=event,
+        VERSION_NO_V="0.14.0b5",
+        TAURI_UPDATER_PUBLIC_KEY_RESEARCH="",
+        TAURI_SIGNING_PRIVATE_KEY="",
+    )
+    result = subprocess.run(
+        ["bash", "-e", "-c", script], cwd=work, env=env, text=True, capture_output=True
+    )
+    assert (result.returncode == 0) == success, result.stderr
+    if success and research == "true":
+        assert json.loads(target.read_text())["plugins"]["updater"]["endpoints"] == []
+    if not success:
+        assert target.read_bytes() == config.read_bytes()
+
+
+@pytest.mark.parametrize("edition", ["standard", "research"])
+def test_manifest_and_compiled_config_share_version_and_partition_assets(
+    config, edition
+):
+    tag = "v0.14.0b5" + ("-research" if edition == "research" else "")
+    result = run_configure(
+        config,
+        *(["--research"] if edition == "research" else []),
+        version=tag,
+        public=key(),
+    )
+    assert result.returncode == 0, result.stderr
+    for token in ("", "-research"):
+        bundle = (
+            config.parent / f"activitywatch-tauri{token}-0.14.0b5-linux-x86_64.AppImage"
+        )
+        bundle.write_bytes(b"fixture, not a cryptographic verification")
+        bundle.with_suffix(".AppImage.sig").write_text("sig" + token)
+    output = config.parent / (
+        "latest-research.json" if edition == "research" else "latest.json"
+    )
+    result = subprocess.run(
+        [
+            sys.executable,
+            str(GENERATOR),
+            "--version",
+            tag,
+            "--tag",
+            tag,
+            "--edition",
+            edition,
+            "--repo",
+            "ActivityWatch/activitywatch",
+            "--notes",
+            "fixture",
+            "--dist",
+            str(config.parent),
+            "--output",
+            str(output),
+        ],
+        text=True,
+        capture_output=True,
+    )
+    assert result.returncode == 0, result.stderr
+    manifest = json.loads(output.read_text())
+    assert (
+        manifest["version"]
+        == json.loads(config.read_text())["version"]
+        == "0.14.0-beta.5"
+    )
+    assert list(manifest["platforms"]) == ["linux-x86_64"]
+    target = manifest["platforms"]["linux-x86_64"]
+    assert ("-research" in target["url"]) == (edition == "research")
+    assert f"/releases/download/{tag}/" in target["url"]
+    assert target["signature"] == "sig" + ("-research" if edition == "research" else "")
diff --git a/scripts/tests/test_generate_latest_json.py b/scripts/tests/test_generate_latest_json.py
index acd4fd59f..e878a2ae0 100644
--- a/scripts/tests/test_generate_latest_json.py
+++ b/scripts/tests/test_generate_latest_json.py
@@ -33,6 +33,27 @@ def test_normalize_version_strips_v_and_research_suffix():
     assert gen.normalize_version("0.14.0b4-research") == "0.14.0b4"
 
 
+@pytest.mark.parametrize(
+    "version,expected",
+    [
+        ("v0.14.0", "0.14.0"),
+        ("v0.14.0b5-research", "0.14.0-beta.5"),
+        ("0.14.0a1", "0.14.0-alpha.1"),
+        ("0.14.0rc2", "0.14.0-rc.2"),
+        ("v0.14.0.dev-0123456", "0.14.0-dev.g0123456"),
+        ("v0.14.0b5.dev-abcdef0", "0.14.0-beta.5.dev.gabcdef0"),
+    ],
+)
+def test_tauri_version(version, expected):
+    assert gen.tauri_version(version) == expected
+
+
+@pytest.mark.parametrize("version", ["0.14", "0.14.0b01", "00.14.0", "0.14.0oops", ""])
+def test_tauri_version_refuses_unrecognized_labels(version):
+    with pytest.raises(ValueError):
+        gen.tauri_version(version)
+
+
 def test_infer_edition_from_tag_or_explicit_flag():
     assert gen.infer_edition("v0.14.0") == "standard"
     assert gen.infer_edition("v0.14.0b4") == "standard"

From 535d3e434a8fb4d7a93ad885f268a18660fcae99 Mon Sep 17 00:00:00 2001
From: Bob 
Date: Wed, 9 Sep 2026 14:59:57 +0000
Subject: [PATCH 2/2] fix(updater): drop msi bundle target when the version has
 a non-numeric pre-release

Tauri's msi (WiX) bundler rejects any pre-release identifier that isn't
numeric-only, but tauri_version() maps AW's beta/rc/dev suffixes to
SemVer pre-release strings like "0.14.0-beta.5" or
"0.14.0-dev.gabc1234". Every non-final-release Windows build (which is
effectively every CI build) failed bundling with:

  failed to bundle project: `optional pre-release identifier in app
  version must be numeric-only and cannot be greater than 65535 for
  msi target`

Drop msi from bundle.targets on Windows when the computed version is
msi-incompatible, keeping nsis (which has no such restriction).

Co-Authored-By: Bob 
---
 scripts/package/configure_tauri_release.py    | 31 +++++++++++-
 scripts/tests/test_configure_tauri_release.py | 48 +++++++++++++++++++
 2 files changed, 77 insertions(+), 2 deletions(-)

diff --git a/scripts/package/configure_tauri_release.py b/scripts/package/configure_tauri_release.py
index 19b7e498b..ab245508a 100644
--- a/scripts/package/configure_tauri_release.py
+++ b/scripts/package/configure_tauri_release.py
@@ -6,6 +6,7 @@
 import binascii
 import json
 import os
+import sys
 from pathlib import Path
 
 from generate_latest_json import tauri_version
@@ -17,6 +18,20 @@
 STANDARD_ENDPOINT = "https://github.com/ActivityWatch/activitywatch/releases/latest/download/latest.json"
 
 
+def msi_rejects(version: str) -> bool:
+    """True if Tauri's msi (WiX) bundler will reject this SemVer version.
+
+    The msi target requires the optional pre-release identifier to be
+    numeric-only (e.g. "0.14.0-5"), unlike nsis or the other bundle formats.
+    AW's own pre-release scheme ("0.14.0-beta.5", "0.14.0-dev.gabc1234")
+    fails that check every time.
+    """
+    if "-" not in version:
+        return False
+    prerelease = version.split("-", 1)[1]
+    return not all(part.isdigit() for part in prerelease.split("."))
+
+
 def public_key(encoded: str) -> bytes:
     """Read Tauri's base64-wrapped minisign public key, ignoring its comment."""
     try:
@@ -32,10 +47,22 @@ def public_key(encoded: str) -> bytes:
 
 
 def configure(
-    path: Path, version: str, research: bool, require_signing_key: bool
+    path: Path,
+    version: str,
+    research: bool,
+    require_signing_key: bool,
+    *,
+    platform: str = sys.platform,
 ) -> None:
     config = json.loads(path.read_text(encoding="utf-8"))
-    config["version"] = tauri_version(version)
+    tv = tauri_version(version)
+    config["version"] = tv
+    if platform == "win32" and msi_rejects(tv):
+        bundle = config.setdefault("bundle", {})
+        if bundle.get("targets") == "all":
+            # Windows only ever produces msi+nsis from "all"; drop msi and
+            # keep nsis, which accepts the full AW pre-release scheme.
+            bundle["targets"] = ["nsis"]
     if research:
         updater = config["plugins"]["updater"]
         if updater["endpoints"] != [STANDARD_ENDPOINT]:
diff --git a/scripts/tests/test_configure_tauri_release.py b/scripts/tests/test_configure_tauri_release.py
index a818d4037..7f2b9f874 100644
--- a/scripts/tests/test_configure_tauri_release.py
+++ b/scripts/tests/test_configure_tauri_release.py
@@ -17,6 +17,9 @@
     "research-updates/latest-research.json"
 )
 
+sys.path.insert(0, str(ROOT / "scripts/package"))
+from configure_tauri_release import configure, msi_rejects  # noqa: E402
+
 
 def key(material=b"r" * 32, comment="fixture", key_id=b"i" * 8):
     packet = base64.b64encode(b"Ed" + key_id + material).decode()
@@ -57,6 +60,51 @@ def run_configure(config, *args, public="", private="", version="v0.14.0b5-resea
     )
 
 
+@pytest.mark.parametrize(
+    "version,rejects",
+    [
+        ("0.14.0", False),
+        ("0.14.0-5", False),
+        ("0.14.0-beta.5", True),
+        ("0.14.0-dev.gabc1234", True),
+        ("0.14.0-rc.1", True),
+    ],
+)
+def test_msi_rejects(version, rejects):
+    assert msi_rejects(version) is rejects
+
+
+def test_windows_drops_msi_for_non_numeric_prerelease(config):
+    # v0.14.0b5 -> "0.14.0-beta.5", which the msi (WiX) bundler rejects.
+    configure(
+        config, "v0.14.0b5", research=False, require_signing_key=False, platform="win32"
+    )
+    after = json.loads(config.read_text())
+    assert after["version"] == "0.14.0-beta.5"
+    assert after["bundle"]["targets"] == ["nsis"]
+
+
+def test_non_windows_keeps_all_targets_for_non_numeric_prerelease(config):
+    configure(
+        config,
+        "v0.14.0b5",
+        research=False,
+        require_signing_key=False,
+        platform="darwin",
+    )
+    after = json.loads(config.read_text())
+    assert after["bundle"]["targets"] == "all"
+
+
+def test_windows_keeps_all_targets_for_msi_safe_version(config):
+    configure(
+        config, "v0.14.0", research=False, require_signing_key=False, platform="win32"
+    )
+    after = json.loads(config.read_text())
+    assert after["version"] == "0.14.0"
+    assert after["bundle"]["targets"] == "all"
+
+
 def test_standard_version_changes_without_changing_update_trust(config):
     before = json.loads(config.read_text())
     result = run_configure(config, version="v0.14.0rc2")