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
39 changes: 31 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions scripts/package/UPDATER.md
Original file line number Diff line number Diff line change
@@ -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<hash>` 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.
108 changes: 108 additions & 0 deletions scripts/package/configure_tauri_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/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
import sys
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 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:
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,
*,
platform: str = sys.platform,
) -> None:
config = json.loads(path.read_text(encoding="utf-8"))
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]:
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()
28 changes: 27 additions & 1 deletion scripts/package/generate_latest_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<base>{number}\.{number}\.{number})"
rf"(?:(?P<pre>a|b|rc)(?P<serial>{number}))?"
r"(?:\.dev-(?P<dev>[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"]))
Comment on lines +54 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Development Versions Sort Newer

For a non-tag build based on 0.14.0b5, this appends the development identifiers and embeds 0.14.0-beta.5.dev.g<hash>. SemVer considers that version newer than 0.14.0-beta.5, so a standard development build that retains updater trust will reject the eventual beta release as an older version instead of accepting the update.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — dev-suffix versions (e.g. 0.14.0-beta.5.dev.g<hash>) do sort newer than 0.14.0-beta.5 under SemVer, which would cause a developer build that retained updater trust to reject the eventual beta release as "older".

The PR already mitigates the practical risk in two ways: smoke/PR builds clear update trust (secretless research PR smoke builds clear update trust), so CI artifacts aren't affected. Tagged release builds are the intended distribution path for research keys; non-tag developer builds with research keys are an advanced/manual scenario the PR description already flags as out-of-scope for this build-config PR (Full platform builds and installed-app upgrades have not been run locally).

A follow-up to strip the dev suffix (or force-disable updater trust for non-tag builds) would be the clean fix, but it belongs in a follow-up once the trust isolation itself is validated.

return match["base"] + ("-" + ".".join(suffix) if suffix else "")


def infer_edition(tag: str, edition=None) -> str:
if edition:
if edition not in EDITIONS:
Expand Down Expand Up @@ -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)
Expand Down
Loading