Skip to content

refactor(ci): unify release workflows (#1313) #2

refactor(ci): unify release workflows (#1313)

refactor(ci): unify release workflows (#1313) #2

Workflow file for this run

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
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@v6
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' }}
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
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@v6
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 }}"
git tag -a "$tag" -m "Development prerelease $tag"
git push origin "$tag"
{
echo "## Dev release created"
echo ""
echo "- Tag: \`$tag\`"
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"
build-qt:
name: Build Qt artifacts
if: github.event_name == 'push' || github.event_name == 'pull_request'
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
env:
AW_EXTRAS: true
MACOSX_DEPLOYMENT_TARGET: 10.9
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
python_version: [3.9]
node_version: [22]
skip_rust: [false]
skip_webui: [false]
experimental: [false]
steps:
- uses: actions/checkout@v6
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)
VERSION_NO_V="${VERSION_WITH_V#v}"
echo "VERSION_WITH_V=${VERSION_WITH_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: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python_version }}
- name: Set up Node
if: ${{ !matrix.skip_webui }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node_version }}
- name: Set up Rust
if: ${{ !matrix.skip_rust }}
uses: dtolnay/rust-toolchain@master
id: toolchain
with:
toolchain: stable
- name: Cache node_modules
uses: actions/cache@v5
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@v5
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==1.4.2
- 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
- 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: |
if [ -n "$APPLE_EMAIL" ]; then
./scripts/ci/import-macos-p12.sh
fi
source venv/bin/activate
make dist/ActivityWatch.dmg
if [ -n "$APPLE_EMAIL" ]; then
codesign --force --verbose --timestamp -s ${APPLE_PERSONALID} dist/ActivityWatch.dmg
brew install akeru-inc/tap/xcnotary
xcnotary precheck dist/ActivityWatch.app
xcnotary precheck dist/ActivityWatch.dmg
make dist/notarize
fi
mv dist/ActivityWatch.dmg dist/activitywatch-${VERSION_WITH_V}-macos-$(uname -m).dmg
env:
APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_PERSONALID: ${{ secrets.APPLE_TEAMID }}
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 }}
path: dist/activitywatch-*.*
build-tauri:
name: Build Tauri artifacts
if: github.event_name == 'push' || github.event_name == 'pull_request'
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
env:
AW_EXTRAS: true
TAURI_BUILD: true
MACOSX_DEPLOYMENT_TARGET: 10.9
defaults:
run:
shell: bash
strategy:
fail-fast: false
max-parallel: 5
matrix:
os:
[
ubuntu-24.04,
ubuntu-24.04-arm,
windows-latest,
macos-latest,
]
python_version: [3.9]
node_version: [22]
skip_rust: [false]
skip_webui: [false]
experimental: [false]
steps:
- uses: actions/checkout@v6
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)
VERSION_NO_V="${VERSION_WITH_V#v}"
echo "VERSION_WITH_V=${VERSION_WITH_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@v6
with:
python-version: ${{ matrix.python_version }}
- name: Set up Node
if: ${{ !matrix.skip_webui }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node_version }}
- name: Set up Rust
if: ${{ !matrix.skip_rust }}
uses: dtolnay/rust-toolchain@master
id: toolchain
with:
toolchain: stable
- name: Cache node_modules
uses: actions/cache@v5
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@v5
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==1.4.2
- name: Build
uses: nick-fields/retry@v4
with:
timeout_minutes: 60
max_attempts: 3
shell: bash
command: |
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
uses: nick-fields/retry@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 }}
- 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
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-${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: |
if [ -n "$APPLE_EMAIL" ]; then
./scripts/ci/import-macos-p12.sh
fi
source venv/bin/activate
make dist/ActivityWatch.dmg
if [ -n "$APPLE_EMAIL" ]; then
codesign --force --verbose --timestamp -s ${APPLE_PERSONALID} dist/ActivityWatch.dmg
brew install akeru-inc/tap/xcnotary
xcnotary precheck dist/ActivityWatch.app
xcnotary precheck dist/ActivityWatch.dmg
make dist/notarize
fi
mv dist/ActivityWatch.dmg dist/activitywatch-tauri-${VERSION_WITH_V}-macos-$(uname -m).dmg
env:
APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_PERSONALID: ${{ secrets.APPLE_TEAMID }}
APPLE_TEAMID: ${{ secrets.APPLE_TEAMID }}
CERTIFICATE_MACOS_P12_BASE64: ${{ secrets.CERTIFICATE_MACOS_P12_BASE64 }}
CERTIFICATE_MACOS_P12_PASSWORD: ${{ secrets.CERTIFICATE_MACOS_P12_PASSWORD }}
- name: Upload packages
uses: actions/upload-artifact@v7
with:
name: builds-tauri-${{ matrix.os }}-py${{ matrix.python_version }}
path: dist/activitywatch-*.*
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@v6
with:
submodules: 'recursive'
fetch-depth: 0
- uses: ActivityWatch/check-version-format-action@v2
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@v6
with:
python-version: "3.12"
- name: Install deps
run: |
pip install requests
- name: Generate release notes
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: Rename
run: |
mv changelog.md release_notes.md
- 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-tauri, release-notes]
runs-on: ubuntu-latest
steps:
- 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@v2
id: version
with:
prefix: 'v'
- name: Release
uses: softprops/action-gh-release@v3
with:
draft: true
files: dist/*/activitywatch-*.*
body_path: dist/release_notes/release_notes.md
prerelease: ${{ !(steps.version.outputs.is_stable == 'true') }}