Repository navigation
Release #25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" |