Files
upstream-mirror c5c5af8cde
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 22:29:06 +05:00

1472 lines
60 KiB
YAML

name: Release
# Unified release/build pipeline for ActivityWatch.
#
# Triggers:
# - schedule / workflow_dispatch: decide whether to create a new dev prerelease tag
# - push / pull_request: run the normal build matrices
# - tag push: run both build matrices, generate release notes, and publish one draft release
on:
schedule:
- cron: '0 12 * * 4'
workflow_dispatch:
inputs:
release_line:
description: 'Release line to prerelease from'
required: true
default: patch
type: choice
options:
- patch
- minor
edition:
description: 'Build edition: research enables privacy filter in aw-watcher-window by default'
required: false
type: choice
options:
- standard
- research
default: standard
push:
branches: [master]
tags:
- v*
pull_request:
branches: [master]
permissions:
contents: write
actions: read
checks: read
jobs:
preflight:
name: Pre-flight checks
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
concurrency:
group: dev-release
cancel-in-progress: false
outputs:
should_release: ${{ steps.preflight.outputs.should_release }}
next_tag: ${{ steps.preflight.outputs.next_tag }}
since_ref: ${{ steps.preflight.outputs.since_ref }}
commits_since_ref: ${{ steps.preflight.outputs.commits_since_ref }}
head_sha: ${{ steps.preflight.outputs.head_sha }}
steps:
- uses: actions/checkout@v7
with:
ref: master
fetch-depth: 0
- name: Decide whether to create a dev release
id: preflight
env:
GH_TOKEN: ${{ github.token }}
RELEASE_LINE: ${{ github.event.inputs.release_line || 'patch' }}
EDITION: ${{ github.event.inputs.edition || 'standard' }}
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
ref_epoch=$(date -d "2024-01-04" +%s)
now_epoch=$(date -u +%s)
weeks_since=$(( (now_epoch - ref_epoch) / 604800 ))
if [ $((weeks_since % 2)) -eq 1 ]; then
echo "Skipping this week to keep the cadence biweekly."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
bump_version() {
local version="$1" release_line="$2"
IFS='.' read -r major minor patch <<< "$version"
if [ "$release_line" = "minor" ]; then
minor=$((minor + 1))
patch=0
else
patch=$((patch + 1))
fi
echo "${major}.${minor}.${patch}"
}
latest_stable=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)
if [ -z "$latest_stable" ]; then
echo "No stable tag found, refusing to create prerelease tags."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
next_base=$(bump_version "${latest_stable#v}" "$RELEASE_LINE")
# If a prerelease for a version ahead of next_base already exists,
# continue that release line instead of starting a new one from the
# stable bump. Example: latest_stable=v0.13.2 + patch -> v0.13.3,
# but if v0.14.0b1 exists, produce v0.14.0b2 instead.
highest_prerelease=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$' | head -1 || true)
if [ -n "$highest_prerelease" ]; then
hp_base="${highest_prerelease%%b*}"
hp_base="${hp_base#v}"
if [ "$(printf '%s\n%s\n' "$hp_base" "$next_base" | sort -V | tail -1)" = "$hp_base" ] && \
[ "$hp_base" != "$next_base" ]; then
echo "Existing prerelease $highest_prerelease is ahead of computed v${next_base}; continuing that release line."
next_base="$hp_base"
fi
fi
prerelease_pattern="^v${next_base//./\\.}b[0-9]+$"
last_prerelease=$(git tag --sort=-version:refname | grep -E "$prerelease_pattern" | head -1 || true)
if [ -n "$last_prerelease" ]; then
since_ref="$last_prerelease"
last_prerelease_num=${last_prerelease##*b}
next_tag="v${next_base}b$((last_prerelease_num + 1))"
else
since_ref="$latest_stable"
next_tag="v${next_base}b1"
fi
if [ "$EDITION" = "research" ]; then
# A research release is the current standard prerelease lineage
# with an edition suffix. It must not consume the next standard bN.
if [ -n "$last_prerelease" ]; then
next_tag="${last_prerelease}-research"
else
next_tag="${next_tag}-research"
fi
if git rev-parse -q --verify "refs/tags/${next_tag}" >/dev/null; then
echo "Research tag $next_tag already exists; skipping duplicate release."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
commits_since_ref=$(git rev-list "${since_ref}..HEAD" --count)
echo "latest_stable=$latest_stable"
echo "last_prerelease=${last_prerelease:-<none>}"
echo "since_ref=$since_ref"
echo "next_tag=$next_tag"
echo "commits_since_ref=$commits_since_ref"
if [ "$commits_since_ref" -eq 0 ]; then
echo "No new commits since $since_ref, skipping dev release."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
head_sha=$(git rev-parse HEAD)
current_suite_id=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--jq '.check_suite_id' 2>/dev/null || echo "0")
# Auxiliary workflow jobs are not CI signals; exclude them so their
# skipped or failed conclusions do not block the release.
conclusions=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${head_sha}/check-runs" \
--paginate \
--slurp 2>/dev/null | jq -r --arg suite "$current_suite_id" '
[.[].check_runs[]?
| select(
.app.slug == "github-actions" and
((.check_suite.id | tostring) != $suite) and
(.name | test("^(Dependabot|Auto-merge|greeting|Pre-flight checks|Create dev release tag)$") | not)
)
| .conclusion]
| unique
| .[]
' 2>/dev/null || echo unknown)
echo "CI conclusions: $conclusions"
if echo "$conclusions" | grep -qE 'failure|action_required|timed_out|cancelled|startup_failure'; then
echo "CI has failures on HEAD, skipping dev release."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if echo "$conclusions" | grep -qE 'null|pending'; then
echo "CI is still running on HEAD, skipping dev release."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$conclusions" ] || [ "$conclusions" = "unknown" ]; then
echo "CI status unavailable on HEAD, skipping dev release."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! echo "$conclusions" | grep -q 'success'; then
echo "No successful CI checks found on HEAD, skipping dev release."
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "next_tag=$next_tag" >> "$GITHUB_OUTPUT"
echo "since_ref=$since_ref" >> "$GITHUB_OUTPUT"
echo "commits_since_ref=$commits_since_ref" >> "$GITHUB_OUTPUT"
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
create-tag:
name: Create dev release tag
needs: preflight
if: needs.preflight.outputs.should_release == 'true'
runs-on: ubuntu-latest
concurrency:
group: dev-release
cancel-in-progress: false
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.preflight.outputs.head_sha }}
fetch-depth: 1
token: ${{ secrets.AWBOT_GH_TOKEN }}
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Create and push prerelease tag
run: |
set -euo pipefail
tag="${{ needs.preflight.outputs.next_tag }}"
msg="Development prerelease $tag"
if [[ "$tag" == *-research ]]; then
msg="Development prerelease $tag (research edition)"
fi
git tag -a "$tag" -m "$msg"
git push origin "$tag"
{
echo "## Dev release created"
echo ""
echo "- Tag: \`$tag\`"
if [[ "$tag" == *-research ]]; then
echo "- Edition: research"
fi
echo "- Changes since: \`${{ needs.preflight.outputs.since_ref }}\`"
echo "- Commits: \`${{ needs.preflight.outputs.commits_since_ref }}\`"
echo ""
echo "The tag-triggered build jobs in this workflow will now build artifacts and create/update the draft prerelease."
} >> "$GITHUB_STEP_SUMMARY"
research-edition-checks:
name: Research Edition — patch and packaging smoke tests
# Run on every PR and push so drifted submodule pins fail at review time,
# not when Erik pushes the annotated release tag. The patcher is stdlib-only
# so no poetry/build step is needed; only submodules (real source files) and
# pytest (for the unit-test fixture suite).
if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
with:
submodules: 'recursive'
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.9'
- name: Install pytest
run: pip install pytest
# Verify every patch target string exists exactly once in the real
# submodule sources for both build flavours. Fails immediately if a
# target is missing or ambiguous — the same guard that would abort a tag
# build, now running at PR time.
- name: Verify Qt patch targets (--check)
run: python3 scripts/patch_research_edition_profile.py qt --check
- name: Verify Tauri patch targets (--check)
run: python3 scripts/patch_research_edition_profile.py tauri --check
# Fixture-based unit tests: patchers apply correctly on synthetic trees,
# fail closed on missing/ambiguous tokens, and leave ordinary defaults
# untouched on the unpatched tree. Also exercise the Windows installer
# collection step with fixture .exe files named by the real patched .iss
# sources; this catches naming drift without Inno Setup or signing.
- 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'
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
env:
AW_EXTRAS: true
MACOSX_DEPLOYMENT_TARGET: "12.0"
AW_RESEARCH_EDITION: ${{ matrix.research || (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
# Build Linux Qt/AppImage artifacts on the oldest supported runner to
# keep the bundled glibc baseline compatible with older distributions.
os: [ubuntu-22.04, windows-latest, macos-latest, macos-15-intel]
python_version: [3.9]
node_version: [22]
skip_rust: [false]
skip_webui: [false]
experimental: [false]
# Research Edition build leg: apply the research patch set and
# compile/test/package the *patched* tree on every PR and branch push,
# so a drifted patch target or a patched-source compile failure dies at
# PR time instead of at tag time. Linux only: cheapest full build and no
# signing secrets. On tag pushes and research dispatches `research`
# resolves to false, so this row merges into the existing ubuntu-22.04 row
# instead of adding a duplicate leg -- research tags already build the
# research edition in every row, and standard tags must not upload
# research artifacts to the standard release.
research: [false]
include:
- os: ubuntu-22.04
python_version: 3.9
node_version: 22
skip_rust: false
skip_webui: false
experimental: false
research: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
steps:
- uses: actions/checkout@v7
with:
submodules: 'recursive'
fetch-depth: 0
- name: Set RELEASE
run: |
echo "RELEASE=${{ startsWith(github.ref_name, 'v') || github.ref_name == 'master' }}" >> "$GITHUB_ENV"
- name: Set tag metadata
if: startsWith(github.ref, 'refs/tags/v')
run: |
echo "VERSION_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
- name: Determine and output version
run: |
VERSION_WITH_V=$(bash scripts/package/getversion.sh)
# Strip the research tag suffix for filenames: the edition gets its
# own token (activitywatch[-tauri][-research]-<version>-...), so the
# version part stays a plain version.
VERSION_WITH_V="${VERSION_WITH_V%-research}"
VERSION_NO_V="${VERSION_WITH_V#v}"
echo "VERSION_WITH_V=${VERSION_WITH_V}" >> "$GITHUB_ENV"
echo "VERSION_NO_V=${VERSION_NO_V}" >> "$GITHUB_ENV"
echo "========================================"
echo "Build Version Information"
echo "========================================"
echo "GitHub ref: ${{ github.ref }}"
echo "GitHub ref_name: ${{ github.ref_name }}"
echo "Version (with v): ${VERSION_WITH_V}"
echo "Version (no v): ${VERSION_NO_V}"
echo "========================================"
- name: Verify aw-server-rust submodule version matches release tag
if: startsWith(github.ref, 'refs/tags/v')
run: |
# Fail fast if the aw-server-rust submodule is pinned to an older
# release line than the one we're tagging. The Windows 0.14 Tauri
# bundle shipped with aw-server-rust v0.13.1 (activitywatch#1380);
# catch the same class of mismatch here before spending ~30 min on a
# full Tauri build.
#
# The bundled binary's version lives in
# aw-server-rust/aw-server/Cargo.toml (the workspace-level
# aw-server-rust/Cargo.toml has no [package].version field).
BUNDLED_VERSION=$(grep -m1 '^version = ' aw-server-rust/aw-server/Cargo.toml | sed -E 's/^version = "(.*)".*/\1/')
if [ -z "$BUNDLED_VERSION" ]; then
echo "ERROR: could not read aw-server version from aw-server-rust/aw-server/Cargo.toml" >&2
exit 1
fi
AW_VERSION="${VERSION_NO_V}" # e.g. "0.14.0b3"
if [ -z "$AW_VERSION" ]; then
echo "ERROR: VERSION_NO_V is empty — the 'Determine and output version' step must export it to GITHUB_ENV" >&2
exit 1
fi
AW_MAJOR_MINOR=$(echo "$AW_VERSION" | cut -d'.' -f1-2) # "0.14"
AWS_MAJOR_MINOR=$(echo "$BUNDLED_VERSION" | cut -d'.' -f1-2) # "0.14"
echo "AW release tag: ${AW_VERSION} (major.minor: ${AW_MAJOR_MINOR})"
echo "Bundled aw-server: ${BUNDLED_VERSION} (major.minor: ${AWS_MAJOR_MINOR})"
if [ "$AW_MAJOR_MINOR" != "$AWS_MAJOR_MINOR" ]; then
echo ""
echo "ERROR: aw-server-rust major.minor (${AWS_MAJOR_MINOR}) does not match"
echo " AW release major.minor (${AW_MAJOR_MINOR})."
echo " The aw-server-rust submodule is stale for this tag."
echo " Update the submodule (cd aw-server-rust && git pull) and re-tag."
exit 1
fi
echo "OK: aw-server version ${BUNDLED_VERSION} is consistent with AW release ${AW_VERSION}"
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python_version }}
- name: Set up Node
if: ${{ !matrix.skip_webui }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node_version }}
- name: Set up Rust
if: ${{ !matrix.skip_rust }}
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master
id: toolchain
with:
toolchain: stable
- name: Cache node_modules
uses: actions/cache@v6
if: ${{ !matrix.skip_webui }}
with:
path: aw-server-rust/aw-webui/node_modules
key: ${{ matrix.os }}-node_modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ matrix.os }}-node_modules-
- name: Cache cargo build
uses: actions/cache@v6
env:
cache-name: cargo-build-target
with:
path: aw-server-rust/target
key: ${{ matrix.os }}-${{ env.cache-name }}-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ matrix.os }}-${{ env.cache-name }}-${{ steps.toolchain.outputs.rustc_hash }}-
- name: Install APT dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
appstream \
qt5-qmake \
qtbase5-dev \
qtwayland5 \
libqt5x11extras5 \
libfontconfig1 \
libxcb1 \
libfontconfig1-dev \
libfreetype6-dev \
libx11-dev \
libxcursor-dev \
libxext-dev \
libxfixes-dev \
libxft-dev \
libxi-dev \
libxrandr-dev \
libxrender-dev
- name: Install dependencies
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
choco install innosetup
fi
pip3 install poetry==2.2.1
- name: Patch research edition defaults
if: env.AW_RESEARCH_EDITION == 'true'
run: |
python3 scripts/patch_research_edition_config.py aw-watcher-window/aw_watcher_window/config.py
# Participants' machines must resume tracking after reboots without
# setup steps: enable start-at-login on first launch (aw-qt#131).
python3 scripts/patch_research_edition_awqt.py aw-qt/aw_qt/config.py
# Watchers store buckets as `{client}_{hostname}`. That hostname is a
# participant identifier on named machines, and /api/0/export copies it
# into the map key, embedded id, and hostname field. Standard builds
# must not change; this patch is Research-only and fail-closed.
- name: Patch research edition export hostname sanitizer
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_export.py
# The watcher keeps approved app names and replaces browser titles with a
# study category. This preset maps both representations into the study
# taxonomy so Top Applications and Top Categories agree.
- name: Emit research edition category preset for the web UI
if: env.AW_RESEARCH_EDITION == 'true'
shell: bash
run: |
preset="$(python3 scripts/emit_research_category_preset.py)"
echo "AW_PRESET_CATEGORY_SETS=${preset}" >> "$GITHUB_ENV"
- name: Build
run: |
python3 -m venv venv
source venv/bin/activate || source venv/Scripts/activate
poetry install
make build SKIP_WEBUI=${{ matrix.skip_webui }} SKIP_SERVER_RUST=${{ matrix.skip_rust }}
pip freeze
- name: Run tests
run: |
source venv/bin/activate || source venv/Scripts/activate
make test SKIP_SERVER_RUST=${{ matrix.skip_rust }}
- name: Run integration tests
if: runner.os != 'Windows'
run: |
source venv/bin/activate || source venv/Scripts/activate
make test-integration
# The patcher's exact-match table is release-critical. Run it on every
# PR/release job (not only research tags) so a drifted submodule pin
# fails before packaging. Must stay on the unpatched tree: --check
# and the fail-closed tests assert the ordinary defaults.
- name: Test research edition profile patcher
run: |
source venv/bin/activate || source venv/Scripts/activate
python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q
# Research Edition profile identity is baked AFTER the module test
# suites: they assert the ordinary defaults (profile=default, port 5600)
# and would fail on the patched tree. Python modules are editable
# installs, so PyInstaller collects the patched source at package time;
# the Rust binaries are rebuilt below. Fail-closed: a stale submodule pin
# aborts the build rather than shipping a bundle that runs the default
# profile next to a participant's standard install.
- name: Patch research edition profile identity
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_profile.py qt
- name: Rebuild aw-server-rust with research defaults
if: env.AW_RESEARCH_EDITION == 'true' && matrix.skip_rust != true
run: |
source venv/bin/activate || source venv/Scripts/activate
make --directory=aw-server-rust aw-server SKIP_WEBUI=true
- name: Package
run: |
source venv/bin/activate || source venv/Scripts/activate
poetry install
make package SKIP_SERVER_RUST=${{ matrix.skip_rust }}
- name: Package dmg
if: runner.os == 'macOS'
run: |
SIGN_MACOS=false
if [[ "${GITHUB_REF:-}" == refs/tags/v* || "$AW_RESEARCH_EDITION" == "true" ]]; then
SIGN_MACOS=true
fi
if [ "$SIGN_MACOS" = true ]; then
for var in APPLE_EMAIL APPLE_PASSWORD APPLE_TEAMID CERTIFICATE_MACOS_P12_BASE64 CERTIFICATE_MACOS_P12_PASSWORD; do
if [ -z "${!var:-}" ]; then
echo "ERROR: $var is required to sign macOS release artifacts" >&2
exit 1
fi
done
./scripts/ci/import-macos-p12.sh
APPLE_PERSONALID=$(security find-identity -v -p codesigning "${KEY_CHAIN:-build.keychain-db}" | sed -n 's/.*"\(.*\)"/\1/p' | head -1)
if [ -z "$APPLE_PERSONALID" ]; then
echo "ERROR: no codesigning identity found after importing the macOS certificate" >&2
exit 1
fi
export APPLE_PERSONALID
fi
BUNDLE="ActivityWatch"
if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then
BUNDLE="ActivityWatch-Research"
fi
source venv/bin/activate
make "dist/${BUNDLE}.dmg" APP_BUNDLE="${BUNDLE}"
if [ "$SIGN_MACOS" = true ]; then
codesign --force --verbose --timestamp -s "${APPLE_PERSONALID}" "dist/${BUNDLE}.dmg"
brew install akeru-inc/tap/xcnotary
xcnotary precheck "dist/${BUNDLE}.app"
xcnotary precheck "dist/${BUNDLE}.dmg"
make dist/notarize
fi
EDITION=""
if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi
mv "dist/${BUNDLE}.dmg" dist/activitywatch${EDITION}-${VERSION_WITH_V}-macos-$(uname -m).dmg
env:
APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_PERSONALID: ""
APPLE_TEAMID: ${{ secrets.APPLE_TEAMID }}
CERTIFICATE_MACOS_P12_BASE64: ${{ secrets.CERTIFICATE_MACOS_P12_BASE64 }}
CERTIFICATE_MACOS_P12_PASSWORD: ${{ secrets.CERTIFICATE_MACOS_P12_PASSWORD }}
- name: Package AppImage
if: startsWith(runner.os, 'linux')
run: ./scripts/package/package-appimage.sh
- name: Package deb
if: startsWith(runner.os, 'linux')
run: ./scripts/package/package-deb.sh
- name: Upload packages
uses: actions/upload-artifact@v7
with:
name: builds-${{ matrix.os }}-py${{ matrix.python_version }}${{ matrix.research && '-research' || '' }}
path: dist/activitywatch-*.*
build-qt-manylinux-2-28:
name: Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor)
if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-22.04
container:
image: quay.io/pypa/manylinux_2_28_x86_64
env:
AW_EXTRAS: true
AW_RESEARCH_EDITION: ${{ (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }}
# AppImage tools (linuxdeploy/appimagetool) are themselves AppImages;
# FUSE is unavailable inside Docker containers, so use extract-and-run.
APPIMAGE_EXTRACT_AND_RUN: 1
# aw-sync enables openssl/vendored on Linux and tries to build OpenSSL
# from source; use the container's system openssl-devel instead.
OPENSSL_NO_VENDOR: 1
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
python_version: [3.9]
node_version: [22]
steps:
- uses: actions/checkout@v7
with:
submodules: 'recursive'
fetch-depth: 0
# Configure git safe.directory immediately after checkout so that every
# subsequent step — including the version probe below — can read the full
# history. Deferring this step past the first `git describe` call causes
# the version probe to fall back to v0.0.0.dev-unknown.
- name: Configure git safe directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Set RELEASE
run: |
echo "RELEASE=${{ startsWith(github.ref_name, 'v') || github.ref_name == 'master' }}" >> "$GITHUB_ENV"
- name: Set tag metadata
if: startsWith(github.ref, 'refs/tags/v')
run: |
echo "VERSION_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
- name: Determine and output version
run: |
VERSION_WITH_V=$(bash scripts/package/getversion.sh)
# Strip the research tag suffix for filenames: the edition gets its
# own token (activitywatch[-research]-<version>-...), so the version
# part stays a plain version string.
VERSION_WITH_V="${VERSION_WITH_V%-research}"
VERSION_NO_V="${VERSION_WITH_V#v}"
echo "VERSION_WITH_V=${VERSION_WITH_V}" >> "$GITHUB_ENV"
echo "VERSION_NO_V=${VERSION_NO_V}" >> "$GITHUB_ENV"
echo "========================================"
echo "Build Version Information"
echo "========================================"
echo "GitHub ref: ${{ github.ref }}"
echo "GitHub ref_name: ${{ github.ref_name }}"
echo "Version (with v): ${VERSION_WITH_V}"
echo "Version (no v): ${VERSION_NO_V}"
echo "========================================"
- name: Verify aw-server-rust submodule version matches release tag
if: startsWith(github.ref, 'refs/tags/v')
run: |
# Fail fast if the aw-server-rust submodule is pinned to an older
# release line than the one we're tagging. Same check as in
# build-qt and build-qt-tauri — prevents shipping a manylinux
# bundle with a mismatched aw-server-rust binary.
#
# The bundled binary's version lives in
# aw-server-rust/aw-server/Cargo.toml (the workspace-level
# aw-server-rust/Cargo.toml has no [package].version field).
BUNDLED_VERSION=$(grep -m1 '^version = ' aw-server-rust/aw-server/Cargo.toml | sed -E 's/^version = "(.*)".*/\1/')
if [ -z "$BUNDLED_VERSION" ]; then
echo "ERROR: could not read aw-server version from aw-server-rust/aw-server/Cargo.toml" >&2
exit 1
fi
AW_VERSION="${VERSION_NO_V}" # e.g. "0.14.0b3"
if [ -z "$AW_VERSION" ]; then
echo "ERROR: VERSION_NO_V is empty — the 'Determine and output version' step must export it to GITHUB_ENV" >&2
exit 1
fi
AW_MAJOR_MINOR=$(echo "$AW_VERSION" | cut -d'.' -f1-2) # "0.14"
AWS_MAJOR_MINOR=$(echo "$BUNDLED_VERSION" | cut -d'.' -f1-2) # "0.14"
echo "AW release tag: ${AW_VERSION} (major.minor: ${AW_MAJOR_MINOR})"
echo "Bundled aw-server: ${BUNDLED_VERSION} (major.minor: ${AWS_MAJOR_MINOR})"
if [ "$AW_MAJOR_MINOR" != "$AWS_MAJOR_MINOR" ]; then
echo ""
echo "ERROR: aw-server-rust major.minor (${AWS_MAJOR_MINOR}) does not match"
echo " AW release major.minor (${AW_MAJOR_MINOR})."
echo " The aw-server-rust submodule is stale for this tag."
echo " Update the submodule (cd aw-server-rust && git pull) and re-tag."
exit 1
fi
echo "OK: aw-server version ${BUNDLED_VERSION} is consistent with AW release ${AW_VERSION}"
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node_version }}
- name: Set up Rust
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master
id: toolchain
with:
toolchain: stable
- name: Cache node_modules
uses: actions/cache@v6
with:
path: aw-server-rust/aw-webui/node_modules
key: manylinux_2_28-node_modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
manylinux_2_28-node_modules-
- name: Cache cargo build
uses: actions/cache@v6
env:
cache-name: cargo-build-target
with:
path: aw-server-rust/target
key: manylinux_2_28-${{ env.cache-name }}-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
manylinux_2_28-${{ env.cache-name }}-${{ steps.toolchain.outputs.rustc_hash }}-
- name: Install Linux dependencies
run: |
dnf install -y epel-release
dnf install -y \
qt5-qtbase-devel \
qt5-qtx11extras \
qt5-qtwayland \
fontconfig-devel \
freetype-devel \
libX11-devel \
libXcursor-devel \
libXext-devel \
libXfixes-devel \
libXft-devel \
libXi-devel \
libXrandr-devel \
libXrender-devel \
libxcb-devel \
openssl-devel \
kernel-headers \
gcc \
gcc-c++ \
python39-devel \
python39-libs \
squashfs-tools \
zip \
wget
- name: Set up Python PATH
run: |
# Prepend the manylinux CPython 3.9 so `python3` resolves to the
# container's build Python, which ships headers and a shared
# libpython needed by PyInstaller.
echo "/opt/python/cp39-cp39/bin" >> "$GITHUB_PATH"
echo "LD_LIBRARY_PATH=/usr/lib64:${LD_LIBRARY_PATH:-}" >> "$GITHUB_ENV"
- name: Install Python tooling
run: python3 -m pip install poetry==2.2.1
- name: Patch research edition defaults
if: env.AW_RESEARCH_EDITION == 'true'
run: |
python3 scripts/patch_research_edition_config.py aw-watcher-window/aw_watcher_window/config.py
# Participants' machines must resume tracking after reboots without
# setup steps: enable start-at-login on first launch (aw-qt#131).
python3 scripts/patch_research_edition_awqt.py aw-qt/aw_qt/config.py
- name: Patch research edition export hostname sanitizer
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_export.py
- name: Emit research edition category preset for the web UI
if: env.AW_RESEARCH_EDITION == 'true'
run: |
preset="$(python3 scripts/emit_research_category_preset.py)"
echo "AW_PRESET_CATEGORY_SETS=${preset}" >> "$GITHUB_ENV"
- name: Build
run: |
python3 -m venv venv
source venv/bin/activate
poetry install
make build
pip freeze
- name: Run tests
run: |
source venv/bin/activate
make test
- name: Run integration tests
run: |
source venv/bin/activate
make test-integration
# The patcher's exact-match table is release-critical. Run it on every
# PR/release job (not only research tags) so a drifted submodule pin
# fails before packaging. Must stay on the unpatched tree: --check
# and the fail-closed tests assert the ordinary defaults.
- name: Test research edition profile patcher
run: |
source venv/bin/activate
python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q
# Research Edition profile identity is baked AFTER the module test
# suites: they assert the ordinary defaults (profile=default, port 5600)
# and would fail on the patched tree. Python modules are editable
# installs, so PyInstaller collects the patched source at package time;
# the Rust binaries are rebuilt below. Fail-closed: a stale submodule pin
# aborts the build rather than shipping a bundle that runs the default
# profile next to a participant's standard install.
- name: Patch research edition profile identity
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_profile.py qt
- name: Rebuild aw-server-rust with research defaults
if: env.AW_RESEARCH_EDITION == 'true'
run: |
source venv/bin/activate
make --directory=aw-server-rust aw-server SKIP_WEBUI=true
- name: Package
run: |
source venv/bin/activate
poetry install
make package
- name: Package AppImage
run: ./scripts/package/package-appimage.sh
- name: Rename artifacts with manylinux_2_28 suffix
run: |
EDITION=""
if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi
# ZIP produced by `make package`
ZIP_SRC="dist/activitywatch${EDITION}-${VERSION_WITH_V}-linux-x86_64.zip"
ZIP_DST="dist/activitywatch${EDITION}-${VERSION_WITH_V}-linux-x86_64-manylinux_2_28.zip"
[ -f "$ZIP_SRC" ] && mv -v "$ZIP_SRC" "$ZIP_DST"
# AppImage produced by package-appimage.sh (intentionally unversioned)
AI_SRC="dist/activitywatch${EDITION}-linux-x86_64.AppImage"
AI_DST="dist/activitywatch${EDITION}-linux-x86_64-manylinux_2_28.AppImage"
[ -f "$AI_SRC" ] && mv -v "$AI_SRC" "$AI_DST"
- name: ABI gate — verify glibc 2.28 floor
run: |
bash scripts/package/abi-gate.sh \
dist/activitywatch*-manylinux_2_28.zip \
dist/activitywatch*-manylinux_2_28.AppImage
- name: Upload packages
uses: actions/upload-artifact@v7
with:
name: builds-manylinux_2_28-qt-py${{ matrix.python_version }}
path: dist/activitywatch-*.*
build-tauri:
name: Build Tauri artifacts
if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
env:
AW_EXTRAS: true
TAURI_BUILD: true
MACOSX_DEPLOYMENT_TARGET: "12.0"
AW_RESEARCH_EDITION: ${{ matrix.research || (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }}
# All subprojects share one virtualenv. Poetry's parallel installer can
# race while replacing the same dependency from different lock files.
POETRY_INSTALLER_PARALLEL: "false"
defaults:
run:
shell: bash
strategy:
fail-fast: false
max-parallel: 5
matrix:
os:
[
ubuntu-24.04,
ubuntu-24.04-arm,
windows-latest,
macos-latest,
macos-15-intel,
]
python_version: [3.9]
node_version: [22]
skip_rust: [false]
skip_webui: [false]
experimental: [false]
# Research Edition build leg: apply the research patch set and
# compile/test/package the *patched* tree on every PR and branch push,
# so a drifted patch target or a patched-source compile failure dies at
# PR time instead of at tag time. Linux only: cheapest full build and no
# signing secrets. On tag pushes and research dispatches `research`
# resolves to false, so this row merges into the existing ubuntu-24.04 row
# instead of adding a duplicate leg -- research tags already build the
# research edition in every row, and standard tags must not upload
# research artifacts to the standard release.
research: [false]
include:
- os: ubuntu-24.04
python_version: 3.9
node_version: 22
skip_rust: false
skip_webui: false
experimental: false
research: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
steps:
- uses: actions/checkout@v7
with:
submodules: "recursive"
fetch-depth: 0
- name: Set environment variables
run: |
echo "RELEASE=${{ startsWith(github.ref_name, 'v') || github.ref_name == 'master' }}" >> "$GITHUB_ENV"
echo "TAURI_BUILD=true" >> "$GITHUB_ENV"
- name: Set tag metadata
if: startsWith(github.ref, 'refs/tags/v')
run: |
echo "VERSION_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
- name: Determine and output version
run: |
VERSION_WITH_V=$(bash scripts/package/getversion.sh)
# Strip the research tag suffix for filenames: the edition gets its
# own token (activitywatch[-tauri][-research]-<version>-...), so the
# version part stays a plain version.
VERSION_WITH_V="${VERSION_WITH_V%-research}"
VERSION_NO_V="${VERSION_WITH_V#v}"
echo "VERSION_WITH_V=${VERSION_WITH_V}" >> "$GITHUB_ENV"
echo "VERSION_NO_V=${VERSION_NO_V}" >> "$GITHUB_ENV"
echo "========================================"
echo "Build Version Information (Tauri)"
echo "========================================"
echo "GitHub ref: ${{ github.ref }}"
echo "GitHub ref_name: ${{ github.ref_name }}"
echo "Version (with v): ${VERSION_WITH_V}"
echo "Version (no v): ${VERSION_NO_V}"
echo "========================================"
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python_version }}
- name: Verify Tauri and Qt embed the same aw-server-rust revision
# Tauri embeds the Git revision in its Cargo.lock, independently of
# the top-level submodule used by Qt. Check the actual build inputs;
# checking only the submodule allowed v0.14.0b4 Tauri to ship 0.13.1.
run: python3 scripts/check_tauri_server.py "$VERSION_NO_V"
- name: Set up Node
if: ${{ !matrix.skip_webui }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node_version }}
- name: Set up Rust
if: ${{ !matrix.skip_rust }}
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master
id: toolchain
with:
toolchain: stable
- name: Cache node_modules
uses: actions/cache@v6
if: ${{ !matrix.skip_webui }}
with:
path: |
aw-server-rust/aw-webui/node_modules
aw-tauri/node_modules
key: ${{ matrix.os }}-node_modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ matrix.os }}-node_modules-
- name: Cache cargo build
uses: actions/cache@v6
env:
cache-name: cargo-build-target
with:
path: |
aw-server-rust/target
aw-tauri/src-tauri/target
key: ${{ matrix.os }}-${{ env.cache-name }}-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ matrix.os }}-${{ env.cache-name }}-${{ steps.toolchain.outputs.rustc_hash }}-
- name: Install APT dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
xdg-utils
# Pin WebKit to avoid blank-webview regression on newer ubuntu-24.04 packages.
# See: ActivityWatch/aw-tauri#99
sudo apt-get install -y \
libwebkit2gtk-4.1-0=2.44.0-2 \
libwebkit2gtk-4.1-dev=2.44.0-2 \
libjavascriptcoregtk-4.1-0=2.44.0-2 \
libjavascriptcoregtk-4.1-dev=2.44.0-2 \
gir1.2-javascriptcoregtk-4.1=2.44.0-2 \
gir1.2-webkit2-4.1=2.44.0-2
- name: Install dependencies
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
choco install innosetup
fi
pip3 install poetry==2.2.1
- name: Patch research edition defaults
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_config.py aw-watcher-window/aw_watcher_window/config.py
# Watchers store buckets as `{client}_{hostname}`. That hostname is a
# participant identifier on named machines, and /api/0/export copies it
# into the map key, embedded id, and hostname field. Standard builds
# must not change; this patch is Research-only and fail-closed.
- name: Patch research edition export hostname sanitizer
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_export.py
# The watcher keeps approved app names and replaces browser titles with a
# study category. This preset maps both representations into the study
# taxonomy so Top Applications and Top Categories agree.
- name: Emit research edition category preset for the web UI
if: env.AW_RESEARCH_EDITION == 'true'
shell: bash
run: |
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:
timeout_minutes: 60
max_attempts: 3
shell: bash
command: |
set -e
python3 -m venv venv
source venv/bin/activate || source venv/Scripts/activate
poetry install
make build SKIP_WEBUI=${{ matrix.skip_webui }} SKIP_SERVER_RUST=${{ matrix.skip_rust }}
pip freeze
env:
# 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
with:
timeout_minutes: 60
max_attempts: 3
shell: bash
command: |
source venv/bin/activate || source venv/Scripts/activate
make test SKIP_SERVER_RUST=${{ matrix.skip_rust }}
# The patcher's exact-match table is release-critical. Run it on every
# PR/release job (not only research tags) so a drifted submodule pin
# fails before packaging. Must stay on the unpatched tree: --check
# and the fail-closed tests assert the ordinary defaults.
- name: Test research edition profile patcher
run: |
source venv/bin/activate || source venv/Scripts/activate
python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q
# Research Edition profile identity is baked AFTER the module test
# suites: they assert the ordinary defaults (profile=default, port 5600)
# and would fail on the patched tree. Python modules are editable
# installs, so PyInstaller collects the patched source at package time;
# the Rust binaries are rebuilt below. Fail-closed: a stale submodule pin
# aborts the build rather than shipping a bundle that runs the default
# profile next to a participant's standard install.
- name: Patch research edition profile identity
if: env.AW_RESEARCH_EDITION == 'true'
run: python3 scripts/patch_research_edition_profile.py tauri
- name: Rebuild aw-tauri with research defaults
if: env.AW_RESEARCH_EDITION == 'true'
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4
with:
timeout_minutes: 60
max_attempts: 3
shell: bash
command: |
set -e
source venv/bin/activate || source venv/Scripts/activate
make --directory=aw-tauri build
env:
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')
run: |
for var in APPLE_EMAIL APPLE_PASSWORD APPLE_TEAMID CERTIFICATE_MACOS_P12_BASE64 CERTIFICATE_MACOS_P12_PASSWORD; do
if [ -z "${!var:-}" ]; then
echo "ERROR: $var is required to sign macOS release artifacts" >&2
exit 1
fi
done
./scripts/ci/import-macos-p12.sh
APPLE_PERSONALID=$(security find-identity -v -p codesigning "${KEY_CHAIN:-build.keychain-db}" | sed -n 's/.*"\(.*\)"/\1/p' | head -1)
if [ -z "$APPLE_PERSONALID" ]; then
echo "ERROR: no codesigning identity found after importing the macOS certificate" >&2
exit 1
fi
echo "APPLE_PERSONALID=$APPLE_PERSONALID" >> "$GITHUB_ENV"
env:
APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAMID: ${{ secrets.APPLE_TEAMID }}
CERTIFICATE_MACOS_P12_BASE64: ${{ secrets.CERTIFICATE_MACOS_P12_BASE64 }}
CERTIFICATE_MACOS_P12_PASSWORD: ${{ secrets.CERTIFICATE_MACOS_P12_PASSWORD }}
- name: Package
run: |
source venv/bin/activate || source venv/Scripts/activate
poetry install
make package SKIP_SERVER_RUST=${{ matrix.skip_rust }}
- name: Package Linux (Tauri bundles)
if: runner.os == 'Linux'
run: |
# aw-tauri's bundler produces .AppImage/.deb/.rpm under
# dist/activitywatch/aw-tauri/ via `make package`. Copy them out with
# versioned, arch-suffixed filenames so they match the
# `dist/activitywatch-*.*` upload pattern below.
ARCH=$(uname -m)
shopt -s nullglob
EDITION=""
if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi
for ext in AppImage deb rpm; do
files=( dist/activitywatch/aw-tauri/*.${ext} )
case ${#files[@]} in
0) continue ;;
1) cp -v "${files[0]}" "dist/activitywatch-tauri${EDITION}-${VERSION_WITH_V}-linux-${ARCH}.${ext}" ;;
*) echo "ERROR: expected at most 1 .${ext} bundle, found ${#files[@]}" >&2; exit 1 ;;
esac
done
- name: Package dmg
if: runner.os == 'macOS'
run: |
SIGN_MACOS=false
if [[ "${GITHUB_REF:-}" == refs/tags/v* || "$AW_RESEARCH_EDITION" == "true" ]]; then
SIGN_MACOS=true
fi
BUNDLE="ActivityWatch"
if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then
BUNDLE="ActivityWatch-Research"
fi
source venv/bin/activate
make "dist/${BUNDLE}.dmg" APP_BUNDLE="${BUNDLE}"
if [ "$SIGN_MACOS" = true ]; then
codesign --force --verbose --timestamp -s "${APPLE_PERSONALID}" "dist/${BUNDLE}.dmg"
brew install akeru-inc/tap/xcnotary
xcnotary precheck "dist/${BUNDLE}.app"
xcnotary precheck "dist/${BUNDLE}.dmg"
make dist/notarize
fi
EDITION=""
if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi
mv "dist/${BUNDLE}.dmg" dist/activitywatch-tauri${EDITION}-${VERSION_WITH_V}-macos-$(uname -m).dmg
env:
APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }}
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 }}${{ matrix.research && '-research' || '' }}
path: |
dist/activitywatch-*.*
dist/updater/*
release-notes:
name: Generate release notes
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
submodules: 'recursive'
fetch-depth: 0
- uses: ActivityWatch/check-version-format-action@5b223bd718c4c00466359532cf944474f069625a # v2.0.1
id: version
with:
prefix: 'v'
- name: Echo version
run: |
echo "${{ steps.version.outputs.full }} (stable: ${{ steps.version.outputs.is_stable }})"
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Install deps
run: |
pip install requests
- name: Generate release notes
if: ${{ !endsWith(github.ref_name, '-research') }}
run: |
LAST_RELEASE=`STABLE_ONLY=${{ steps.version.outputs.is_stable }} ./scripts/get_latest_release.sh`
./scripts/build_changelog.py --range "$LAST_RELEASE...${{ steps.version.outputs.full }}"
- name: Generate release notes (research edition)
if: endsWith(github.ref_name, '-research')
run: |
# check-version-format-action cannot parse research-suffixed tags,
# so derive the range end from the tag name directly. Research
# releases are never stable.
LAST_RELEASE=$(STABLE_ONLY=false ./scripts/get_latest_release.sh)
# get_latest_release.sh only matches standard tags; use git describe
# to find the topologically nearest ancestral research release.
# Excluding the current tag also ignores newer or unrelated lineages.
prev_research=$(git describe --tags --abbrev=0 \
--match '*-research' --exclude "${GITHUB_REF_NAME}" \
"${GITHUB_REF_NAME}" 2>/dev/null || true)
if [ -n "$LAST_RELEASE" ] && [ -n "$prev_research" ] && \
git merge-base --is-ancestor "$LAST_RELEASE" "$prev_research"; then
LAST_RELEASE="$prev_research"
fi
./scripts/build_changelog.py --range "$LAST_RELEASE...${GITHUB_REF_NAME}"
- name: Rename
run: |
mv changelog.md release_notes.md
- name: Adjust release notes for research edition
if: endsWith(github.ref_name, '-research')
run: |
{
echo "> [!IMPORTANT]"
echo "> **ActivityWatch Research Edition** (\`${GITHUB_REF_NAME}\`) — prerelease build for research-study participants, with the privacy filter and study category map enabled by default (\`research_enabled = true\` in aw-watcher-window)."
echo "> Not intended for general use — regular users should install the latest [standard release](https://github.com/ActivityWatch/activitywatch/releases) instead."
echo ""
cat release_notes.md
} > release_notes_research.md
mv release_notes_research.md release_notes.md
# The generated Downloads section hardcodes standard-edition asset
# filenames, which do not exist on research releases (research
# artifacts carry a -research token). Point at the asset
# list instead of linking to 404s.
python3 - <<'PY'
import re
path = "release_notes.md"
notes = open(path).read()
notes = re.sub(
r"(?m)^ - \[\*\*(Windows|macOS|Linux)\*\*\]\([^)]*\)[^\n]*\n?",
"",
notes,
)
notes = notes.replace(
"# Downloads\n",
"# Downloads\n\nResearch Edition installers are attached to this"
" release as assets below (filenames contain `research`)."
" Standard-release download links do not apply to this build.\n",
)
open(path, "w").write(notes)
PY
- name: Upload release notes
uses: actions/upload-artifact@v7
with:
name: release_notes
path: release_notes.md
release:
name: Publish draft release
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [build-qt, build-qt-manylinux-2-28, 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:
path: dist
- name: Display structure of downloaded files
run: ls -R
working-directory: dist
- uses: ActivityWatch/check-version-format-action@5b223bd718c4c00466359532cf944474f069625a # v2.0.1
id: version
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.
#
# 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
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-*.*
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.
prerelease: ${{ !(steps.version.outputs.is_stable == 'true') }}
# Retry CI after transient upload-artifact timeout.