Skip to content

Commit 83381d4

Browse files
fix(updater): isolate research update trust and normalize versions (#1442)
* fix(updater): isolate research update trust and normalize versions Git-Session-Id: 3454 * 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 <bob@superuserlabs.org>
1 parent 25145ce commit 83381d4

6 files changed

Lines changed: 551 additions & 9 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,9 @@ jobs:
292292
- name: Run profile patcher and installer collection tests
293293
run: python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q
294294

295+
- name: Test updater channel isolation and release versions
296+
run: python3 -m pytest scripts/tests/test_generate_latest_json.py scripts/tests/test_configure_tauri_release.py -q
297+
295298
build-qt:
296299
name: Build Qt artifacts
297300
if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
@@ -1067,6 +1070,27 @@ jobs:
10671070
preset="$(python3 scripts/emit_research_category_preset.py)"
10681071
echo "AW_PRESET_CATEGORY_SETS=${preset}" >> "$GITHUB_ENV"
10691072
1073+
# aw-tauri/Makefile enables bundle.createUpdaterArtifacts through a CLI
1074+
# --config override when TAURI_SIGNING_PRIVATE_KEY is set. The JSON file
1075+
# alone therefore does not describe whether signed bundles are produced.
1076+
- name: Configure Tauri release version and update channel
1077+
run: |
1078+
args=()
1079+
if [ "$AW_RESEARCH_EDITION" = true ]; then
1080+
args+=(--research)
1081+
if [[ "$GITHUB_REF" == refs/tags/v* || "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
1082+
args+=(--require-signing-key)
1083+
fi
1084+
fi
1085+
python3 scripts/package/configure_tauri_release.py \
1086+
--config aw-tauri/src-tauri/tauri.conf.json \
1087+
--version "$VERSION_NO_V" "${args[@]}"
1088+
env:
1089+
TAURI_UPDATER_PUBLIC_KEY_RESEARCH: ${{ vars.TAURI_UPDATER_PUBLIC_KEY_RESEARCH }}
1090+
# Select the secret NAME, never `research_secret || standard_secret`:
1091+
# an absent research secret must not fall back to standard signing.
1092+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }}
1093+
10701094
- name: Build
10711095
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4
10721096
with:
@@ -1081,10 +1105,9 @@ jobs:
10811105
make build SKIP_WEBUI=${{ matrix.skip_webui }} SKIP_SERVER_RUST=${{ matrix.skip_rust }}
10821106
pip freeze
10831107
env:
1084-
# Signs aw-tauri bundles and emits .sig files for the updater when
1085-
# createUpdaterArtifacts is enabled in aw-tauri/src-tauri/tauri.conf.json.
1086-
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
1087-
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
1108+
# aw-tauri/Makefile enables createUpdaterArtifacts when this key is set.
1109+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }}
1110+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'] }}
10881111

10891112
- name: Run tests
10901113
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4
@@ -1128,8 +1151,8 @@ jobs:
11281151
source venv/bin/activate || source venv/Scripts/activate
11291152
make --directory=aw-tauri build
11301153
env:
1131-
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
1132-
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
1154+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }}
1155+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'] }}
11331156

11341157
- name: Import macOS signing certificate
11351158
if: runner.os == 'macOS' && (startsWith(github.ref, 'refs/tags/v') || env.AW_RESEARCH_EDITION == 'true')
@@ -1402,8 +1425,8 @@ jobs:
14021425
# are set by Actions) rather than interpolated into the shell script, so
14031426
# a crafted v* tag cannot inject commands into this contents-write job.
14041427
#
1405-
# Editions are partitioned by filename so they cannot share an updater
1406-
# endpoint: standard writes latest.json, research writes latest-research.json.
1428+
# Keep versioned release assets separate. Research clients use the
1429+
# independent research-updates branch; these assets do not publish that feed.
14071430
- name: Generate updater manifest
14081431
run: |
14091432
set -euo pipefail

