Skip to content
177 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
The phone app's release workflow installs the app's dependencies as well as Wrangler (EAS reads app.config.ts through the app's own expo), and scripts/mobile-release.mjs trims EXPO_TOKEN, as the build workflow does.80// A token pasted with a newline breaks the request's header, and EAS says
81// only that its GraphQL request failed.
82if (process.env.EXPO_TOKEN) process.env.EXPO_TOKEN = process.env.EXPO_TOKEN.trim();
83
The phone app with your own g1t, on g1t.sh/download until Google Play, and Node 24 (#5)84function run(command, args, options = {}) {
85 const result = spawnSync(command, args, { stdio: options.capture ? ["ignore", "pipe", "inherit"] : "inherit", encoding: "utf8", ...options });
86 if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`);
87 return result.stdout ?? "";
88}
89
90function build() {
91 run("npx", [...EAS, "build", "--platform", "android", "--profile", PROFILE, "--non-interactive", "--wait"], { cwd: APP });
92}
93
94async function fetchBuild(id) {
95 const v = version();
96 const args = id
97 ? ["build:view", id, "--json"]
98 : ["build:list", "--platform", "android", "--build-profile", PROFILE, "--status", "finished", "--limit", "5", "--json", "--non-interactive"];
99 const answer = JSON.parse(run("npx", [...EAS, ...args], { cwd: APP, capture: true }));
100 const chosen = id ? answer : newestBuild(answer);
101 if (!chosen?.artifacts?.buildUrl) throw new Error("no finished preview build: run build first");
102 if (chosen.appVersion && chosen.appVersion !== v) {
103 throw new Error(`the build is version ${chosen.appVersion}, but apps/mobile is ${v}`);
104 }
105 const response = await fetch(chosen.artifacts.buildUrl);
106 if (!response.ok) throw new Error(`downloading the APK failed: ${response.status}`);
107 const dir = outDir(v);
108 rmSync(dir, { recursive: true, force: true });
109 mkdirSync(dir, { recursive: true });
110 writeFileSync(join(dir, apkName(v)), Buffer.from(await response.arrayBuffer()));
111 writeFileSync(join(dir, "build.json"), JSON.stringify({ id: chosen.id, build: chosen.appBuildVersion ?? null }, null, 2));
112 console.log(`fetched build ${chosen.id} (versionCode ${chosen.appBuildVersion ?? "?"}) into ${dir}`);
113}
114
115function writeManifest(notes) {
116 const v = version();
117 const dir = outDir(v);
118 const apk = join(dir, apkName(v));
119 if (!existsSync(apk)) throw new Error("no APK: run fetch first");
120 const meta = existsSync(join(dir, "build.json")) ? JSON.parse(readFileSync(join(dir, "build.json"), "utf8")) : {};
121 const bytes = readFileSync(apk);
122 const files = [{ name: apkName(v), size: statSync(apk).size, sha256: createHash("sha256").update(bytes).digest("hex") }];
123 const { manifest, sums } = manifestFor({ version: v, build: meta.build ?? null, notes, files });
124 writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
125 writeFileSync(join(dir, "latest.json"), JSON.stringify(manifest, null, 2));
126 writeFileSync(join(dir, "SHA256SUMS"), sums);
127 console.log(`${apkName(v)}: ${files[0].size} bytes, sha256 ${files[0].sha256}`);
128}
129
130function publish(dryRun) {
131 const v = version();
132 const dir = outDir(v);
133 if (!existsSync(join(dir, "latest.json"))) throw new Error("no latest.json: run manifest first");
134 const put = (key, file, type) => {
135 const args = ["wrangler", "r2", "object", "put", `${BUCKET}/${key}`, "--file", file, "--remote", "--content-type", type];
136 if (dryRun) console.log(`would put ${key}`);
137 else run("npx", args, { cwd: join(ROOT, "apps/web") });
138 };
139 const type = (name) =>
140 name.endsWith(".apk") ? "application/vnd.android.package-archive" : name.endsWith(".json") ? "application/json" : "text/plain";
141 // The version's files first; latest.json last, so the page never offers a file that is not there yet.
142 for (const name of readdirSync(dir)) {
143 if (name === "latest.json" || name === "build.json") continue;
144 put(`mobile/${v}/${name}`, join(dir, name), type(name));
145 }
146 put("mobile/latest.json", join(dir, "latest.json"), "application/json");
147}
148
149if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/mobile-release.mjs")) {
150 const [command, ...rest] = process.argv.slice(2);
151 const option = (name) => {
152 const at = rest.indexOf(`--${name}`);
153 return at >= 0 ? rest[at + 1] : undefined;
154 };
155 try {
156 switch (command) {
157 case "build":
158 build();
159 break;
160 case "fetch":
161 await fetchBuild(option("build"));
162 break;
163 case "manifest":
164 writeManifest(option("notes") ?? "");
165 break;
166 case "publish":
167 publish(rest.includes("--dry-run"));
168 break;
169 default:
170 console.error("usage: node scripts/mobile-release.mjs build|fetch|manifest|publish");
171 process.exit(2);
172 }
173 } catch (error) {
174 console.error(error instanceof Error ? error.message : error);
175 process.exit(1);
176 }
177}

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