From e87faf6cc012fb411dde513bd697e82c34111aa7 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 15:54:09 -0600 Subject: [PATCH 1/2] Send each Android merge to internal testing, and closed testing only on request. Every merge that touched `apps/android/` was released to Play's closed testing track. Play reviews each closed release, and a newer submission replaces one still in review, so a day with several merges asked for reviews of builds no tester ever received, and testers could wait on a queue that kept restarting. A push to `main` now releases to the `internal` track, which Play does not review and which reaches testers within minutes. Closed testing gets a build when the workflow is started by hand with the new `closed_testing` box ticked. The input is a checkbox rather than a track name so that `internal` and `alpha` stay the only tracks a run can reach: production is still promoted by hand in Play Console. The docs describe the three steps a build now takes, and note that the internal track has its own tester list. Co-Authored-By: Claude Fable 5.2 --- .github/workflows/android.yml | 35 +++++++++++++++++++++++--------- AGENTS.md | 2 +- docs/android.md | 38 +++++++++++++++++++++++++++-------- docs/deploying.md | 4 ++-- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7de0dd6..7fe13bf 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,7 +1,8 @@ name: Android -# Builds the release bundle and, on `main`, uploads it to Play's closed testing track. The -# unit tests run in ci.yml on every pull request; this workflow is only about the bundle, +# Builds the release bundle and, on `main`, uploads it to Play's internal testing track. +# Closed testing gets a build only when someone starts this workflow by hand and asks for +# it. The unit tests run in ci.yml on every pull request; this workflow is only about the bundle, # and is its own workflow so that a push that changes nothing in the app (a copy change on # the site, say) does not upload a build. See "Shipping to Play" in docs/android.md for the # secrets it reads and the one-off setup in Play Console and GitHub. @@ -18,6 +19,14 @@ on: # Cutting a release build on demand, without waiting for a push. Started on `main` it # publishes; on any other branch it only builds. workflow_dispatch: + inputs: + # A checkbox rather than a free-text track name, so that the only tracks a run can + # reach are the two spelled out in the publish step. A text input would let anyone + # who can start the workflow type `production`. + closed_testing: + description: Send this build to closed testing (Play reviews it) instead of internal testing + type: boolean + default: false # A second push to a pull request makes the first one's build pointless, so don't pay to # finish it. Elsewhere a run that has started always finishes, and runs go one at a time, @@ -124,7 +133,7 @@ jobs: # re-signs it per device; the APK is what `adb install` and a sideload take. # # The repository is public, so anyone signed in to GitHub can download a run's - # artifacts. The default 90 days would leave every closed-testing build open to them; + # artifacts. The default 90 days would leave every testing build open to them; # three is long enough to sideload a build for a quick check, and Play keeps its own # copy of the bundle for anything later. - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -149,25 +158,33 @@ jobs: name=$(grep -m1 'versionName = ' app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/') echo "name=${PUSHTHAT_VERSION_CODE} (${name})" >> "$GITHUB_OUTPUT" + # A merge goes to internal testing, and closed testing only when the run was started by + # hand with the box ticked. Every merge used to go to closed testing, but Play reviews + # each closed release and a newer submission replaces one still in review, so on a day + # with several merges the earlier builds were reviewed for nothing and testers could + # wait on a queue that kept restarting. Internal testing skips that review and has a + # build on phones within minutes, which is what a per-merge build wants; closed + # testing then gets the builds someone chose to put in front of its testers. `inputs` + # is empty on a push, so a push always lands on `internal`. + # # `status: completed` rolls the release out to the track's testers as soon as Play's - # checks pass, so a merge reaches their phones without anyone opening Play Console. + # checks pass, so a build reaches their phones without anyone opening Play Console. # It started as `draft`, which left every build waiting for a person to press the - # button, and closed testing is for getting each build onto testers' phones. - # Production is still promoted by hand in Play Console; nothing here can reach it, - # because the only track named is `alpha`. + # button. Production is still promoted by hand in Play Console; nothing here can + # reach it, because the only tracks named are `internal` and `alpha`. # # `alpha` is the API's name for the default closed testing track: "closed" is not a # track name, and a custom closed track is referenced by the name it was given in Play # Console. `tracks`, plural: the singular `track` still works but warns that it is # going away. - - name: Publish to Play closed testing + - name: Publish to Play if: steps.publishable.outputs.ready == 'true' uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5 with: serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} packageName: com.makerocketgonow.pushthat releaseFiles: apps/android/app/build/outputs/bundle/release/*.aab - tracks: alpha + tracks: ${{ inputs.closed_testing && 'alpha' || 'internal' }} status: completed releaseName: ${{ steps.release_name.outputs.name }} # Written by hand in `apps/android/store/whatsnew/`, not generated from commit diff --git a/AGENTS.md b/AGENTS.md index b2a83c8..da68ca9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,7 @@ functions' suites deliver signed events to the webhook over HTTP and point CI (`.github/workflows/ci.yml`) runs the functions' unit tests, the web app's lint, key vectors and builds, the Android unit tests and the encryption reference's tests on every pull request. `.github/workflows/android.yml` builds the Android release bundle, and on -`main` releases it to Play's closed testing track ([docs/android.md](docs/android.md#shipping-to-play)). +`main` releases it to Play's internal testing track ([docs/android.md](docs/android.md#shipping-to-play)). The emulator suites run only in the deploy that follows a merge, and the Xcode builds not at all, so run them yourself when a change reaches the rules, the functions or the Apple apps: by the time CI runs them, the change is on `main`. diff --git a/docs/android.md b/docs/android.md index 8b9cf3c..3fa2e1f 100644 --- a/docs/android.md +++ b/docs/android.md @@ -53,14 +53,36 @@ Each value can also come from the environment, which is how CI signs: ## Shipping to Play -A push to `main` that touches `apps/android/` builds a signed bundle and uploads it to the -closed testing track and rolls it out to that track's testers (`.github/workflows/android.yml`, -`status: completed`): once Play's checks pass, testers get it without anyone opening Play -Console. Production is not touched; promoting a build there stays a manual release in Play -Console. Run the workflow -from the Actions tab on `main` to cut a build without a push. Pull requests and other -branches run only the `build` job: the tests and an unsigned bundle, which proves the -release path compiles, with no secrets and nothing published. +A build moves through three tracks, and CI only ever starts the first two: + +- **Internal testing, on every merge.** A push to `main` that touches `apps/android/` + builds a signed bundle, uploads it to the internal testing track and rolls it out to that + track's testers (`.github/workflows/android.yml`, `status: completed`). Play does not + review internal releases, so the build is on their phones within minutes and nobody opens + Play Console. +- **Closed testing, when someone asks.** Run the workflow from the Actions tab on `main` + with "Send this build to closed testing" ticked. It builds `main` as it stands, under a + new version code, and releases it to the closed track, where Play reviews it before + testers get it. Promoting an internal release in Play Console does the same with the + exact bundle the internal testers had. +- **Production, by hand.** Promote a closed testing release in Play Console (Closed + testing, the release, Promote release, Production), where Play reviews it again. The + workflow names only `internal` and `alpha`, so nothing in CI can reach production. + +Every merge used to go to closed testing. Play reviews each closed release, and a newer +submission replaces one still in review, so several merges in a day meant reviews of builds +no tester ever received. Closed testing is now for the builds worth a review. + +The internal track has its own tester list in Play Console (Internal testing, Testers), of +up to 100 people, separate from the closed track's: someone on only the closed list gets +nothing from a merge. + +Running the workflow from the Actions tab on `main` without the box ticked cuts an internal +build without a push. A run started by hand waits behind a build in progress like any +other, and GitHub keeps only the newest waiting run, so a merge that lands while it waits +displaces it: start it again. Pull requests and other branches run only the `build` job: +the tests and an unsigned bundle, which proves the release path compiles, with no secrets +and nothing published. Don't build a bundle by hand for Play. A local build keeps `versionCode = 8`, which Play has already seen; CI passes the workflow's run number plus 100, which only rises. Never lower diff --git a/docs/deploying.md b/docs/deploying.md index 23e1d9f..1381756 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -4,8 +4,8 @@ A push to `main` deploys itself. The CI workflow (`.github/workflows/ci.yml`) ru checks, and when they pass it deploys what the push changed to the `push-that` Firebase project: the functions when `functions/` changed, and the site with the Firestore and Storage rules when `apps/web/` changed. A change to `firebase.json` or `.firebaserc` -deploys both. The Android app has its own workflow, which uploads a draft to Play's closed -testing track (see [android.md](android.md#shipping-to-play)). Nothing else ships from CI: +deploys both. The Android app has its own workflow, which releases each build to Play's +internal testing track (see [android.md](android.md#shipping-to-play)). Nothing else ships from CI: the Chrome extension and the Apple apps are still released by hand. ## What a deploy runs From 7d3d53673039d5a00b46fd7bdffb15b99f455a36 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Thu, 12 Nov 2026 16:04:40 -0600 Subject: [PATCH 2/2] Add a workflow that promotes an Android build to production. Releasing to production meant opening Play Console and promoting a release by hand, which is awkward away from a desk and offers no staged rollout without more clicking. "Android promote" is started by hand on `main` and asks Play to release a bundle it already has: the newest build on internal testing, or a named version code, to a percentage of production. Running it again with a larger percentage widens the rollout. Nothing is rebuilt, so production gets the build testers ran, with the name and note it already had. It is its own workflow so that `android.yml`, which runs on every merge, still cannot name the production track. Its key lives on a new `play-production` Environment that wants a reviewer's approval, so starting the run is a request and approving it is the release. The work is done by `.github/scripts/play-promote.mjs`, plain Node with no dependencies, so the production key is only ever handled by code in this repository. It lists Play's tracks instead of asking for them by name, because Google's reference names the internal track `qa` where the upload tools call it `internal`. Its tests run against a stand-in for Play, in a new `play-promote` CI job and again in the workflow before the release step. Co-Authored-By: Claude Fable 5.2 --- .github/scripts/play-promote.mjs | 197 ++++++++++++++++++++++++++ .github/scripts/play-promote.test.mjs | 164 +++++++++++++++++++++ .github/workflows/android-promote.yml | 64 +++++++++ .github/workflows/ci.yml | 12 ++ AGENTS.md | 11 +- docs/android.md | 53 ++++++- 6 files changed, 495 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/play-promote.mjs create mode 100644 .github/scripts/play-promote.test.mjs create mode 100644 .github/workflows/android-promote.yml diff --git a/.github/scripts/play-promote.mjs b/.github/scripts/play-promote.mjs new file mode 100644 index 0000000..b3f4700 --- /dev/null +++ b/.github/scripts/play-promote.mjs @@ -0,0 +1,197 @@ +// Releases a build Play already has to the production track, for +// .github/workflows/android-promote.yml. Nothing is built or uploaded: the version code +// named is one a merge already sent to a testing track, so production gets the exact +// bundle testers ran, not a rebuild of whatever `main` has become since. +// +// Plain Node with no dependencies, on purpose. The alternatives were fastlane's `supply` +// and the Gradle Play Publisher plugin, each a dependency tree to keep current for the sake +// of four HTTP calls, and an action to mint the token, which would be one more entry on the +// repository's Actions allow-list with the production key in its hands. Signing the token +// request here keeps that key inside code this repository owns. + +import { createSign } from 'node:crypto'; +import { appendFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications'; +// Fixed, not read from the key's own `token_uri`: the signed assertion is as good as the +// key for an hour, so where it is sent is not something the key file gets to decide. +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const SCOPE = 'https://www.googleapis.com/auth/androidpublisher'; + +const PRODUCTION = 'production'; +// The upload action, fastlane and Play Console's own URLs call the internal testing track +// `internal`; Google's API reference lists its name as `qa`. Which of the two a listing +// returns is not something to find out on a production release, so both are recognised +// and neither is ever asked for by name: the tracks are listed and looked through. +const INTERNAL = ['internal', 'qa']; + +const base64url = (value) => Buffer.from(value).toString('base64url'); + +/** The signed request a service account trades for an access token (RFC 7523). */ +export function tokenAssertion(key, now = Date.now()) { + const issued = Math.floor(now / 1000); + const unsigned = [ + base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })), + base64url( + JSON.stringify({ + iss: key.client_email, + scope: SCOPE, + aud: TOKEN_URL, + iat: issued, + exp: issued + 600, + }), + ), + ].join('.'); + const signature = createSign('RSA-SHA256').update(unsigned).sign(key.private_key); + return `${unsigned}.${base64url(signature)}`; +} + +async function accessToken(key, fetchImpl) { + const response = await fetchImpl(TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: tokenAssertion(key), + }), + }); + if (!response.ok) { + throw new Error(`Google refused the service account (${response.status}): ${await response.text()}`); + } + return (await response.json()).access_token; +} + +/** A `request(method, path, body)` bound to one app, for `promote`. */ +export async function playClient(key, packageName, fetchImpl = fetch) { + const token = await accessToken(key, fetchImpl); + return async (method, path, body) => { + const response = await fetchImpl(`${API}/${packageName}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body ? { 'content-type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + throw new Error(`Play refused ${method} ${path} (${response.status}): ${await response.text()}`); + } + return response.json(); + }; +} + +/** + * A whole percentage from 1 to 100. Play's own field is a fraction strictly between 0 and + * 1, with 100% written as a different status instead, which is an easy thing to get wrong + * by hand: 0.1 and 10 are both plausible things to type for "ten percent". + */ +export function parseRollout(text) { + const percent = Number(text); + if (!/^\d+$/.test(String(text).trim()) || percent < 1 || percent > 100) { + throw new Error(`The rollout is a whole percentage from 1 to 100, not "${text}".`); + } + return percent; +} + +const codesOf = (track) => (track.releases ?? []).flatMap((release) => release.versionCodes ?? []); + +const newest = (codes) => codes.reduce((a, b) => (BigInt(b) > BigInt(a) ? b : a)); + +/** + * Releases `versionCode` (or, when none is given, the newest build on internal testing) to + * `percent` of production. Returns what it did, for the run's summary. + */ +export async function promote({ request, versionCode, percent }) { + const edit = await request('POST', '/edits'); + + // Every track in one call, each with the releases it is serving. Form factor tracks + // (`wear:production`) are left out: this app has none, and a phone build is not what + // one of them would want promoted. + const listed = ((await request('GET', `/edits/${edit.id}/tracks`)).tracks ?? []).filter( + (track) => !track.track.includes(':'), + ); + // Production first, so that running again with a larger percentage finds the rollout + // already under way and widens it, keeping its name and note, instead of starting over + // from the copy on a testing track. + const tracks = [...listed.filter((t) => t.track === PRODUCTION), ...listed.filter((t) => t.track !== PRODUCTION)]; + + let code = versionCode ? String(versionCode).trim() : ''; + if (!code) { + const internal = tracks.filter((t) => INTERNAL.includes(t.track)).flatMap(codesOf); + if (internal.length === 0) { + throw new Error('Internal testing has no build to promote. Name a version code.'); + } + code = newest(internal); + } + + let from; + let source; + for (const track of tracks) { + source = (track.releases ?? []).find((release) => (release.versionCodes ?? []).includes(code)); + if (source) { + from = track.track; + break; + } + } + // Play would accept any code it has ever been sent, but a build no track is serving has + // no note to carry over and nobody currently running it. Refusing is also what catches a + // mistyped code before it reaches production rather than after. + if (!source) { + const available = tracks.map((t) => `${t.track}: ${codesOf(t).join(', ') || 'none'}`).join('; '); + throw new Error(`No track is serving version code ${code} (${available || 'no tracks'}). Promote an older build in Play Console.`); + } + if (from === PRODUCTION && source.status === 'completed') { + throw new Error(`Version code ${code} is already released to all of production.`); + } + + const release = { + name: source.name, + versionCodes: [code], + releaseNotes: source.releaseNotes, + // `userFraction` may only accompany `inProgress`, and must be below 1: everyone is + // `completed` with no fraction at all. + ...(percent === 100 ? { status: 'completed' } : { status: 'inProgress', userFraction: percent / 100 }), + }; + + // Only the new release is listed. The build production already serves in full stays for + // everyone outside the fraction without being named: `releases` in an update is the + // desired change, not the whole track, and Google's own staged rollout examples send + // exactly this. + await request('PUT', `/edits/${edit.id}/tracks/${PRODUCTION}`, { track: PRODUCTION, releases: [release] }); + + // Nothing above is real until this call: an edit is a draft on Play's side, and one that + // is never committed (because a step above threw) simply expires. Committing is what + // sends the release to Play's review. + await request('POST', `/edits/${edit.id}:commit`); + + return { code, from, percent, name: source.name }; +} + +async function main() { + const { PLAY_SERVICE_ACCOUNT_JSON, PLAY_PACKAGE_NAME, VERSION_CODE, ROLLOUT_PERCENT, GITHUB_STEP_SUMMARY } = + process.env; + if (!PLAY_SERVICE_ACCOUNT_JSON) { + throw new Error('No PLAY_SERVICE_ACCOUNT_JSON on the `play-production` Environment: nothing to publish with.'); + } + if (!PLAY_PACKAGE_NAME) throw new Error('PLAY_PACKAGE_NAME is not set.'); + // Before anything is sent, so a typo costs nothing. + const percent = parseRollout(ROLLOUT_PERCENT); + + const request = await playClient(JSON.parse(PLAY_SERVICE_ACCOUNT_JSON), PLAY_PACKAGE_NAME); + const done = await promote({ request, versionCode: VERSION_CODE, percent }); + + const line = + done.from === PRODUCTION + ? `Widened the production rollout of ${done.name ?? done.code} to ${done.percent}%.` + : `Sent ${done.name ?? done.code} from ${done.from} to ${done.percent}% of production. Play reviews it before anyone gets it.`; + console.log(line); + if (GITHUB_STEP_SUMMARY) appendFileSync(GITHUB_STEP_SUMMARY, `${line}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/.github/scripts/play-promote.test.mjs b/.github/scripts/play-promote.test.mjs new file mode 100644 index 0000000..ea3f531 --- /dev/null +++ b/.github/scripts/play-promote.test.mjs @@ -0,0 +1,164 @@ +// Run with `node --test '.github/scripts/*.test.mjs'`. Play is a stand-in throughout: these prove what +// the script asks Play for, not that Play agrees, which only a real promotion can. + +import assert from 'node:assert/strict'; +import { createVerify, generateKeyPairSync } from 'node:crypto'; +import { test } from 'node:test'; + +import { parseRollout, playClient, promote, tokenAssertion } from './play-promote.mjs'; + +const NOTES = [{ language: 'en-US', text: 'Something a tester would notice.' }]; + +/** A Play with the given tracks, recording every call made to it. */ +function fakePlay(tracks) { + const calls = []; + const request = async (method, path, body) => { + calls.push({ method, path, body }); + if (method === 'POST' && path === '/edits') return { id: 'edit-1' }; + if (method === 'GET' && path === '/edits/edit-1/tracks') { + return { tracks: Object.entries(tracks).map(([track, releases]) => ({ track, releases })) }; + } + const track = path.match(/^\/edits\/edit-1\/tracks\/(.+)$/)?.[1]; + if (method === 'PUT' && track) return body; + if (method === 'POST' && path === '/edits/edit-1:commit') return { id: 'edit-1' }; + throw new Error(`Unexpected ${method} ${path}`); + }; + return { calls, request }; +} + +const update = (calls) => calls.find((call) => call.method === 'PUT'); +const committed = (calls) => calls.some((call) => call.path.endsWith(':commit')); + +test('the newest internal build goes to a fraction of production, with its name and note', async () => { + const play = fakePlay({ + internal: [ + { name: '141 (1.2.0)', versionCodes: ['141'], status: 'completed', releaseNotes: NOTES }, + { name: '142 (1.2.0)', versionCodes: ['142'], status: 'completed', releaseNotes: NOTES }, + ], + }); + + const done = await promote({ request: play.request, versionCode: '', percent: 10 }); + + assert.deepEqual(done, { code: '142', from: 'internal', percent: 10, name: '142 (1.2.0)' }); + assert.equal(update(play.calls).path, '/edits/edit-1/tracks/production'); + assert.deepEqual(update(play.calls).body, { + track: 'production', + releases: [ + { name: '142 (1.2.0)', versionCodes: ['142'], releaseNotes: NOTES, status: 'inProgress', userFraction: 0.1 }, + ], + }); + assert.equal(play.calls.at(-1).path, '/edits/edit-1:commit'); +}); + +test('newest is by number, not by text', async () => { + const play = fakePlay({ internal: [{ versionCodes: ['99'] }, { versionCodes: ['100'] }] }); + assert.equal((await promote({ request: play.request, percent: 5 })).code, '100'); +}); + +test('100 percent is a completed release with no fraction', async () => { + const play = fakePlay({ internal: [{ name: '142 (1.2.0)', versionCodes: ['142'], releaseNotes: NOTES }] }); + + await promote({ request: play.request, versionCode: '142', percent: 100 }); + + const [release] = update(play.calls).body.releases; + assert.equal(release.status, 'completed'); + assert.equal('userFraction' in release, false); +}); + +test('a rollout under way is widened where it is, not restarted from a testing track', async () => { + // Listed after internal, which still holds the same build under an older note: the + // copy on production is the one to carry forward. + const play = fakePlay({ + internal: [{ name: '142 (1.2.0)', versionCodes: ['142'], releaseNotes: [] }], + production: [ + { name: '120 (1.1.0)', versionCodes: ['120'], status: 'completed' }, + { name: '142 (1.2.0)', versionCodes: ['142'], status: 'inProgress', userFraction: 0.1, releaseNotes: NOTES }, + ], + }); + + const done = await promote({ request: play.request, versionCode: '142', percent: 50 }); + + assert.equal(done.from, 'production'); + assert.deepEqual(update(play.calls).body.releases, [ + { name: '142 (1.2.0)', versionCodes: ['142'], releaseNotes: NOTES, status: 'inProgress', userFraction: 0.5 }, + ]); +}); + +test('the internal track is found under the name Google documents for it too', async () => { + const play = fakePlay({ qa: [{ versionCodes: ['142'] }], alpha: [{ versionCodes: ['150'] }] }); + assert.deepEqual(await promote({ request: play.request, percent: 10 }), { code: '142', from: 'qa', percent: 10, name: undefined }); +}); + +test('a build only closed testing has can be promoted', async () => { + const play = fakePlay({ internal: [{ versionCodes: ['150'] }], alpha: [{ versionCodes: ['142'] }] }); + assert.equal((await promote({ request: play.request, versionCode: '142', percent: 10 })).from, 'alpha'); +}); + +test('a version code no track is serving is refused before anything changes', async () => { + const play = fakePlay({ internal: [{ versionCodes: ['142'] }] }); + + await assert.rejects(promote({ request: play.request, versionCode: '124', percent: 10 }), /No track is serving version code 124.*internal: 142/); + assert.equal(update(play.calls), undefined); + assert.equal(committed(play.calls), false); +}); + +test('a build already released to everyone is refused', async () => { + const play = fakePlay({ production: [{ versionCodes: ['142'], status: 'completed' }] }); + + await assert.rejects(promote({ request: play.request, versionCode: '142', percent: 10 }), /already released/); + assert.equal(committed(play.calls), false); +}); + +test('an empty internal track with no version code named is refused', async () => { + const play = fakePlay({}); + await assert.rejects(promote({ request: play.request, percent: 10 }), /no build to promote/); +}); + +test('the rollout is a whole percentage from 1 to 100', () => { + assert.equal(parseRollout('10'), 10); + assert.equal(parseRollout('100'), 100); + for (const wrong of ['0', '101', '0.1', '10%', '', 'ten', '-5']) { + assert.throws(() => parseRollout(wrong), /whole percentage/, wrong); + } +}); + +test('the token request is signed by the key and asks only for the Play scope', async () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = { + client_email: 'publisher@example.iam.gserviceaccount.com', + private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }), + }; + + const [header, claims, signature] = tokenAssertion(key, 1_700_000_000_000).split('.'); + + assert.equal( + createVerify('RSA-SHA256').update(`${header}.${claims}`).verify(publicKey, Buffer.from(signature, 'base64url')), + true, + ); + assert.deepEqual(JSON.parse(Buffer.from(claims, 'base64url')), { + iss: 'publisher@example.iam.gserviceaccount.com', + scope: 'https://www.googleapis.com/auth/androidpublisher', + aud: 'https://oauth2.googleapis.com/token', + iat: 1_700_000_000, + exp: 1_700_000_600, + }); +}); + +test('calls carry the token, and a refusal from Play names the call and stops', async () => { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = { client_email: 'publisher@example.iam.gserviceaccount.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }) }; + const seen = []; + const fetchImpl = async (url, init) => { + seen.push({ url: String(url), init }); + if (String(url) === 'https://oauth2.googleapis.com/token') return Response.json({ access_token: 'token-1' }); + if (String(url).endsWith('/edits')) return Response.json({ id: 'edit-1' }); + return new Response('{"error":"nope"}', { status: 403 }); + }; + + const request = await playClient(key, 'com.example.app', fetchImpl); + assert.deepEqual(await request('POST', '/edits'), { id: 'edit-1' }); + assert.equal(seen[1].url, 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/edits'); + assert.equal(seen[1].init.headers.authorization, 'Bearer token-1'); + + await assert.rejects(request('GET', '/edits/edit-1/tracks'), /Play refused GET \/edits\/edit-1\/tracks \(403\)/); +}); diff --git a/.github/workflows/android-promote.yml b/.github/workflows/android-promote.yml new file mode 100644 index 0000000..ec89566 --- /dev/null +++ b/.github/workflows/android-promote.yml @@ -0,0 +1,64 @@ +name: Android promote + +# Releases a build Play already has to production, started by hand from the Actions tab. +# Nothing is built here: android.yml puts every merge on the internal testing track, and +# this moves one of those bundles on, so production gets the bundle testers ran. +# +# Its own workflow, not a third option in android.yml, so that the workflow which runs on +# every merge still cannot name the production track however it is started. See "Promoting +# to production" in docs/android.md for the one-off setup. +on: + workflow_dispatch: + inputs: + version_code: + description: Version code to release. Empty means the newest build on internal testing + type: string + required: false + rollout: + description: Percentage of production users, 1 to 100. Run again with a larger number to widen it + type: string + default: '10' + +# One promotion at a time, and one that has started always finishes: a cancelled run could +# stop between changing the track and committing, which is harmless (the edit expires) but +# leaves nobody sure what happened. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # The Play key this job reads lives on the `play-production` Environment, which accepts + # only `main` and has a required reviewer, so starting the workflow is a request and the + # approval is the release. That is why this is not the `play` Environment android.yml + # uses: that one has to let a merge publish with nobody watching. + # + # GitHub creates an Environment that a job names and nobody has set up, with no rules at + # all. That is safe here only because a new Environment has no secrets either, and the + # script stops when the key is missing: create the Environment with its reviewer before + # putting the key on it, never the other way round. + promote: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: play-production + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + + # Run here as well as in ci.yml, with no secret in reach, so the script that is about + # to touch production is one whose own commit passed. + - run: node --test '.github/scripts/*.test.mjs' + + # The inputs go in as environment variables, not into the command line: text typed + # into a form is never spliced into a shell that also holds the key. + - name: Release to production + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + PLAY_PACKAGE_NAME: com.makerocketgonow.pushthat + VERSION_CODE: ${{ inputs.version_code }} + ROLLOUT_PERCENT: ${{ inputs.rollout }} + run: node .github/scripts/play-promote.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fc218b..3dd12a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,18 @@ jobs: - run: node --check index.js - run: npm test + # The script android-promote.yml releases to production with. That workflow runs only + # when someone starts it, so without this a broken change to the script would be found + # at the moment of a release. No install: the script has no dependencies. + play-promote: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + - run: node --test '.github/scripts/*.test.mjs' + # Which of the two deploys this run makes. A push deploys a folder when it changed since # the last push to `main` whose run succeeded: that commit is known to be live, and a run # that failed (a check, or a deploy) leaves its changes to be picked up by the next one. diff --git a/AGENTS.md b/AGENTS.md index da68ca9..f92c854 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,9 @@ xcodebuild -project PushThat.xcodeproj -scheme PushThat -destination 'platform=m # packages/push-test (sending needs a real key, so the send script is only parsed) npm ci && node --check index.js && npm test + +# .github/scripts (from the repository root; no install) +node --test '.github/scripts/*.test.mjs' ``` `npm run test:rules` and `npm run test:emulator` run against the Firestore and Storage @@ -118,8 +121,8 @@ functions' suites deliver signed events to the webhook over HTTP and point `STRIPE_API_URL` at a stand-in for Stripe's API, so nothing reaches Stripe. CI (`.github/workflows/ci.yml`) runs the functions' unit tests, the web app's lint, key -vectors and builds, the Android unit tests and the encryption reference's tests on every -pull request. `.github/workflows/android.yml` builds the Android release bundle, and on +vectors and builds, the Android unit tests, the encryption reference's tests and the +production promote script's tests on every pull request. `.github/workflows/android.yml` builds the Android release bundle, and on `main` releases it to Play's internal testing track ([docs/android.md](docs/android.md#shipping-to-play)). The emulator suites run only in the deploy that follows a merge, and the Xcode builds not at all, so run them yourself when a change reaches the rules, the functions or the Apple @@ -150,5 +153,7 @@ else is refused before it starts. A secret that signs or publishes lives on an Environment that accepts only `main`, never on the repository: a pull request runs its own copy of the workflow, so a repository secret is one any branch can read. The Android release job uses the `play` Environment -([docs/android.md](docs/android.md#shipping-to-play)). A job that needs such a secret +([docs/android.md](docs/android.md#shipping-to-play)), and the job that releases to +production uses `play-production`, which also wants a reviewer's approval +([docs/android.md](docs/android.md#promoting-to-production)). A job that needs such a secret declares the Environment, and a job that runs on pull requests holds none. diff --git a/docs/android.md b/docs/android.md index 3fa2e1f..f471f66 100644 --- a/docs/android.md +++ b/docs/android.md @@ -65,9 +65,10 @@ A build moves through three tracks, and CI only ever starts the first two: new version code, and releases it to the closed track, where Play reviews it before testers get it. Promoting an internal release in Play Console does the same with the exact bundle the internal testers had. -- **Production, by hand.** Promote a closed testing release in Play Console (Closed - testing, the release, Promote release, Production), where Play reviews it again. The - workflow names only `internal` and `alpha`, so nothing in CI can reach production. +- **Production, when someone approves it.** Run the "Android promote" workflow, or promote + a release in Play Console. Either way Play reviews it again. See + [Promoting to production](#promoting-to-production). `android.yml` names only `internal` + and `alpha`, so a merge cannot reach production. Every merge used to go to closed testing. Play reviews each closed release, and a newer submission replaces one still in review, so several merges in a day meant reviews of builds @@ -112,6 +113,52 @@ back on the repository and reopens that, so set them as: gh secret set ANDROID_KEY_PASSWORD --env play --repo MakeRocketGoNow/pushthat ``` +## Promoting to production + +Run "Android promote" from the Actions tab on `main` (`.github/workflows/android-promote.yml`). +It builds nothing: it asks Play to release a bundle it already has, so production gets the +build testers ran. Two inputs: + +- **Version code.** Empty means the newest build on internal testing. Otherwise any code a + track is serving now, internal or closed. An older build that no track serves any more + is promoted in Play Console instead. +- **Rollout.** A whole percentage, 10 unless changed. Run the workflow again with the same + version code and a larger number to widen the rollout, and with 100 to finish it. + +The run then waits for approval, and approving it is the release. The release keeps the +name and the "What's new" note it had on the track it came from. Play reviews it before +anyone gets it, and a commit to Play cancels a review already under way for this app and +submits everything together, so promoting while a closed testing release is in review +restarts that review. + +From a terminal: + +```bash +gh workflow run android-promote.yml --ref main -f rollout=10 +``` + +Play Console does the same by hand (Internal testing or Closed testing, the release, +Promote release, Production), and is the only way to halt a rollout. + +One-off setup, in this order: + +1. Create the `play-production` Environment (Settings, Environments) with a required + reviewer and Deployment branches limited to `main`. +2. Only then put the key on it: + `gh secret set PLAY_SERVICE_ACCOUNT_JSON --env play-production --repo MakeRocketGoNow/pushthat`. + The same service account as the `play` Environment works if it may release to + production in Play Console (Users and permissions); a second account that only this + Environment holds keeps the per-merge key unable to. + +The order matters because GitHub creates an Environment the first time a job names it, +with no rules. Without the key the run stops before it reaches Play, so an Environment +that exists but is unprotected is harmless only while it is empty. + +The work is done by `.github/scripts/play-promote.mjs`, which has no dependencies and +whose tests (`node --test '.github/scripts/*.test.mjs'`) run on every pull request. They +prove what the script asks Play for against a stand-in; only a real promotion proves Play +agrees. + ## Release notes `store/whatsnew/whatsnew-en-US` is what testers see under "What's new". The workflow passes