Skip to content

Release

Release #25

Workflow file for this run

name: Release
# Full release workflow: bumps versionName in source, commits, tags, and pushes.
# The tag push (via RELEASE_PAT) triggers the Build workflow automatically.
#
# Supports stable and pre-release versions:
# Stable: 0.14.0 → full Play Store production release
# Beta: 0.14.0b1 → GitHub pre-release, Play Store internal track
# Dev: 0.14.0dev20260712 → GitHub pre-release, Play Store internal track
#
# Prerequisites:
# - Add a RELEASE_PAT secret: a GitHub PAT with `repo` scope.
# Using a PAT instead of GITHUB_TOKEN ensures the tag push triggers Build.
#
# Usage: Actions → Release → Run workflow → enter version (e.g. 0.14.0 or 0.14.0b1)
#
# Release discipline (added after aw-android#261, where a build with 12 PRs and a
# 25-commit aw-server-rust bump went straight to 100% of production and wedged the
# app for upgrading users):
# - A stable release must confirm `installed_on_device`: this commit, or a
# beta/internal build containing every commit since the last release, was
# installed on a real device with existing history and the Activity view
# opened. The job fails otherwise.
# - The job summary lists every commit and the aw-server-rust submodule range
# since the previous tag, so "small bump" is judged by the diff.
# - Stable releases start as a staged rollout (default 10%). The fraction is
# recorded in the tag annotation and read by the Build workflow's publish
# step. Widen it with Actions → Rollout after the first day looks clean.
# - A stable release refuses to start while the Play production track still
# has a staged rollout in progress (scripts/check-production-rollout.py).
# Play keeps one in-progress release per track, so a new staged release
# silently replaces the old one: on 2026-10-11, v0.14.3 at 50% replaced
# v0.14.2 (versionCode 46, stuck at 50%), and the comparison quietly became
# 0.14.3 vs 0.14.1. Complete the previous rollout (Actions → Rollout,
# fraction 1.0) or halt it in Play Console first. If the Play API can't be
# read, the check fails closed. `allow_inprogress_rollout=true` overrides
# both, with a warning, for when superseding is intended; it is recorded in
# the tag annotation. The Build workflow re-runs the check right before the
# production upload (covering hand-pushed tags and overlapping releases)
# and honours the same annotation. Pre-releases go to the internal track
# and are not checked.
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release, without v prefix (e.g. 0.14.0 for stable, 0.14.0b1 for beta, 0.14.0dev20260712 for dev)'
required: true
type: string
installed_on_device:
description: 'Stable only: this build (or a beta containing every commit since the last release) was installed on a real device with existing history and the Activity view opened'
required: false
default: false
type: boolean
rollout:
description: 'Stable only: initial Play production rollout fraction (widen later with the Rollout workflow)'
required: false
default: '0.1'
type: choice
options: ['0.1', '0.25', '0.5', '1.0']
allow_inprogress_rollout:
description: 'Stable only: start even if the Play production track has a staged rollout in progress (it will be replaced) or its state cannot be read'
required: false
default: false
type: boolean
jobs:
release:
name: Bump versionName, commit, and tag
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check for RELEASE_PAT
env:
VERSION: ${{ inputs.version }}
run: |
if [ -z "${{ secrets.RELEASE_PAT }}" ]; then
echo "::warning::RELEASE_PAT secret is not set. Falling back to GITHUB_TOKEN."
echo "::warning::GitHub prevents GITHUB_TOKEN pushes from triggering other workflows."
echo "::warning::After this run, manually trigger the Build workflow:"
echo "::warning:: gh workflow run build.yml --repo ActivityWatch/aw-android --ref refs/tags/v${VERSION}"
echo "::warning::Or add RELEASE_PAT (Settings → Secrets → Actions) to fix this permanently."
fi
- uses: actions/checkout@v4
with:
# RELEASE_PAT is a repo-scoped PAT; its pushes trigger the Build workflow.
# Falls back to GITHUB_TOKEN (build won't auto-trigger, but tag is still created).
token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
# Accept stable (X.Y.Z) and pre-release (X.Y.ZbN, X.Y.ZrcN, X.Y.ZdevYYYYMMDD, etc.)
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+[a-z0-9._-]*$ ]]; then
echo "::error::Version must start with X.Y.Z (e.g. 0.14.0, 0.14.0b1, 0.14.0dev20260712). Got: ${VERSION}"
exit 1
fi
if git rev-parse "refs/tags/v${VERSION}" >/dev/null 2>&1; then
echo "::error::Tag v${VERSION} already exists."
exit 1
fi
- name: Release discipline gate
id: gate
env:
VERSION: ${{ inputs.version }}
INSTALLED_ON_DEVICE: ${{ inputs.installed_on_device }}
ROLLOUT: ${{ inputs.rollout }}
run: |
# Stable: previous production tag, not the nearest beta. `git describe`
# would hide the prerelease range and understate the release (the
# v0.14.0-shaped failure this gate exists to catch).
PREV_TAG="$(bash scripts/previous-release-tag.sh "$VERSION")"
{
echo "## Release v${VERSION}"
if [ -n "$PREV_TAG" ]; then
echo "### Commits since ${PREV_TAG} ($(git rev-list --count "${PREV_TAG}..HEAD"))"
git log "${PREV_TAG}..HEAD" --pretty=format:'- %h %s' | sed 's/^$/- (none)/'
echo
echo "### aw-server-rust submodule"
git diff --submodule=log "${PREV_TAG}..HEAD" -- aw-server-rust \
| sed 's/^/ /' || true
else
echo "(no previous tag found)"
fi
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
if [ "$INSTALLED_ON_DEVICE" != "true" ]; then
echo "::error::Stable release ${VERSION} without a device check. Install this build (or a beta containing every commit since ${PREV_TAG:-the last release}) on a real device with existing history, open the Activity view, then re-run with installed_on_device=true."
exit 1
fi
echo "Stable release: initial production rollout ${ROLLOUT}" | tee -a "$GITHUB_STEP_SUMMARY"
echo "stable=true" >> "$GITHUB_OUTPUT"
else
echo "Pre-release: internal track, no rollout gate."
echo "stable=false" >> "$GITHUB_OUTPUT"
fi
# Loaded before anything is bumped: the production-rollout check below
# needs the same service account, and it must fail before any commit/tag.
- uses: adnsio/setup-age-action@v1.2.0
- name: Load Fastlane secrets
env:
KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }}
run: |
printf "$KEY_FASTLANE_API" > fastlane/api-8546008605074111507-287154-450dc77b365f.json.key
cat fastlane/api-8546008605074111507-287154-450dc77b365f.json.age \
| age -d -i fastlane/api-8546008605074111507-287154-450dc77b365f.json.key \
-o fastlane/api-8546008605074111507-287154-450dc77b365f.json
rm fastlane/api-8546008605074111507-287154-450dc77b365f.json.key
- name: Set up Python
if: steps.gate.outputs.stable == 'true'
uses: actions/setup-python@v6
with:
python-version: "3.12"
# Play keeps one in-progress release per track: a new staged production
# release would silently replace a rollout still in progress (v0.14.3
# replacing v0.14.2 at 50%, 2026-10-11). Fails closed if Play can't be read.
- name: Refuse to supersede an in-progress production rollout
if: steps.gate.outputs.stable == 'true'
env:
ALLOW_INPROGRESS_ROLLOUT: ${{ inputs.allow_inprogress_rollout }}
run: |
ARGS=()
if [ "$ALLOW_INPROGRESS_ROLLOUT" = "true" ]; then
echo "::warning::allow_inprogress_rollout=true: a staged production rollout in progress (or an unreadable Play track) will not block this release."
ARGS+=(--allow-inprogress)
fi
# Install failure falls through to the script, which fails closed
# (or warns with the override) when google-auth/requests are missing.
pip install --quiet google-auth requests || echo "::warning::pip install google-auth requests failed"
python3 scripts/check-production-rollout.py \
--credentials fastlane/api-8546008605074111507-287154-450dc77b365f.json \
"${ARGS[@]}"
- name: Bump versionName in mobile/build.gradle
run: |
VERSION="${{ inputs.version }}"
# Replace quoted versionName value; works for both single and double quotes.
sed -i -E "s/^([[:space:]]*versionName[[:space:]]+)[\"'][^\"']+[\"']/\1'${VERSION}'/" \
mobile/build.gradle
# Verify: the line must end with the closing quote (no dangling chars).
grep -qE "^[[:space:]]*versionName[[:space:]]+'${VERSION}'[[:space:]]*$" \
mobile/build.gradle || {
echo "::error::versionName update failed. Current state:"
grep "versionName" mobile/build.gradle
exit 1
}
echo "Updated versionName to '${VERSION}'"
# version set by .ruby-version
- name: Set up Ruby and install fastlane
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Update versionCode
uses: Wandalen/wretry.action@v3.8.0_js_action
with:
command: bundle exec fastlane update_version
attempt_limit: 3
attempt_delay: 20000
- name: Generate changelog
run: |
VERSION_CODE=$(sed -n 's/.*versionCode \([0-9]*\).*/\1/p' mobile/build.gradle | head -1)
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
# Google Play rejects release notes longer than 500 characters per
# language, and fastlane only surfaces this at upload time — after the
# tag has already been pushed. Trim here, on whole-line boundaries, so
# a release is never blocked by a long changelog.
# Byte length is used as the bound: for UTF-8 it is >= the character
# count Google measures, so this can only ever be conservative.
# Play Store "What's new" is plain text — the URL is readable but not
# clickable — so keep the full-changelog pointer short and
# recognisable. Generated entries always carry it: the entry list is
# filtered to user-facing changes, so the GitHub release is the only
# route to the complete history.
MAX_BYTES=500
MORE_LINE="Full changelog: https://github.com/ActivityWatch/aw-android/releases/tag/v${{ inputs.version }}"
BUDGET=$(( MAX_BYTES - ${#MORE_LINE} - 2 ))
# Trim the entry list to the budget, then append the pointer.
# LC_ALL=C makes length() count bytes in any awk (locale-aware
# awks would otherwise count characters). Whole-line trimming
# means no multibyte character is ever split; UTF-8 bytes are
# >= characters, so a byte budget is conservative for Play's
# 500-character limit.
write_changelog() {
LC_ALL=C awk -v budget="$BUDGET" '
{ need = length($0) + 1 }
used + need > budget { exit }
{ print; used += need }
' "$1" > "${CHANGELOG}.tmp"
if [ ! -s "${CHANGELOG}.tmp" ]; then
# Not even the first entry fits — fall back to a generic note.
printf '%s\n' "- Bug fixes and stability improvements" > "${CHANGELOG}.tmp"
fi
printf '\n%s\n' "$MORE_LINE" >> "${CHANGELOG}.tmp"
mv "${CHANGELOG}.tmp" "$CHANGELOG"
}
if [ -f "$CHANGELOG" ]; then
echo "Using existing changelog: $CHANGELOG"
if [ "$(wc -c < "$CHANGELOG")" -gt "$MAX_BYTES" ]; then
echo "::warning::$CHANGELOG is $(wc -c < "$CHANGELOG") bytes; trimming to Google Play's ${MAX_BYTES}-character limit"
ENTRIES="$(mktemp)"
cp "$CHANGELOG" "$ENTRIES"
write_changelog "$ENTRIES"
rm -f "$ENTRIES"
fi
else
# Same baseline as the release summary: stable → previous production
# tag, prerelease → nearest tag.
PREV_TAG="$(bash scripts/previous-release-tag.sh "${{ inputs.version }}")"
ENTRIES="$(mktemp)"
if [ -n "$PREV_TAG" ]; then
# Only user-facing changes belong in "What's new": the filter drops
# chore/build/ci/test/docs commits, infra-scoped ones, and
# research-edition work, so the salient changes fit the 500-byte
# budget instead of being truncated behind CI noise.
git log "${PREV_TAG}..HEAD" --pretty=format:"%s" \
| python3 scripts/user-facing-changelog.py \
> "$ENTRIES" || true
fi
if [ ! -s "$ENTRIES" ]; then
printf '%s\n' "- Bug fixes and stability improvements" > "$ENTRIES"
fi
write_changelog "$ENTRIES"
rm -f "$ENTRIES"
echo "Generated $CHANGELOG"
fi
echo "Final changelog ($(wc -c < "$CHANGELOG") bytes):"
cat "$CHANGELOG"
- name: Commit and tag
run: |
VERSION="${{ inputs.version }}"
VERSION_CODE=$(sed -n 's/.*versionCode \([0-9]*\).*/\1/p' mobile/build.gradle | head -1)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add mobile/build.gradle
git add fastlane/metadata/android/en-US/changelogs/
git commit -m "chore: bump versionName to ${VERSION}, versionCode to ${VERSION_CODE}"
# The Build workflow reads `rollout=` from the tag annotation to stage
# the production rollout; pre-releases go to the internal track.
# `allow_inprogress_rollout=true` is carried the same way, so the
# Build workflow's pre-upload rollout check honours the override too.
TAG_ARGS=(-m "Release v${VERSION}" -m "rollout=${{ inputs.rollout }}")
if [ "${{ inputs.allow_inprogress_rollout }}" = "true" ]; then
TAG_ARGS+=(-m "allow_inprogress_rollout=true")
fi
git tag -a "v${VERSION}" "${TAG_ARGS[@]}"
git push origin HEAD:master
git push origin "v${VERSION}"