‎scripts/package/UPDATER.md‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Tauri release configuration
2+
3+
`release.yml` runs `configure_tauri_release.py` before compiling aw-tauri.
4+
It writes the release version to `aw-tauri/src-tauri/tauri.conf.json`, which
5+
Tauri embeds as its package version. `generate_latest_json.py` uses the same
6+
`tauri_version()` conversion for the manifest: `v0.14.0b5` becomes
7+
`0.14.0-beta.5`. Original AW spellings remain in asset filenames and tag URLs.
8+
Development builds also receive a SemVer version, with a `dev.g<hash>` suffix.
9+
10+
The standard build keeps its checked-in updater endpoint and public key.
11+
Research builds use only:
12+
13+
```text
14+
https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json
15+
```
16+
17+
Configure these repository settings before a research tag or manual build:
18+
19+
| Setting | Purpose |
20+
| --- | --- |
21+
| Variable `TAURI_UPDATER_PUBLIC_KEY_RESEARCH` | Base64-encoded minisign public-key file emitted by Tauri's signer |
22+
| Secret `TAURI_SIGNING_PRIVATE_KEY_RESEARCH` | Research-only signing key in Tauri's expected format |
23+
| Secret `TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH` | Password for that key (empty for an unencrypted key) |
24+
25+
The workflow selects secret **names** by edition. A missing research secret
26+
never falls back to a standard secret. The configurator rejects a research
27+
public key containing the standard key material, even if its comment or key
28+
ID differs. Research tag/manual builds fail if either key is missing.
29+
Secretless research PR smoke builds clear both updater endpoints and the
30+
public key. They cannot consume the standard channel.
31+
32+
`aw-tauri/Makefile` enables `bundle.createUpdaterArtifacts` by passing a
33+
`--config` override to the Tauri build when `TAURI_SIGNING_PRIVATE_KEY` is
34+
present. That setting is therefore absent from the checked-in JSON even
35+
though release builds produce signatures. Both the initial build and the
36+
research-profile rebuild receive the edition's selected signing key.
37+
38+
## Release acceptance still required
39+
40+
This configuration does not create the `research-updates` branch, publish
41+
its manifest, verify that a private key matches the configured public key,
42+
or change any already installed client. Before offering an update:
43+
44+
- Provision the channel and verify its public URL. Publish only a complete,
45+
verified manifest after its versioned release is public; use a normal
46+
fast-forward commit with an expected parent and a monotonic version check
47+
so a late release job cannot overwrite a newer feed.
48+
- Verify actual bundle signatures with the research key and reject standard
49+
signatures. Config/fixture checks alone are insufficient.
50+
- Verify the built app's version, equal-version no-update, one newer-version
51+
upgrade, and no repeated offer after restart. Both versions must preserve
52+
the research profile, privacy defaults, install identity, and bundled
53+
watchers/aw-sync. Native updater bundles and full AW packages require a
54+
payload-equivalence check.
55+
- Replace or verifiably contain any already distributed research app that
56+
embedded the standard endpoint/key **before publishing a valid standard
57+
stable manifest**. A future configuration cannot repair an installed app.
58+
59+
The pipeline creates a draft versioned release. Its `latest-research.json`
60+
asset is distinct from the live branch feed; uploading the asset does not
61+
advance that feed. Do not publish the draft until these gates are satisfied.
62+
63+
## Focused checks
64+
65+
Initialize the pinned `aw-tauri` submodule, then run:
66+
67+
```sh
68+
python3 -m pytest scripts/tests/test_generate_latest_json.py scripts/tests/test_configure_tauri_release.py -q
69+
```
70+
71+
These tests execute both CLIs against the pinned config, exercise missing and
72+
same-key rejection, preserve standard trust, and check both directions of
73+
asset partitioning. Dummy signatures in these fixtures do not prove signing
74+
or installation.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env python3
2+
"""Set the compiled updater version and isolate Research Edition update trust."""
3+
4+
import argparse
5+
import base64
6+
import binascii
7+
import json
8+
import os
9+
import sys
10+
from pathlib import Path
11+
12+
from generate_latest_json import tauri_version
13+
14+
RESEARCH_ENDPOINT = (
15+
"https://raw.githubusercontent.com/ActivityWatch/activitywatch/"
16+
"research-updates/latest-research.json"
17+
)
18+
STANDARD_ENDPOINT = "https://github.com/ActivityWatch/activitywatch/releases/latest/download/latest.json"
19+
20+
21+
def msi_rejects(version: str) -> bool:
22+
"""True if Tauri's msi (WiX) bundler will reject this SemVer version.
23+
24+
The msi target requires the optional pre-release identifier to be
25+
numeric-only (e.g. "0.14.0-5"), unlike nsis or the other bundle formats.
26+
AW's own pre-release scheme ("0.14.0-beta.5", "0.14.0-dev.gabc1234")
27+
fails that check every time.
28+
"""
29+
if "-" not in version:
30+
return False
31+
prerelease = version.split("-", 1)[1]
32+
return not all(part.isdigit() for part in prerelease.split("."))
33+
34+
35+
def public_key(encoded: str) -> bytes:
36+
"""Read Tauri's base64-wrapped minisign public key, ignoring its comment."""
37+
try:
38+
lines = base64.b64decode(encoded, validate=True).decode("ascii").splitlines()
39+
if len(lines) != 2 or not lines[0].startswith("untrusted comment: "):
40+
raise ValueError("invalid public key envelope")
41+
packet = base64.b64decode(lines[1], validate=True)
42+
if len(packet) != 42 or packet[:2] != b"Ed":
43+
raise ValueError("invalid public key packet")
44+
return packet[10:]
45+
except (ValueError, UnicodeError, binascii.Error) as exc:
46+
raise ValueError("Expected a base64-encoded minisign public key") from exc
47+
48+
49+
def configure(
50+
path: Path,
51+
version: str,
52+
research: bool,
53+
require_signing_key: bool,
54+
*,
55+
platform: str = sys.platform,
56+
) -> None:
57+
config = json.loads(path.read_text(encoding="utf-8"))
58+
tv = tauri_version(version)
59+
config["version"] = tv
60+
if platform == "win32" and msi_rejects(tv):
61+
bundle = config.setdefault("bundle", {})
62+
if bundle.get("targets") == "all":
63+
# Windows only ever produces msi+nsis from "all"; drop msi and
64+
# keep nsis, which accepts the full AW pre-release scheme.
65+
bundle["targets"] = ["nsis"]
66+
if research:
67+
updater = config["plugins"]["updater"]
68+
if updater["endpoints"] != [STANDARD_ENDPOINT]:
69+
raise ValueError(
70+
"Unexpected source updater endpoint; review before patching"
71+
)
72+
research_key = os.environ.get("TAURI_UPDATER_PUBLIC_KEY_RESEARCH", "").strip()
73+
signing_key = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "").strip()
74+
if require_signing_key and (not research_key or not signing_key):
75+
raise ValueError(
76+
"Research releases require their own public and signing keys"
77+
)
78+
if research_key:
79+
if public_key(research_key) == public_key(updater["pubkey"]):
80+
raise ValueError(
81+
"Research updater key must differ from the standard key"
82+
)
83+
updater["pubkey"] = research_key
84+
updater["endpoints"] = [RESEARCH_ENDPOINT]
85+
else:
86+
if signing_key:
87+
raise ValueError("Research signing key supplied without its public key")
88+
# Secretless PR smoke builds must never retain standard update trust.
89+
updater["pubkey"] = ""
90+
updater["endpoints"] = []
91+
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
92+
93+
94+
def main() -> None:
95+
parser = argparse.ArgumentParser(description=__doc__)
96+
parser.add_argument("--config", type=Path, required=True)
97+
parser.add_argument("--version", required=True)
98+
parser.add_argument("--research", action="store_true")
99+
parser.add_argument("--require-signing-key", action="store_true")
100+
args = parser.parse_args()
101+
try:
102+
configure(args.config, args.version, args.research, args.require_signing_key)
103+
except (ValueError, KeyError) as exc:
104+
parser.exit(1, f"ERROR: {exc}\n")
105+
106+
107+
if __name__ == "__main__":
108+
main()

