Skip to content
173 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)1#!/usr/bin/env node
2// Releases of the phone app (apps/mobile) for Android, published on
3// g1t.sh/download while the app is not in Google Play: built by EAS from
4// the `preview` profile (g1t (beta), sh.g1t.app.preview, a signed release
5// APK that needs no computer), downloaded, described in a manifest, and
6// published to the g1t-downloads R2 bucket that g1t.sh serves at
7// /downloads/mobile/ (apps/web/app/routes/downloads-runner.ts). The
8// download page lists the newest (apps/web/app/routes/download.tsx).
9//
10// Google Play and the App Store publish an organization's apps only once
11// it is verified with a D-U-N-S number; until Flagon's is, this is how the
12// app is given out. Installed copies take most changes over the air
13// (expo-updates, the `preview` channel: `eas update --channel preview`);
14// a release here is for what needs a new install.
15//
16// node scripts/mobile-release.mjs build # EAS builds the APK (about 15 minutes)
17// node scripts/mobile-release.mjs fetch [--build <id>] # downloads the newest finished preview build, or that one
18// node scripts/mobile-release.mjs manifest [--notes "…"] # latest.json and SHA256SUMS from what was fetched
19// node scripts/mobile-release.mjs publish [--dry-run]
20//
21// What a release is, at mobile/<version>/ in the bucket:
22//
23// g1t-<version>-android.apk the app, signed with the upload key EAS keeps
24// SHA256SUMS `sha256 name`
25// manifest.json { version, build, pub_date, notes, downloads }
26//
27// and at mobile/: latest.json, the newest release's manifest. `version` is
28// apps/mobile/app.config.ts's, `build` the Android versionCode EAS gave it.
29//
30// Environment:
31// EXPO_TOKEN for build and fetch (an Expo access token, from expo.dev)
32// CLOUDFLARE_API_TOKEN for publish
33import { spawnSync } from "node:child_process";
34import { createHash } from "node:crypto";
35import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
36import { dirname, join } from "node:path";
37import { fileURLToPath } from "node:url";
38
39const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
40const APP = join(ROOT, "apps/mobile");
41const BUCKET = "g1t-downloads";
42/** The EAS command line, pinned so a release does not change with its newest version. */
43const EAS = ["--yes", "eas-cli@24.12.1"];
44const PROFILE = "preview";
45
46/** The app's version, as app.config.ts says it. */
47export function version() {
48 const config = readFileSync(join(APP, "app.config.ts"), "utf8");
49 const found = /^\s*version:\s*"([^"]+)"/m.exec(config);
50 if (!found) throw new Error("no version in apps/mobile/app.config.ts");
51 return found[1];
52}
53
54/** What a release's APK is called. */
55export function apkName(v) {
56 return `g1t-${v}-android.apk`;
57}
58
59const outDir = (v) => join(ROOT, "target/mobile-release", v);
60
61/**
62 * The manifest for a release folder's files: each one's size and SHA-256,
63 * and SHA256SUMS as `sha256sum` writes it.
64 */
65export function manifestFor({ version: v, build, notes, files, now = new Date() }) {
66 const downloads = files.map((file) => ({ platform: "android", kind: "apk", name: file.name, size: file.size, sha256: file.sha256 }));
67 return {
68 manifest: { version: v, build, pub_date: now.toISOString(), notes: notes || undefined, downloads },
69 sums: files.map((file) => `${file.sha256} ${file.name}`).join("\n") + "\n",
70 };
71}
72
73/** The newest finished build, from `eas build:list --json`, or null. */
74export function newestBuild(list) {
75 const finished = (Array.isArray(list) ? list : []).filter((build) => build?.status === "FINISHED" && build?.artifacts?.buildUrl);
76 finished.sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
77 return finished[0] ?? null;
78}
79
80function run(command, args, options = {}) {
81 const result = spawnSync(command, args, { stdio: options.capture ? ["ignore", "pipe", "inherit"] : "inherit", encoding: "utf8", ...options });
82 if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
83 return result.stdout ?? "";
84}
85
86function build() {
87 run("npx", [...EAS, "build", "--platform", "android", "--profile", PROFILE, "--non-interactive", "--wait"], { cwd: APP });
88}
89
90async function fetchBuild(id) {
91 const v = version();
92 const args = id
93 ? ["build:view", id, "--json"]
94 : ["build:list", "--platform", "android", "--build-profile", PROFILE, "--status", "finished", "--limit", "5", "--json", "--non-interactive"];
95 const answer = JSON.parse(run("npx", [...EAS, ...args], { cwd: APP, capture: true }));
96 const chosen = id ? answer : newestBuild(answer);
97 if (!chosen?.artifacts?.buildUrl) throw new Error("no finished preview build: run build first");
98 if (chosen.appVersion && chosen.appVersion !== v) {
99 throw new Error(`the build is version ${chosen.appVersion}, but apps/mobile is ${v}`);
100 }
101 const response = await fetch(chosen.artifacts.buildUrl);
102 if (!response.ok) throw new Error(`downloading the APK failed: ${response.status}`);
103 const dir = outDir(v);
104 rmSync(dir, { recursive: true, force: true });
105 mkdirSync(dir, { recursive: true });
106 writeFileSync(join(dir, apkName(v)), Buffer.from(await response.arrayBuffer()));
107 writeFileSync(join(dir, "build.json"), JSON.stringify({ id: chosen.id, build: chosen.appBuildVersion ?? null }, null, 2));
108 console.log(`fetched build ${chosen.id} (versionCode ${chosen.appBuildVersion ?? "?"}) into ${dir}`);
109}
110
111function writeManifest(notes) {
112 const v = version();
113 const dir = outDir(v);
114 const apk = join(dir, apkName(v));
115 if (!existsSync(apk)) throw new Error("no APK: run fetch first");
116 const meta = existsSync(join(dir, "build.json")) ? JSON.parse(readFileSync(join(dir, "build.json"), "utf8")) : {};
117 const bytes = readFileSync(apk);
118 const files = [{ name: apkName(v), size: statSync(apk).size, sha256: createHash("sha256").update(bytes).digest("hex") }];
119 const { manifest, sums } = manifestFor({ version: v, build: meta.build ?? null, notes, files });
120 writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
121 writeFileSync(join(dir, "latest.json"), JSON.stringify(manifest, null, 2));
122 writeFileSync(join(dir, "SHA256SUMS"), sums);
123 console.log(`${apkName(v)}: ${files[0].size} bytes, sha256 ${files[0].sha256}`);
124}
125
126function publish(dryRun) {
127 const v = version();
128 const dir = outDir(v);
129 if (!existsSync(join(dir, "latest.json"))) throw new Error("no latest.json: run manifest first");
130 const put = (key, file, type) => {
131 const args = ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type];
132 if (dryRun) console.log(`would put ${key}`);
133 else run("npx", args, { cwd: join(ROOT, "apps/web") });
134 };
135 const type = (name) =>
136 name.endsWith(".apk") ? "application/vnd.android.package-archive" : name.endsWith(".json") ? "application/json" : "text/plain";
137 // The version's files first; latest.json last, so the page never offers a file that is not there yet.
138 for (const name of readdirSync(dir)) {
139 if (name === "latest.json" || name === "build.json") continue;
140 put(`mobile/${v}/${name}`, join(dir, name), type(name));
141 }
142 put("mobile/latest.json", join(dir, "latest.json"), "application/json");
143}
144
145if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/mobile-release.mjs")) {
146 const [command, ...rest] = process.argv.slice(2);
147 const option = (name) => {
148 const at = rest.indexOf(`--${name}`);
149 return at >= 0 ? rest[at + 1] : undefined;
150 };
151 try {
152 switch (command) {
153 case "build":
154 build();
155 break;
156 case "fetch":
157 await fetchBuild(option("build"));
158 break;
159 case "manifest":
160 writeManifest(option("notes") ?? "");
161 break;
162 case "publish":
163 publish(rest.includes("--dry-run"));
164 break;
165 default:
166 console.error("usage: node scripts/mobile-release.mjs build|fetch|manifest|publish");
167 process.exit(2);
168 }
169 } catch (error) {
170 console.error(error instanceof Error ? error.message : error);
171 process.exit(1);
172 }
173}

This file's history is long; its oldest lines are credited to the oldest commit read.