Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions .github/scripts/play-promote.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
}
164 changes: 164 additions & 0 deletions .github/scripts/play-promote.test.mjs
Original file line number Diff line number Diff line change
@@ -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\)/);
});
Loading
Loading