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
28 changes: 28 additions & 0 deletions .github/workflows/build-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ on:
type: choice
options: [release, staging, dev]
default: release
track:
description: Upload the bundle to this Google Play track (release and staging; needs PLAY_SERVICE_ACCOUNT_JSON)
type: choice
options: [none, internal]
default: none
environment:
description: A GitHub environment whose variables and secrets to use (empty = the repository's)
type: string
Expand All @@ -35,6 +40,10 @@ on:
description: release, staging or dev
type: string
default: release
track:
description: Upload the bundle to this Google Play track (none, internal); release and staging only
type: string
default: none
path:
description: Where this repository is checked out in the calling one (a submodule)
type: string
Expand All @@ -57,6 +66,9 @@ on:
required: false
ANDROID_KEY_PASSWORD:
required: false
# Only with a track: the Google Play service account's JSON key (docs/self-hosting.md, Play Store).
PLAY_SERVICE_ACCOUNT_JSON:
required: false

concurrency:
group: openclapper-build-apk-${{ inputs.environment || 'default' }}-${{ inputs.variant || 'release' }}
Expand All @@ -80,6 +92,8 @@ jobs:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
TRACK: ${{ inputs.track || 'none' }}
EXPO_NO_GIT_STATUS: "1"
# Phones are arm64; every other ABI multiplies the native build (and the APK). Variable to override.
ANDROID_ABIS: ${{ vars.ANDROID_ABIS || 'arm64-v8a' }}
Expand All @@ -88,6 +102,10 @@ jobs:
working-directory: .
run: |
case "$VARIANT" in release|staging|dev) ;; *) echo "::error::variant must be release, staging or dev"; exit 1 ;; esac
case "$TRACK" in none) ;; internal)
[ "$VARIANT" != dev ] || { echo "::error::The dev client does not go to Google Play"; exit 1; }
[ -n "$PLAY_SERVICE_ACCOUNT_JSON" ] || { echo "::error::Set the PLAY_SERVICE_ACCOUNT_JSON secret to upload to Google Play (docs/self-hosting.md)"; exit 1; } ;;
*) echo "::error::track must be none or internal"; exit 1 ;; esac
[ "$VARIANT" != dev ] || exit 0 # the development client needs neither a server nor the key
[ -n "$EXPO_PUBLIC_API_URL" ] || { echo "::error::Set the APP_URL variable to your server, e.g. https://openclapper-api.<subdomain>.workers.dev"; exit 1; }
for v in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do
Expand Down Expand Up @@ -225,6 +243,16 @@ jobs:
echo "Every native library is aligned to 16 KB." | tee -a "$GITHUB_STEP_SUMMARY"
fi

# Google Play's own API, no third party (scripts/play-upload.mjs). The package comes from the built
# APK, so it always matches the variant. A version code Play already has is a notice, not a failure.
- name: Upload to Google Play
if: env.AAB_NAME != '' && env.TRACK != 'none'
run: |
tools=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)
package=$("$tools/aapt2" dump badging "$RUNNER_TEMP/$APK_NAME" | sed -n "s/^package: name='\([^']*\)'.*/\1/p")
node scripts/play-upload.mjs --bundle "$RUNNER_TEMP/$AAB_NAME" --package "$package" --track "$TRACK" \
--notes "OpenClapper $(node -p 'require("./package.json").version') ($(git rev-parse --short HEAD))" | tee -a "$GITHUB_STEP_SUMMARY"

