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/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/.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 b2a83c8..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,9 +121,9 @@ 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 -`main` releases it to Play's closed testing track ([docs/android.md](docs/android.md#shipping-to-play)). +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 apps: by the time CI runs them, the change is on `main`. @@ -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 8b9cf3c..f471f66 100644 --- a/docs/android.md +++ b/docs/android.md @@ -53,14 +53,37 @@ 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, 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 +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 @@ -90,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 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