// 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 | --apk [--dry] [--yes] [--json]'; async function sha256File(file: string): Promise { 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 { 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.'); }