75 lines
4.3 KiB
TypeScript
75 lines
4.3 KiB
TypeScript
// Google Play internal app sharing: upload an app bundle or APK and get an install link for testers, with no track,
|
|
// no review and no version code rules.
|
|
// storebound play share --aab app.aab | --apk app.apk [--dry] [--yes] [--json]
|
|
// Prints file, size, sha256 and package; --dry stops there. Otherwise it asks (the build becomes installable by anyone
|
|
// allowed to use the link, for 60 days), uploads, and prints the share link and the certificate fingerprint of the
|
|
// re-signed build. --json: { downloadUrl, certificateFingerprint, sha256 } (dry: { dry, packageName, file, kind, bytes, sha256 }).
|
|
// Testers must turn on internal app sharing in the Play Store app; uploaders need "Release apps to testing tracks"
|
|
// or a place on the Console's uploader list. See skills/adhoc-distribution/SKILL.md.
|
|
// Sources (checked October 2026): https://developers.google.com/android-publisher/api-ref/rest/v3/internalappsharingartifacts
|
|
// and https://support.google.com/googleplay/android-developer/answer/9844679 (details in cli/lib/play-share.ts).
|
|
import { consoleLink } from '../lib/console-links';
|
|
import { createHash } from 'node:crypto';
|
|
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { confirm, need } from '../lib/ctx';
|
|
import { done, log, printJson, warn } from '../lib/log';
|
|
import { mb, parseShareArtifact, SHARE_HEADERS, type ShareKind, shareProblems, shareUrl } from '../lib/play-share';
|
|
import { playClient } from './client';
|
|
|
|
const TAG = 'share';
|
|
const USAGE = 'usage: storebound play share --aab <app.aab> | --apk <app.apk> [--dry] [--yes] [--json]';
|
|
|
|
async function sha256File(file: string): Promise<string> {
|
|
const h = createHash('sha256');
|
|
for await (const chunk of createReadStream(file)) h.update(chunk as Buffer);
|
|
return h.digest('hex');
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const pkg = need(config.play?.packageName, 'play.packageName');
|
|
const aab = a.get('aab'), apk = a.get('apk');
|
|
if ((!aab && !apk) || (aab && apk) || aab === 'true' || apk === 'true') throw new Error(USAGE);
|
|
const kind: ShareKind = aab ? 'bundle' : 'apk';
|
|
const file = (aab ?? apk)!;
|
|
if (!existsSync(file)) throw new Error(`${file}: no such file`);
|
|
const bytes = statSync(file).size;
|
|
const problems = shareProblems(kind, file, bytes);
|
|
if (problems.length) throw new Error(problems.join('\n'));
|
|
const sha256 = await sha256File(file);
|
|
const json = a.has('json');
|
|
const info = (m: string): void => { if (!json) log(TAG, m); };
|
|
info(`${pkg}: ${kind === 'bundle' ? 'app bundle' : 'APK'} ${file}, ${mb(bytes)}, sha256 ${sha256}`);
|
|
|
|
if (a.has('dry')) {
|
|
info(`dry run: would POST it to internal app sharing (${shareUrl(pkg, kind).replace(/\?.*/, '')}); nothing sent`);
|
|
if (json) printJson({ dry: true, packageName: pkg, file, kind, bytes, sha256 });
|
|
return;
|
|
}
|
|
if (!(await confirm(ctx, `Upload ${file} to Google Play internal app sharing for ${pkg}? Anyone allowed to use the link can install it for 60 days.`))) {
|
|
console.error('not confirmed, nothing uploaded');
|
|
return;
|
|
}
|
|
|
|
const play = await playClient(root, pkg);
|
|
info(`uploading ${mb(bytes)}, this can take minutes`);
|
|
const t0 = Date.now();
|
|
const beat = setInterval(() => info(`still uploading (${Math.round((Date.now() - t0) / 1000)}s)`), 15_000);
|
|
let res: unknown;
|
|
try {
|
|
res = await play.call('POST', shareUrl(pkg, kind), new Uint8Array(readFileSync(file)), { ...SHARE_HEADERS });
|
|
} catch (e) {
|
|
const m = (e as Error).message;
|
|
if (/-> 403/.test(m)) throw new Error(`${m}\n\nThe service account needs "Release apps to testing tracks" for this app, or a place on the internal app sharing uploader list (${consoleLink('play.internal-sharing')}).`);
|
|
throw e;
|
|
} finally { clearInterval(beat); }
|
|
const art = parseShareArtifact(res);
|
|
if (art.sha256 && art.sha256 !== sha256) warn(TAG, `Play reports sha256 ${art.sha256}, the local file is ${sha256}`);
|
|
if (json) { printJson(art); return; }
|
|
done(TAG, 'uploaded');
|
|
console.log(`share link: ${art.downloadUrl}`);
|
|
console.log(`certificate fingerprint (re-signed by Google): ${art.certificateFingerprint}`);
|
|
console.log('Testers: Play Store > Settings > About > tap "Play Store version" 7 times > turn on Internal app sharing, then open the link. The link expires in 60 days.');
|
|
}
|