- name: Disk
if: always()
working-directory: .
Expand Down
2 changes: 1 addition & 1 deletion docs/infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ The reference instance ran behind Cloudflare Access until 2026-10-19 (epic #107)
## Secrets and rotation

- To cut a user off: block the account in the admin plane (every session ends at once) or reset its password.
- The deploy API token (`CLOUDFLARE_API_TOKEN`, [self-hosting](self-hosting.md#1-a-cloudflare-api-token)) and, optionally, the read-only analytics token for the admin board (`CF_ANALYTICS_TOKEN`, a Worker secret on the admin plane, [monitoring](features/monitoring.md)) are the secrets; Access AUDs, team domains and resource ids are not secret (they show in login redirects and URLs).
- The deploy API token (`CLOUDFLARE_API_TOKEN`, [self-hosting](self-hosting.md#1-a-cloudflare-api-token)) the optional read-only analytics token for the admin board (`CF_ANALYTICS_TOKEN`, a Worker secret on the admin plane, [monitoring](features/monitoring.md)) and, for Play uploads, the service account's JSON key (`PLAY_SERVICE_ACCOUNT_JSON`; rotate by adding a new key to the service account in Google Cloud, replacing the secret, then deleting the old key) are the secrets; Access AUDs, team domains and resource ids are not secret (they show in login redirects and URLs).
- Wrangler's OAuth login lacks `account:read`; an exported `CLOUDFLARE_ACCOUNT_ID` (read by `scripts/deploy-config.mjs`) fills it in.
1 change: 1 addition & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Google Play delivers the app instead of sideloading, starting with the **interna
- **Version codes.** Play takes each version code once per app. Production's grows with each release (`0.10.0` → `10000`, [releasing](releasing.md)), so production uploads once per release. The staging app counts commits instead, so every build of `main` is a new version (it needs the full history, which a run with `ref` has).
- **Signing.** Play signs what it delivers (Play App Signing). On the first upload of each app, choose to upload your own key (the Console's "Export and upload a key from a Java keystore", with the PEPK tool it links), so an APK from Build APK and the app from Play carry the same signature and update each other. With Google's key instead, a sideloaded install has to be removed once before Play can update it.
- **The first bundle of each app** is uploaded by hand in the Play Console: the API can neither create an app nor choose its signing key. Play Console → Create app → the package (`org.openclapper`, `org.openclapper.staging`) → Testing → Internal testing → Create release → the `.aab` from Build APK. Then add the testers' emails and open the opt-in link on each phone once.
- **Uploads from the workflow.** Google Cloud Console → a project → IAM → Service accounts → create one and a JSON key for it. Play Console → Users and permissions → invite the service account's email with "Release to testing tracks" on the app (on each app). Store the JSON as the secret `PLAY_SERVICE_ACCOUNT_JSON` (in each GitHub environment that uploads). Then Build APK with `track: internal` uploads the bundle after the build (`scripts/play-upload.mjs`: Google's androidpublisher API with the service account, no dependency). A version code Play already has is a notice, not a failure. An app that was never published takes draft releases only: the run says so, and you roll the release out in the Console once. A newly invited service account may be refused for up to a day.

## The free plan

Expand Down
91 changes: 91 additions & 0 deletions scripts/play-upload.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env node
// Uploads an Android App Bundle to a Google Play track, with Google's own API (androidpublisher v3)
// and nothing else: no Expo account, no third-party action, no dependency. Run by the Build APK
// workflow with `track` set; like every script here it reads its secret from the environment only.
//
// node scripts/play-upload.mjs --bundle app.aab --package org.openclapper --track internal [--notes "…"] [--status completed|draft]
//
// PLAY_SERVICE_ACCOUNT_JSON the service account's JSON key (its content), invited in the Play Console
// with "Release to testing tracks" on the app (docs/self-hosting.md, Play Store)
//
// A version code that Play already has is not an error: the run says so and ends green, so a release
// built again uploads nothing. An app that was never published only takes draft releases: the script
// then retries as a draft, which the Console rolls out by hand.
import { createSign } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

const SCOPE = "https://www.googleapis.com/auth/androidpublisher";
const API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
const UPLOAD = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications";

const b64url = (data) => Buffer.from(data).toString("base64url");

/** The service account's signed assertion for Google's token endpoint (RFC 7523), valid for an hour. */
export function assertion(account, now = Math.floor(Date.now() / 1000)) {
if (!account?.client_email || !account?.private_key) throw new Error("PLAY_SERVICE_ACCOUNT_JSON has no client_email or private_key: use the service account's JSON key");
const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const claims = b64url(JSON.stringify({ iss: account.client_email, scope: SCOPE, aud: account.token_uri ?? "https://oauth2.googleapis.com/token", iat: now, exp: now + 3600 }));
const signature = createSign("RSA-SHA256").update(`${head}.${claims}`).sign(account.private_key).toString("base64url");
return `${head}.${claims}.${signature}`;
}

/** Play's answer when the bundle's version code is already taken in this app. */
export const alreadyUsed = (message) => /version code \d+ has already been used|already been used/i.test(message);
/** Play's answer when an app that was never published gets a release that is not a draft. */
export const draftOnly = (message) => /only releases with status draft/i.test(message);

async function call(token, method, url, body, headers = {}) {
const res = await fetch(url, { method, headers: { authorization: `Bearer ${token}`, ...(body && !(body instanceof Uint8Array) ? { "content-type": "application/json" } : {}), ...headers }, body: body instanceof Uint8Array ? body : body ? JSON.stringify(body) : undefined });
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (!res.ok) { const e = new Error(json.error?.message ?? `HTTP ${res.status}`); e.status = res.status; throw e; }
return json;
}

async function main() {
const { values: o } = parseArgs({ options: { bundle: { type: "string" }, package: { type: "string" }, track: { type: "string", default: "internal" }, notes: { type: "string" }, status: { type: "string", default: "completed" } } });
if (!o.bundle || !o.package) { console.error("usage: node scripts/play-upload.mjs --bundle app.aab --package <id> [--track internal] [--notes …] [--status completed|draft]"); process.exit(1); }
const raw = process.env.PLAY_SERVICE_ACCOUNT_JSON;
if (!raw) { console.error("play-upload: PLAY_SERVICE_ACCOUNT_JSON is not set (docs/self-hosting.md, Play Store)"); process.exit(1); }
const account = JSON.parse(raw);

const tokenRes = await fetch(account.token_uri ?? "https://oauth2.googleapis.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: assertion(account) }) });
const tokenJson = await tokenRes.json();
if (!tokenRes.ok) throw new Error(`token: ${tokenJson.error_description ?? tokenJson.error ?? tokenRes.status}`);
const token = tokenJson.access_token;

const app = `${API}/${encodeURIComponent(o.package)}`;
const edit = await call(token, "POST", `${app}/edits`, {});
const inEdit = `${app}/edits/${edit.id}`;
try {
let versionCode;
try {
// The bundle as the request body; Play processes it before answering, which may take a minute.
({ versionCode } = await call(token, "POST", `${UPLOAD}/${encodeURIComponent(o.package)}/edits/${edit.id}/bundles?uploadType=media`, new Uint8Array(readFileSync(o.bundle)), { "content-type": "application/octet-stream" }));
} catch (e) {
if (!alreadyUsed(e.message)) throw e;
console.log(`play-upload: ${o.package} already has this version code on Play; nothing uploaded (${e.message})`);
await call(token, "DELETE", inEdit).catch(() => {});
return;
}
const release = (status) => ({ track: o.track, releases: [{ versionCodes: [String(versionCode)], status, ...(o.notes ? { releaseNotes: [{ language: "en-US", text: o.notes.slice(0, 500) }] } : {}) }] });
let status = o.status;
try {
await call(token, "PUT", `${inEdit}/tracks/${o.track}`, release(status));
} catch (e) {
if (status === "draft" || !draftOnly(e.message)) throw e;
status = "draft";
console.log("play-upload: the app was never published, so Play takes draft releases only; roll it out in the Console (Testing → the track)");
await call(token, "PUT", `${inEdit}/tracks/${o.track}`, release(status));
}
await call(token, "POST", `${inEdit}:commit`);
console.log(`play-upload: ${o.package} version code ${versionCode} on the ${o.track} track (${status})`);
} catch (e) {
await call(token, "DELETE", inEdit).catch(() => {});
throw e;
}
}

if (process.argv[1] === fileURLToPath(import.meta.url)) main().catch((e) => { console.error(`play-upload: ${e.message}`); process.exit(1); });
29 changes: 29 additions & 0 deletions scripts/play-upload.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// The service-account assertion and the two Play answers the upload treats specially (scripts/play-upload.mjs).
import assert from "node:assert/strict";
import { createVerify, generateKeyPairSync } from "node:crypto";
import { test } from "node:test";
import { alreadyUsed, assertion, draftOnly } from "./play-upload.mjs";

const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const account = { client_email: "uploader@project.iam.gserviceaccount.com", private_key: privateKey.export({ type: "pkcs8", format: "pem" }), token_uri: "https://oauth2.googleapis.com/token" };
const part = (s) => JSON.parse(Buffer.from(s, "base64url").toString());

test("the assertion is an RS256 JWT for the androidpublisher scope, valid for an hour", () => {
const jwt = assertion(account, 1_800_000_000);
const [head, claims, sig] = jwt.split(".");
assert.ok(!/[+/=]/.test(jwt), "base64url, no padding");
assert.deepEqual(part(head), { alg: "RS256", typ: "JWT" });
assert.deepEqual(part(claims), { iss: account.client_email, scope: "https://www.googleapis.com/auth/androidpublisher", aud: account.token_uri, iat: 1_800_000_000, exp: 1_800_003_600 });
assert.ok(createVerify("RSA-SHA256").update(`${head}.${claims}`).verify(publicKey, Buffer.from(sig, "base64url")));
});

test("a key without the service account's fields is refused with a hint", () => {
assert.throws(() => assertion({ type: "authorized_user" }), /service account's JSON key/);
});

test("Play's answers: a version code already used, and a draft-only app", () => {
assert.ok(alreadyUsed("Version code 10000 has already been used."));
assert.ok(!alreadyUsed("The caller does not have permission"));
assert.ok(draftOnly("Only releases with status draft may be created on draft app."));
assert.ok(!draftOnly("Version code 10000 has already been used."));
});
Loading