Skip to content
Open
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
106 changes: 104 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -811,11 +816,67 @@ 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}"
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.
found=0
while IFS= read -r -d '' sig; do
artifact="${sig%.sig}"
if [ -f "$artifact" ]; then
base="${artifact##*/}"
# 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"
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
Expand Down Expand Up @@ -920,6 +981,12 @@ jobs:
needs: [build-qt, build-tauri, release-notes]
runs-on: ubuntu-latest
steps:
# 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

- name: Download build artifacts
uses: actions/download-artifact@v8
with:
Expand All @@ -934,13 +1001,48 @@ jobs:
with:
prefix: 'v'

# 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 "$VERSION" \
--edition "$EDITION" \
--notes "$NOTES" \
--repo "${GITHUB_REPOSITORY}" \
--tag "$TAG" \
--dist dist \
--output "dist/${MANIFEST}"
cat "dist/${MANIFEST}"

- 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
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.
Expand Down
155 changes: 155 additions & 0 deletions scripts/package/generate_latest_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Assemble a Tauri updater manifest from per-platform .sig files.

Expects updater artifacts named
`activitywatch-tauri[-research]-<version>-<platform-key>.<ext>` with a
matching `<...>.sig` file alongside it (as produced by the "Package Tauri
updater artifacts" step in release.yml), where <platform-key> 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-<ver>-...`
- research: `latest-research.json` + `activitywatch-tauri-research-<ver>-...`

so the two lines cannot overwrite each other's GitHub release assets or
share an updater endpoint.
"""
import argparse
import json
import os
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"


# 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,
# .AppImage.tar.gz, .nsis.zip), so stop at the first dot after the
# platform key rather than the last.
pattern = re.compile(
rf"^{re.escape(prefix)}-{re.escape(version)}-(?P<platform>.+?)\.(?P<ext>.+)$"
)

platforms = {}
chosen_ext = {}
for root, _, files in os.walk(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
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()
chosen_ext[platform] = ext
platforms[platform] = {
"signature": signature,
"url": (
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(
f"No {edition} updater artifacts found - refusing to write an empty "
f"{os.path.basename(args.output)}"
)

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} ({edition}) with platforms: {', '.join(sorted(platforms))}")


if __name__ == "__main__":
main()
Loading