Skip to content

Commit 8e0bd28

Browse files
andrew-polkclaude
andcommitted
Add GitHub Actions workflow to publish to Play Console internal track
Pushes to master publish the alpha flavor (with the latest bloom-player alpha); pushes to release publish the production flavor. Both go to the internal track via gradle-play-publisher, mirroring the TeamCity task lists. - The patch number is derived from git: commits since the last change to versionMajor/versionMinor in app/build.gradle. It resets to 0 automatically on a version bump; alpha and production count independently on their branches. A hardcoded offset keyed to the 3.4 bump commit keeps continuity with the old TeamCity counter and expires on its own at the next version bump. - Production releases retain the legacy 1.4 APK (versionCode 104001) so devices below the current minSdkVersion keep a working app; this removes the reason production publishing had to stay manual. - The PLAY_DRY_RUN repo variable makes runs upload to a Play edit without committing it, for verifying the workflow safely. - Successful production publishes tag the commit (vX.Y.Z). - README updated to describe the new CI/CD process. - TEMP (remove before merging to master): the workflow also triggers on pushes to play-publish-gha for pre-merge testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a94f831 commit 8e0bd28

4 files changed

Lines changed: 294 additions & 12 deletions

File tree

.github/workflows/play-publish.yml

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# Publishes Bloom Reader to the Google Play Console (internal track).
2+
#
3+
# - Pushes to `master` -> alpha flavor -> internal track of the "BR Alpha" app
4+
# - Pushes to `release` -> production flavor -> internal track of the "Bloom Reader" app
5+
#
6+
# (The Play track is set to "internal" in app/build.gradle's `play {}` block;
7+
# promotion to other tracks is done manually in the Play Console or via the
8+
# promote*Artifact gradle tasks.)
9+
#
10+
# Required repository secrets:
11+
# KEYSTORE_BASE64 base64 of keystore_bloom_reader.keystore
12+
# (e.g. `base64 -w0 keystore_bloom_reader.keystore`)
13+
# KEYSTORE_STORE_PASSWORD storePassword from keystore_bloom_reader.properties
14+
# KEYSTORE_KEY_ALIAS keyAlias from keystore_bloom_reader.properties
15+
# KEYSTORE_KEY_PASSWORD keyPassword from keystore_bloom_reader.properties
16+
# PLAY_SERVICE_ACCOUNT_JSON contents of the Google Play service account json file
17+
#
18+
# Optional repository variable:
19+
# PLAY_DRY_RUN set to "true" to build, sign, and upload to a Play
20+
# edit WITHOUT committing it — nothing becomes
21+
# visible in the Play Console. Use while verifying
22+
# this workflow; delete the variable to go live.
23+
#
24+
# Versioning: the patch number (3.4.NNN) is the number of commits since
25+
# versionMajor/versionMinor last changed in app/build.gradle, so it resets to 0
26+
# automatically when the version is bumped, and master (alpha) and release
27+
# (production) count independently. NOTE: build.gradle computes versionCode =
28+
# major*100000 + minor*1000 + patch, so patch must stay below 1000.
29+
30+
name: Publish to Play Console
31+
32+
on:
33+
push:
34+
# TEMP: play-publish-gha is included only to test this workflow before
35+
# merging; remove it (revert this commit) before merging to master.
36+
branches: [master, release, play-publish-gha]
37+
workflow_dispatch:
38+
inputs:
39+
flavor:
40+
description: "Flavor to publish (both go to the internal track)"
41+
type: choice
42+
options: [Alpha, Production]
43+
default: Alpha
44+
patch:
45+
description: "Override the patch number (default: commits since the last version bump, plus the +77 legacy alpha offset)"
46+
type: string
47+
required: false
48+
default: ""
49+
50+
# Never run two publishes at once; Play edits would race.
51+
concurrency:
52+
group: play-publish
53+
cancel-in-progress: false
54+
55+
# contents: write lets the workflow push the release tag.
56+
permissions:
57+
contents: write
58+
59+
jobs:
60+
publish:
61+
runs-on: ubuntu-latest
62+
steps:
63+
- uses: actions/checkout@v7
64+
with:
65+
fetch-depth: 0 # full history: the patch number is derived from it
66+
fetch-tags: true # the tag step checks for existing release tags
67+
68+
- name: Determine flavor and build number
69+
id: config
70+
env:
71+
PATCH_INPUT: ${{ inputs.patch }}
72+
run: |
73+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
74+
FLAVOR="${{ inputs.flavor }}"
75+
elif [ "${{ github.ref }}" = "refs/heads/release" ]; then
76+
FLAVOR="Production"
77+
else
78+
FLAVOR="Alpha"
79+
fi
80+
# A real publish must run from the flavor's own branch: the two
81+
# branches have different commit counts, so publishing a flavor
82+
# from the wrong branch corrupts that app's version numbering.
83+
# Dry runs commit nothing to Play and may run from any branch.
84+
if [ "${{ vars.PLAY_DRY_RUN }}" != "true" ]; then
85+
if [ "$FLAVOR" = "Production" ] && [ "${{ github.ref }}" != "refs/heads/release" ]; then
86+
echo "::error::A real Production publish must run from the release branch."
87+
exit 1
88+
fi
89+
if [ "$FLAVOR" = "Alpha" ] && [ "${{ github.ref }}" = "refs/heads/release" ]; then
90+
echo "::error::A real Alpha publish must not run from the release branch."
91+
exit 1
92+
fi
93+
fi
94+
# The patch number counts commits since the last version bump (the
95+
# last commit that changed the versionMajor/versionMinor lines in
96+
# app/build.gradle), so bumping the version resets it to 0 on its
97+
# own, and master (alpha) and release (production) count
98+
# independently on their own branches.
99+
if [ -n "$PATCH_INPUT" ]; then
100+
if ! [[ "$PATCH_INPUT" =~ ^[0-9]+$ ]]; then
101+
echo "::error::patch must be a plain number, got: $PATCH_INPUT"
102+
exit 1
103+
fi
104+
BUILD_NUMBER="$PATCH_INPUT"
105+
else
106+
BUMP_COMMIT=$(git log -1 --format=%H -G'^def version(Major|Minor)' -- app/build.gradle)
107+
BUILD_NUMBER=$(git rev-list --count "$BUMP_COMMIT..HEAD")
108+
# Continuity with the old TeamCity build counter (alpha was at
109+
# 3.4.103 when this workflow took over). Keyed to the 3.4 bump
110+
# commit, so it expires by itself: the next version bump becomes
111+
# the new BUMP_COMMIT and this no longer applies.
112+
if [ "$BUMP_COMMIT" = "06dc6358a9a2ea410d5ce9bf6b39474177461618" ] && [ "$FLAVOR" = "Alpha" ]; then
113+
BUILD_NUMBER=$(( BUILD_NUMBER + 77 ))
114+
fi
115+
fi
116+
if [ "$BUILD_NUMBER" -gt 999 ]; then
117+
echo "::error::patch $BUILD_NUMBER would overflow into the minor-version digits of versionCode"
118+
exit 1
119+
fi
120+
# Gradle task lists mirror the TeamCity builds ("clean" omitted:
121+
# the build dir doesn't exist on a fresh runner), plus the publish
122+
# step. TC's production build stops at assemble (publishing was a
123+
# separate manual step); here publishProductionRelease assembles
124+
# and publishes to the internal track.
125+
if [ "$FLAVOR" = "Alpha" ]; then
126+
TASKS="build publishAlphaRelease promoteAlphaReleaseArtifact"
127+
else
128+
TASKS="lintProductionRelease testProductionReleaseUnitTest publishProductionRelease"
129+
fi
130+
MAJOR=$(sed -n 's/^def versionMajor = \([0-9]*\).*/\1/p' app/build.gradle)
131+
MINOR=$(sed -n 's/^def versionMinor = \([0-9]*\).*/\1/p' app/build.gradle)
132+
echo "flavor=$FLAVOR" >> "$GITHUB_OUTPUT"
133+
echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
134+
echo "version=$MAJOR.$MINOR.$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
135+
echo "tasks=$TASKS" >> "$GITHUB_OUTPUT"
136+
echo "Publishing flavor $FLAVOR version $MAJOR.$MINOR.$BUILD_NUMBER (tasks: $TASKS)"
137+
138+
- uses: actions/setup-java@v5
139+
with:
140+
distribution: temurin
141+
java-version: 17
142+
143+
- uses: gradle/actions/setup-gradle@v6
144+
145+
- uses: actions/setup-node@v6
146+
with:
147+
node-version: 20
148+
cache: yarn
149+
cache-dependency-path: app/yarn.lock
150+
151+
- name: Install bloom-player
152+
working-directory: app
153+
run: yarn install --frozen-lockfile
154+
155+
# Alpha builds always ship the latest alpha of bloom-player;
156+
# production builds use the version locked in yarn.lock.
157+
- name: Upgrade to latest bloom-player alpha
158+
if: steps.config.outputs.flavor == 'Alpha'
159+
working-directory: app
160+
run: yarn upgrade bloom-player@alpha
161+
162+
- name: Set up signing and Play credentials
163+
env:
164+
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
165+
KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }}
166+
KEYSTORE_KEY_ALIAS: ${{ secrets.KEYSTORE_KEY_ALIAS }}
167+
KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
168+
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
169+
run: |
170+
# app/build.gradle reads ~/keystore/keystore_bloom_reader.properties
171+
mkdir -p "$HOME/keystore"
172+
echo "$KEYSTORE_BASE64" | base64 -d > "$HOME/keystore/keystore_bloom_reader.keystore"
173+
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > "$HOME/keystore/play-service-account.json"
174+
cat > "$HOME/keystore/keystore_bloom_reader.properties" <<EOF
175+
storeFile=$HOME/keystore/keystore_bloom_reader.keystore
176+
storePassword=$KEYSTORE_STORE_PASSWORD
177+
keyAlias=$KEYSTORE_KEY_ALIAS
178+
keyPassword=$KEYSTORE_KEY_PASSWORD
179+
serviceAccountJsonFile=$HOME/keystore/play-service-account.json
180+
EOF
181+
182+
- name: Copy bloom-player assets
183+
run: ./gradlew copyBloomPlayerAssets
184+
185+
- name: Build and publish to internal track
186+
run: >
187+
./gradlew ${{ steps.config.outputs.tasks }}
188+
"-Pbuild.number=${{ steps.config.outputs.build_number }}"
189+
"-PplayDryRun=${{ vars.PLAY_DRY_RUN || 'false' }}"
190+
191+
# Only reached if the publish above succeeded.
192+
- name: Tag the released commit
193+
if: steps.config.outputs.flavor == 'Production' && vars.PLAY_DRY_RUN != 'true'
194+
run: |
195+
TAG="v${{ steps.config.outputs.version }}"
196+
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
197+
if [ "$(git rev-parse "refs/tags/$TAG^{commit}")" = "$(git rev-parse HEAD)" ]; then
198+
echo "Tag $TAG already exists on this commit (re-run); nothing to do."
199+
exit 0
200+
fi
201+
echo "::error::Tag $TAG already exists on a different commit."
202+
exit 1
203+
fi
204+
git tag "$TAG"
205+
git push origin "$TAG"
206+
207+
- name: Clean up credentials
208+
if: always()
209+
run: rm -rf "$HOME/keystore"
210+
211+
- name: Upload APK artifact
212+
uses: actions/upload-artifact@v7
213+
with:
214+
name: bloomreader-${{ steps.config.outputs.flavor }}-${{ steps.config.outputs.build_number }}
215+
path: app/build/outputs/apk/**/release/*.apk
216+
if-no-files-found: warn

README.md

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ At this point, anyone can publish a book using the existing Bloom mechanism, and
104104
## Getting the Bloom Reader code dependencies
105105

106106
BloomReader requires a number of files from the [bloom-player](https://github.com/BloomBooks/bloom-player.git) project. By default,
107-
and in the TeamCity build, these are obtained using yarn from the npm output of bloom-player.
107+
and in the CI build, these are obtained using yarn from the npm output of bloom-player.
108108

109109
When building locally, if you need to make changes to bloom-player, you can use `yarn link`.
110110

@@ -144,9 +144,9 @@ The contents of this file are:
144144

145145
where `storeFile` is an absolute path to `keystore_bloom_reader.keystore`. This file and the other values must be shared with you by a member of the team who has them.
146146

147-
## TeamCity builds (and deploying to the Play Store)
147+
## Deploying to the Play Store
148148

149-
To publish to the Play Store, we use a gradle plugin: `https://github.com/Triple-T/gradle-play-publisher`. To use the plugin, you must add `serviceAccountJsonFile=` to the `.properties` file described above. Set the value as an absolute path to the `Google Play Android Developer-cf6d1afc73be.json` file which you must obtain from a member of the team.
149+
To publish to the Play Store, we use a gradle plugin: `https://github.com/Triple-T/gradle-play-publisher`. To use the plugin locally, you must add `serviceAccountJsonFile=` to the `.properties` file described above. Set the value as an absolute path to the `Google Play Android Developer-cf6d1afc73be.json` file which you must obtain from a member of the team.
150150

151151
Gradle tasks which can be called with the plugin include:
152152

@@ -157,13 +157,61 @@ Gradle tasks which can be called with the plugin include:
157157
- publish{Alpha/Beta/Production}ReleaseListing
158158
- pushes only the listing metadata to the Play Store
159159

160-
TeamCity builds are configured to publish the alpha, beta, and production flavors to three respective apps on the Play Store.
161-
162-
- The alpha build is a continuous publish to the internal test and alpha tracks of the "BR Alpha" app.
163-
- The beta build is a manual publish to the internal test and beta tracks of the "BR Beta" app.
164-
- The production build is a manual publish to the internal test track of the "Bloom Reader" app. Currently, releases need to be promoted to production manually in the Play Console.
165-
166-
The `ba-bloom-win10` (in the Bloom pool) is currently the only agent configured with the `.properties` file described above.
160+
### CI/CD (GitHub Actions)
161+
162+
Publishing is automated by the GitHub Actions workflow
163+
[.github/workflows/play-publish.yml](.github/workflows/play-publish.yml):
164+
165+
- Every push to `master` builds the **alpha** flavor — first upgrading to the
166+
latest `bloom-player@alpha` — runs the full `build` (lint and unit tests for
167+
all variants), publishes to the internal track of the "BR Alpha" Play app,
168+
and then promotes it per the `play {}` config in app/build.gradle.
169+
- Every push to `release` runs lint and unit tests, builds the **production**
170+
flavor (using the bloom-player version pinned in app/yarn.lock), publishes
171+
it to the internal track of the "Bloom Reader" Play app, and tags the commit
172+
`vX.Y.Z`. Promoting from the internal track to production is still a manual
173+
step in the Play Console.
174+
- The workflow can also be dispatched manually from the Actions tab (choose a
175+
flavor; optionally override the patch number). A real publish must be
176+
dispatched from the flavor's own branch; dry runs may run from any branch.
177+
178+
#### Version numbers
179+
180+
`versionMajor` and `versionMinor` are defined in app/build.gradle. CI derives
181+
the patch number (the x in 3.4.x) by counting the commits since the last
182+
commit that changed those two lines. Consequences:
183+
184+
- Bumping the version resets the patch to 0 automatically — to start 3.5,
185+
just change `versionMinor` and commit. (But don't touch those lines for any
186+
other reason, e.g. reformatting: that also resets the patch counter.)
187+
- `master` (alpha) and `release` (production) have independent patch numbers.
188+
- versionCode = major\*100000 + minor\*1000 + patch, so the patch must stay
189+
below 1000; the workflow fails if it would overflow.
190+
- Until the next version bump, alpha patch numbers include a hardcoded +77
191+
offset for continuity with the old TeamCity build counter. It is keyed to
192+
the 3.4 bump commit and expires on its own (see the workflow).
193+
194+
#### The legacy 1.4 APK
195+
196+
Every production release retains the legacy 1.4 APK (versionCode 104001) so
197+
that devices below the current minSdkVersion keep a working app (see
198+
`playConfigs` in app/build.gradle). A production release must never be
199+
published without it: re-adding an APK that targets an old API level may not
200+
be possible under current Play requirements.
201+
202+
#### Secrets and configuration
203+
204+
The workflow requires these GitHub repository secrets:
205+
206+
- `KEYSTORE_BASE64` — base64 of `keystore_bloom_reader.keystore`
207+
- `KEYSTORE_STORE_PASSWORD`, `KEYSTORE_KEY_ALIAS`, `KEYSTORE_KEY_PASSWORD`
208+
the corresponding values from `keystore_bloom_reader.properties`
209+
- `PLAY_SERVICE_ACCOUNT_JSON` — the contents of the service account json file
210+
211+
Setting the repository **variable** `PLAY_DRY_RUN` to `true` makes every run
212+
build, sign, and upload to a Play edit without committing it, so nothing
213+
becomes visible in the Play Console. Use it when verifying changes to the
214+
workflow; delete the variable to resume real publishing.
167215

168216
# Localization
169217

app/build.gradle

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ plugins {
44
}
55

66
// This is the master definition of what version of Bloom Reader we are building
7+
// NOTE: CI derives the patch number by counting commits since the last commit
8+
// that changed the two lines below, so any edit to them (even reformatting)
9+
// resets the published patch number to 0. Touch them only to bump the version.
710
def versionMajor = 3
811
def versionMinor = 4
912
def versionRelease = getVersionRelease()
@@ -108,6 +111,21 @@ android {
108111
// Used for the promote*Artifact tasks
109112
fromTrack = "internal"
110113
promoteTrack = "beta"
114+
115+
// CI passes -PplayDryRun=true to build, sign, and upload to a Play
116+
// edit without committing it, so nothing changes in the Play Console.
117+
commit = findProperty('playDryRun') != 'true'
118+
}
119+
120+
playConfigs {
121+
production {
122+
// Each production release must continue to offer the legacy 1.4
123+
// build (versionCode 104001) so devices below our current
124+
// minSdkVersion keep getting a working app. Retaining it here
125+
// means every publish includes it automatically instead of
126+
// relying on a manual step in the Play Console.
127+
retain.artifacts = [104001L]
128+
}
111129
}
112130

113131
testOptions {
@@ -195,4 +213,4 @@ project.afterEvaluate {
195213
// preBuild.dependsOn copyBloomPlayerAssets
196214
//}
197215
// But, if we do that, we can't overwrite the bloom-player assets temporarily for testing versions under development.
198-
// Instead, TeamCity will need build steps to cd app, yarn, cd .., "gradle copyBloomPlayerAssets"
216+
// Instead, the CI build needs steps to cd app, yarn, cd .., "gradle copyBloomPlayerAssets"

app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "0.1.0",
44
"private": true,
55
"dependencies": {
6-
"//": "Developers need to `yarn upgrade bloom-player` to get the latest, because of yarn.lock. (TeamCity does this automatically.)",
6+
"//": "Developers need to `yarn upgrade bloom-player` to get the latest, because of yarn.lock. (CI alpha builds do this automatically.)",
77
"bloom-player": "^2.20.1"
88
},
99
"scripts": {},

0 commit comments

Comments
 (0)