From bc232ed6eeecffc4e73619a3e33d8a3c2ea125cc Mon Sep 17 00:00:00 2001 From: Brayo Date: Sat, 25 Jul 2026 17:20:54 +0300 Subject: [PATCH 1/3] ci(release): publish Tauri updater latest.json on tag releases Sign aw-tauri bundles with TAURI_SIGNING_PRIVATE_KEY, collect per-platform updater artifacts, and generate latest.json for the Tauri updater endpoint when publishing draft releases. URLs use github.repository so they resolve to ActivityWatch/activitywatch on upstream runs. --- .github/workflows/release.yml | 70 ++++++++++++++++++++++- scripts/package/generate_latest_json.py | 75 +++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100755 scripts/package/generate_latest_json.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a04150815..dc3afdc46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -723,6 +723,11 @@ jobs: poetry install 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 }} - name: Run tests uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 @@ -811,11 +816,54 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAMID: ${{ secrets.APPLE_TEAMID }} + - name: Package Tauri updater artifacts + run: | + set -euo pipefail + mkdir -p dist/updater + VERSION="${VERSION_WITH_V#v}" + + case "$RUNNER_OS" in + macOS) OS_NAME="darwin" ;; + Linux) OS_NAME="linux" ;; + Windows) OS_NAME="windows" ;; + esac + case "$(uname -m)" in + arm64|aarch64) ARCH_NAME="aarch64" ;; + x86_64|amd64) ARCH_NAME="x86_64" ;; + *) ARCH_NAME="$(uname -m)" ;; + esac + PLATFORM_KEY="${OS_NAME}-${ARCH_NAME}" + + # Only the bundle formats Tauri's updater consumes — avoids picking up + # .sig files for .deb/.rpm/.dmg if those ever get signed too. + found=0 + while IFS= read -r -d '' sig; do + artifact="${sig%.sig}" + if [ -f "$artifact" ]; then + base="${artifact##*/}" + ext="${base#*.}" # everything after the first dot, e.g. "app.tar.gz" + out="dist/updater/activitywatch-tauri-${VERSION}-${PLATFORM_KEY}.${ext}" + cp "$artifact" "$out" + cp "$sig" "$out.sig" + found=$((found + 1)) + fi + done < <(find aw-tauri/src-tauri/target -type f \( \ + -path "*/release/bundle/macos/*.sig" -o \ + -path "*/release/bundle/appimage/*.sig" -o \ + -path "*/release/bundle/nsis/*.sig" -o \ + -path "*/release/bundle/msi/*.sig" \ + \) -print0) + + echo "Found $found Tauri updater artifact(s) for $PLATFORM_KEY" + ls -la dist/updater/ || true + - name: Upload packages uses: actions/upload-artifact@v7 with: name: builds-tauri-${{ matrix.os }}-py${{ matrix.python_version }} - path: dist/activitywatch-*.* + path: | + dist/activitywatch-*.* + dist/updater/* release-notes: name: Generate release notes @@ -920,6 +968,10 @@ jobs: needs: [build-qt, build-tauri, release-notes] runs-on: ubuntu-latest steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Download build artifacts uses: actions/download-artifact@v8 with: @@ -934,13 +986,27 @@ jobs: with: prefix: 'v' + - name: Generate latest.json + run: | + python3 scripts/package/generate_latest_json.py \ + --version "$(bash scripts/package/getversion.sh | sed 's/^v//')" \ + --notes "ActivityWatch ${{ github.ref_name }}" \ + --repo "${{ github.repository }}" \ + --tag "${{ github.ref_name }}" \ + --dist dist \ + --output dist/latest.json + cat dist/latest.json + - name: Release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 with: draft: true # Empty name falls back to the tag name (standard releases unchanged). name: ${{ endsWith(github.ref_name, '-research') && format('{0} (Research Edition)', github.ref_name) || '' }} - files: dist/*/activitywatch-*.* + files: | + dist/*/activitywatch-*.* + dist/*/updater/* + dist/latest.json body_path: dist/release_notes/release_notes.md # check-version-format-action leaves is_stable unset/false for # research-suffixed tags, so this stays prerelease for them too. diff --git a/scripts/package/generate_latest_json.py b/scripts/package/generate_latest_json.py new file mode 100755 index 000000000..d09109272 --- /dev/null +++ b/scripts/package/generate_latest_json.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Assemble a Tauri updater `latest.json` manifest from per-platform .sig files. + +Expects updater artifacts to be named +`activitywatch-tauri--.` with a matching +`<...>.sig` file alongside it (as produced by the "Package Tauri updater +artifacts" step in release.yml), where is a Tauri +updater platform identifier such as "darwin-aarch64" or "linux-x86_64". +""" +import argparse +import json +import os +import re +from datetime import datetime, timezone + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--notes", required=True) + parser.add_argument( + "--repo", required=True, help="e.g. ActivityWatch/activitywatch" + ) + parser.add_argument("--tag", required=True, help="e.g. v0.13.3") + parser.add_argument("--dist", required=True, help="directory to search for *.sig files") + parser.add_argument("--output", required=True) + args = parser.parse_args() + + # Non-greedy platform group: extensions can be multi-part (.app.tar.gz, + # .AppImage.tar.gz, .nsis.zip), so stop at the first dot after the + # platform key rather than the last. + pattern = re.compile( + rf"^activitywatch-tauri-{re.escape(args.version)}-(?P.+?)\.(?P.+)$" + ) + + platforms = {} + for root, _, files in os.walk(args.dist): + for name in files: + if not name.endswith(".sig"): + continue + asset_name = name[: -len(".sig")] + m = pattern.match(asset_name) + if not m: + continue + with open(os.path.join(root, name)) as f: + signature = f.read().strip() + platforms[m.group("platform")] = { + "signature": signature, + "url": ( + f"https://github.com/{args.repo}/releases/download/" + f"{args.tag}/{asset_name}" + ), + } + + if not platforms: + raise SystemExit( + "No updater artifacts found - refusing to write an empty latest.json" + ) + + manifest = { + "version": args.version, + "notes": args.notes, + "pub_date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "platforms": platforms, + } + + with open(args.output, "w") as f: + json.dump(manifest, f, indent=2) + f.write("\n") + + print(f"Wrote {args.output} with platforms: {', '.join(sorted(platforms))}") + + +if __name__ == "__main__": + main() From 112659c7b369bb461c1ceab7ad4c447d7dca2766 Mon Sep 17 00:00:00 2001 From: Brayo Date: Sat, 22 Aug 2026 22:14:23 +0300 Subject: [PATCH 2/3] fix(release): partition updater manifests by edition and harden tag interpolation Pass tag/repo through Actions env vars instead of interpolating them into shell source, pin checkout in the contents-write release job, and emit latest.json vs latest-research.json with matching artifact prefixes so standard and Research Edition lines cannot share an updater endpoint. --- .github/workflows/release.yml | 43 ++++-- scripts/package/generate_latest_json.py | 114 ++++++++++++---- scripts/tests/test_generate_latest_json.py | 151 +++++++++++++++++++++ 3 files changed, 271 insertions(+), 37 deletions(-) create mode 100644 scripts/tests/test_generate_latest_json.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc3afdc46..782e1b667 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -833,6 +833,8 @@ jobs: *) ARCH_NAME="$(uname -m)" ;; esac PLATFORM_KEY="${OS_NAME}-${ARCH_NAME}" + EDITION="" + if [[ "${AW_RESEARCH_EDITION:-}" == "true" ]]; then EDITION="-research"; fi # Only the bundle formats Tauri's updater consumes — avoids picking up # .sig files for .deb/.rpm/.dmg if those ever get signed too. @@ -842,7 +844,7 @@ jobs: if [ -f "$artifact" ]; then base="${artifact##*/}" ext="${base#*.}" # everything after the first dot, e.g. "app.tar.gz" - out="dist/updater/activitywatch-tauri-${VERSION}-${PLATFORM_KEY}.${ext}" + out="dist/updater/activitywatch-tauri${EDITION}-${VERSION}-${PLATFORM_KEY}.${ext}" cp "$artifact" "$out" cp "$sig" "$out.sig" found=$((found + 1)) @@ -968,7 +970,9 @@ jobs: needs: [build-qt, build-tauri, release-notes] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + # Pin checkout in this contents-write job so a moved v7 tag cannot + # change the release automation. Build jobs keep actions/checkout@v7. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -986,16 +990,36 @@ jobs: with: prefix: 'v' - - name: Generate latest.json + # Tag/repo values are passed via env (GITHUB_REF_NAME / GITHUB_REPOSITORY + # 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. + - name: Generate updater manifest run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + VERSION="${VERSION%-research}" + if [[ "$TAG" == *-research ]]; then + EDITION="research" + MANIFEST="latest-research.json" + NOTES="ActivityWatch ${TAG} (Research Edition)" + else + EDITION="standard" + MANIFEST="latest.json" + NOTES="ActivityWatch ${TAG}" + fi python3 scripts/package/generate_latest_json.py \ - --version "$(bash scripts/package/getversion.sh | sed 's/^v//')" \ - --notes "ActivityWatch ${{ github.ref_name }}" \ - --repo "${{ github.repository }}" \ - --tag "${{ github.ref_name }}" \ + --version "$VERSION" \ + --edition "$EDITION" \ + --notes "$NOTES" \ + --repo "${GITHUB_REPOSITORY}" \ + --tag "$TAG" \ --dist dist \ - --output dist/latest.json - cat dist/latest.json + --output "dist/${MANIFEST}" + cat "dist/${MANIFEST}" - name: Release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 @@ -1007,6 +1031,7 @@ jobs: dist/*/activitywatch-*.* dist/*/updater/* dist/latest.json + dist/latest-research.json body_path: dist/release_notes/release_notes.md # check-version-format-action leaves is_stable unset/false for # research-suffixed tags, so this stays prerelease for them too. diff --git a/scripts/package/generate_latest_json.py b/scripts/package/generate_latest_json.py index d09109272..f2d10fdda 100755 --- a/scripts/package/generate_latest_json.py +++ b/scripts/package/generate_latest_json.py @@ -1,11 +1,19 @@ #!/usr/bin/env python3 -"""Assemble a Tauri updater `latest.json` manifest from per-platform .sig files. +"""Assemble a Tauri updater manifest from per-platform .sig files. -Expects updater artifacts to be named -`activitywatch-tauri--.` with a matching -`<...>.sig` file alongside it (as produced by the "Package Tauri updater -artifacts" step in release.yml), where is a Tauri +Expects updater artifacts named +`activitywatch-tauri[-research]--.` with a +matching `<...>.sig` file alongside it (as produced by the "Package Tauri +updater artifacts" step in release.yml), where is a Tauri updater platform identifier such as "darwin-aarch64" or "linux-x86_64". + +Standard and Research Edition releases are partitioned by filename: + +- standard: `latest.json` + `activitywatch-tauri--...` +- research: `latest-research.json` + `activitywatch-tauri-research--...` + +so the two lines cannot overwrite each other's GitHub release assets or +share an updater endpoint. """ import argparse import json @@ -13,28 +21,47 @@ import re from datetime import datetime, timezone +EDITIONS = ("standard", "research") + + +def normalize_version(version: str) -> str: + """Strip a leading 'v' and a trailing '-research' edition suffix.""" + if version.startswith("v"): + version = version[1:] + if version.endswith("-research"): + version = version[: -len("-research")] + return version + + +def infer_edition(tag: str, edition=None) -> str: + if edition: + if edition not in EDITIONS: + raise ValueError(f"unknown edition {edition!r}") + return edition + return "research" if tag.endswith("-research") else "standard" + + +def asset_prefix(edition: str) -> str: + if edition == "research": + return "activitywatch-tauri-research" + return "activitywatch-tauri" + + +def manifest_filename(edition: str) -> str: + return "latest-research.json" if edition == "research" else "latest.json" -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--version", required=True) - parser.add_argument("--notes", required=True) - parser.add_argument( - "--repo", required=True, help="e.g. ActivityWatch/activitywatch" - ) - parser.add_argument("--tag", required=True, help="e.g. v0.13.3") - parser.add_argument("--dist", required=True, help="directory to search for *.sig files") - parser.add_argument("--output", required=True) - args = parser.parse_args() +def collect_platforms(dist: str, version: str, repo: str, tag: str, edition: str) -> dict: + prefix = asset_prefix(edition) # Non-greedy platform group: extensions can be multi-part (.app.tar.gz, # .AppImage.tar.gz, .nsis.zip), so stop at the first dot after the # platform key rather than the last. pattern = re.compile( - rf"^activitywatch-tauri-{re.escape(args.version)}-(?P.+?)\.(?P.+)$" + rf"^{re.escape(prefix)}-{re.escape(version)}-(?P.+?)\.(?P.+)$" ) platforms = {} - for root, _, files in os.walk(args.dist): + for root, _, files in os.walk(dist): for name in files: if not name.endswith(".sig"): continue @@ -47,28 +74,59 @@ def main(): platforms[m.group("platform")] = { "signature": signature, "url": ( - f"https://github.com/{args.repo}/releases/download/" - f"{args.tag}/{asset_name}" + f"https://github.com/{repo}/releases/download/" + f"{tag}/{asset_name}" ), } + return platforms + + +def build_manifest(version: str, notes: str, platforms: dict, pub_date=None) -> dict: + if pub_date is None: + pub_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + return { + "version": version, + "notes": notes, + "pub_date": pub_date, + "platforms": platforms, + } + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--notes", required=True) + parser.add_argument( + "--repo", required=True, help="e.g. ActivityWatch/activitywatch" + ) + parser.add_argument("--tag", required=True, help="e.g. v0.13.3 or v0.13.3-research") + parser.add_argument( + "--edition", + choices=EDITIONS, + default=None, + help="Release line. Inferred from --tag (*-research) if omitted.", + ) + parser.add_argument("--dist", required=True, help="directory to search for *.sig files") + parser.add_argument("--output", required=True) + args = parser.parse_args(argv) + + version = normalize_version(args.version) + edition = infer_edition(args.tag, args.edition) + platforms = collect_platforms(args.dist, version, args.repo, args.tag, edition) if not platforms: raise SystemExit( - "No updater artifacts found - refusing to write an empty latest.json" + f"No {edition} updater artifacts found - refusing to write an empty " + f"{os.path.basename(args.output)}" ) - manifest = { - "version": args.version, - "notes": args.notes, - "pub_date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "platforms": platforms, - } + manifest = build_manifest(version, args.notes, platforms) with open(args.output, "w") as f: json.dump(manifest, f, indent=2) f.write("\n") - print(f"Wrote {args.output} with platforms: {', '.join(sorted(platforms))}") + print(f"Wrote {args.output} ({edition}) with platforms: {', '.join(sorted(platforms))}") if __name__ == "__main__": diff --git a/scripts/tests/test_generate_latest_json.py b/scripts/tests/test_generate_latest_json.py new file mode 100644 index 000000000..320696123 --- /dev/null +++ b/scripts/tests/test_generate_latest_json.py @@ -0,0 +1,151 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + +GENERATOR = Path(__file__).parents[1] / "package" / "generate_latest_json.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("generate_latest_json", GENERATOR) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +gen = _load() + + +def _write_sig(root: Path, name: str, signature: str = "sig-bytes") -> None: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(signature + "\n") + # Matching binary next to the .sig, as the packaging step copies both. + path.with_name(name[: -len(".sig")]).write_bytes(b"bundle") + + +def test_normalize_version_strips_v_and_research_suffix(): + assert gen.normalize_version("v0.14.0b4") == "0.14.0b4" + assert gen.normalize_version("0.14.0b4") == "0.14.0b4" + assert gen.normalize_version("v0.14.0b4-research") == "0.14.0b4" + assert gen.normalize_version("0.14.0b4-research") == "0.14.0b4" + + +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" + assert gen.infer_edition("v0.14.0b4-research") == "research" + assert gen.infer_edition("v0.14.0b4-research", "standard") == "standard" + assert gen.infer_edition("v0.14.0", "research") == "research" + + +def test_asset_prefix_and_manifest_filename_partition_editions(): + assert gen.asset_prefix("standard") == "activitywatch-tauri" + assert gen.asset_prefix("research") == "activitywatch-tauri-research" + assert gen.manifest_filename("standard") == "latest.json" + assert gen.manifest_filename("research") == "latest-research.json" + + +def test_standard_collect_ignores_research_artifacts(tmp_path): + _write_sig( + tmp_path, + "activitywatch-tauri-0.14.0b4-darwin-aarch64.app.tar.gz.sig", + "std-sig", + ) + _write_sig( + tmp_path, + "activitywatch-tauri-research-0.14.0b4-darwin-aarch64.app.tar.gz.sig", + "research-sig", + ) + + platforms = gen.collect_platforms( + str(tmp_path), + "0.14.0b4", + "ActivityWatch/activitywatch", + "v0.14.0b4", + "standard", + ) + + assert list(platforms) == ["darwin-aarch64"] + assert platforms["darwin-aarch64"]["signature"] == "std-sig" + assert platforms["darwin-aarch64"]["url"].endswith( + "/v0.14.0b4/activitywatch-tauri-0.14.0b4-darwin-aarch64.app.tar.gz" + ) + + +def test_research_collect_ignores_standard_artifacts(tmp_path): + _write_sig( + tmp_path, + "activitywatch-tauri-0.14.0b4-linux-x86_64.AppImage.tar.gz.sig", + "std-sig", + ) + _write_sig( + tmp_path, + "activitywatch-tauri-research-0.14.0b4-linux-x86_64.AppImage.tar.gz.sig", + "research-sig", + ) + + platforms = gen.collect_platforms( + str(tmp_path), + "0.14.0b4", + "ActivityWatch/activitywatch", + "v0.14.0b4-research", + "research", + ) + + assert list(platforms) == ["linux-x86_64"] + assert platforms["linux-x86_64"]["signature"] == "research-sig" + assert platforms["linux-x86_64"]["url"].endswith( + "/v0.14.0b4-research/" + "activitywatch-tauri-research-0.14.0b4-linux-x86_64.AppImage.tar.gz" + ) + + +def test_main_writes_manifest_and_refuses_empty(tmp_path): + _write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig") + out = tmp_path / "latest.json" + + gen.main( + [ + "--version", + "v0.14.0", + "--notes", + "ActivityWatch v0.14.0", + "--repo", + "ActivityWatch/activitywatch", + "--tag", + "v0.14.0", + "--edition", + "standard", + "--dist", + str(tmp_path), + "--output", + str(out), + ] + ) + + manifest = json.loads(out.read_text()) + assert manifest["version"] == "0.14.0" + assert "windows-x86_64" in manifest["platforms"] + + empty = tmp_path / "empty" + empty.mkdir() + with pytest.raises(SystemExit, match="No standard updater artifacts"): + gen.main( + [ + "--version", + "0.14.0", + "--notes", + "none", + "--repo", + "ActivityWatch/activitywatch", + "--tag", + "v0.14.0", + "--dist", + str(empty), + "--output", + str(tmp_path / "latest.json"), + ] + ) From d2167eeec4773f7e928e1ee80cfb1d7fdb51f5c2 Mon Sep 17 00:00:00 2001 From: Brayo Date: Sat, 22 Aug 2026 23:26:13 +0300 Subject: [PATCH 3/3] fix(release): match known updater bundle suffixes instead of first-dot strip AppImage/NSIS/MSI names embed the crate version, so stripping after the first dot produced assets like linux-x86_64.1.0_amd64.AppImage. Match Tauri updater suffixes explicitly, and prefer NSIS over MSI when both signatures exist for the same Windows platform key. --- .github/workflows/release.yml | 13 +++++++++++- scripts/package/generate_latest_json.py | 24 +++++++++++++++++++++- scripts/tests/test_generate_latest_json.py | 19 +++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 782e1b667..a81db21a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -843,7 +843,18 @@ jobs: artifact="${sig%.sig}" if [ -f "$artifact" ]; then base="${artifact##*/}" - ext="${base#*.}" # everything after the first dot, e.g. "app.tar.gz" + # Bundle names embed the crate version (e.g. aw-tauri_0.1.0_amd64.AppImage), + # so "everything after the first dot" would yield "1.0_amd64.AppImage". + case "$base" in + *.app.tar.gz) ext="app.tar.gz" ;; + *.AppImage.tar.gz) ext="AppImage.tar.gz" ;; + *.AppImage) ext="AppImage" ;; + *.nsis.zip) ext="nsis.zip" ;; + *.msi.zip) ext="msi.zip" ;; + *.msi) ext="msi" ;; + *.exe) ext="exe" ;; + *) echo "unknown updater bundle: $base" >&2; exit 1 ;; + esac out="dist/updater/activitywatch-tauri${EDITION}-${VERSION}-${PLATFORM_KEY}.${ext}" cp "$artifact" "$out" cp "$sig" "$out.sig" diff --git a/scripts/package/generate_latest_json.py b/scripts/package/generate_latest_json.py index f2d10fdda..447ee24cf 100755 --- a/scripts/package/generate_latest_json.py +++ b/scripts/package/generate_latest_json.py @@ -51,6 +51,21 @@ def manifest_filename(edition: str) -> str: return "latest-research.json" if edition == "research" else "latest.json" +# Tauri v2 recommends NSIS for Windows updater bundles. When both NSIS and +# MSI signatures exist for the same platform key, keep NSIS regardless of +# os.walk order. Unlisted extensions share rank 0 (first one wins). +WINDOWS_BUNDLE_RANK = { + "nsis.zip": 2, + "exe": 1, + "msi.zip": 0, + "msi": 0, +} + + +def bundle_rank(ext: str) -> int: + return WINDOWS_BUNDLE_RANK.get(ext, 0) + + def collect_platforms(dist: str, version: str, repo: str, tag: str, edition: str) -> dict: prefix = asset_prefix(edition) # Non-greedy platform group: extensions can be multi-part (.app.tar.gz, @@ -61,6 +76,7 @@ def collect_platforms(dist: str, version: str, repo: str, tag: str, edition: str ) platforms = {} + chosen_ext = {} for root, _, files in os.walk(dist): for name in files: if not name.endswith(".sig"): @@ -69,9 +85,15 @@ def collect_platforms(dist: str, version: str, repo: str, tag: str, edition: str m = pattern.match(asset_name) if not m: continue + platform = m.group("platform") + ext = m.group("ext") + prev_ext = chosen_ext.get(platform) + if prev_ext is not None and bundle_rank(ext) <= bundle_rank(prev_ext): + continue with open(os.path.join(root, name)) as f: signature = f.read().strip() - platforms[m.group("platform")] = { + chosen_ext[platform] = ext + platforms[platform] = { "signature": signature, "url": ( f"https://github.com/{repo}/releases/download/" diff --git a/scripts/tests/test_generate_latest_json.py b/scripts/tests/test_generate_latest_json.py index 320696123..acd4fd59f 100644 --- a/scripts/tests/test_generate_latest_json.py +++ b/scripts/tests/test_generate_latest_json.py @@ -103,6 +103,25 @@ def test_research_collect_ignores_standard_artifacts(tmp_path): ) +def test_windows_prefers_nsis_over_msi_regardless_of_walk_order(tmp_path): + _write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.msi.zip.sig", "msi-sig") + _write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig", "nsis-sig") + + platforms = gen.collect_platforms( + str(tmp_path), + "0.14.0", + "ActivityWatch/activitywatch", + "v0.14.0", + "standard", + ) + + assert list(platforms) == ["windows-x86_64"] + assert platforms["windows-x86_64"]["signature"] == "nsis-sig" + assert platforms["windows-x86_64"]["url"].endswith( + "/v0.14.0/activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip" + ) + + def test_main_writes_manifest_and_refuses_empty(tmp_path): _write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig") out = tmp_path / "latest.json"