‎scripts/package/generate_latest_json.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@ def normalize_version(version: str) -> str:
3333
return version
3434

3535

36+
def tauri_version(version: str) -> str:
37+
"""Convert AW release/dev labels to the SemVer embedded by Tauri.
38+
39+
Asset filenames and GitHub tags keep their original AW version spelling.
40+
"""
41+
version = normalize_version(version)
42+
number = r"(?:0|[1-9][0-9]*)"
43+
match = re.fullmatch(
44+
rf"(?P<base>{number}\.{number}\.{number})"
45+
rf"(?:(?P<pre>a|b|rc)(?P<serial>{number}))?"
46+
r"(?:\.dev-(?P<dev>[0-9a-f]+|unknown))?",
47+
version,
48+
)
49+
if not match:
50+
raise ValueError(f"Unsupported ActivityWatch version: {version!r}")
51+
suffix = []
52+
if match["pre"]:
53+
suffix.extend(
54+
({"a": "alpha", "b": "beta", "rc": "rc"}[match["pre"]], match["serial"])
55+
)
56+
if match["dev"]:
57+
# Prefix hashes so an all-digit hash with a leading zero stays valid.
58+
suffix.extend(("dev", "g" + match["dev"]))
59+
return match["base"] + ("-" + ".".join(suffix) if suffix else "")
60+
61+
3662
def infer_edition(tag: str, edition=None) -> str:
3763
if edition:
3864
if edition not in EDITIONS:
@@ -142,7 +168,7 @@ def main(argv=None):
142168
f"{os.path.basename(args.output)}"
143169
)
144170

145-
manifest = build_manifest(version, args.notes, platforms)
171+
manifest = build_manifest(tauri_version(version), args.notes, platforms)
146172

147173
with open(args.output, "w") as f:
148174
json.dump(manifest, f, indent=2)

0 commit comments

Comments
 (0)