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:-}" 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]--...), 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]--...), 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]--...), 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.