Skip to content

Commit

The phone app is given out on g1t.sh/download until it is in Google Play, and g1t is built on Node 24.

Google Play and the App Store publish a company's apps only once it is verified with a D-U-N-S number, which Flagon is waiting on. Until then the download page's On your phone offers the Android app's newest release, the preview build (g1t (beta), a signed release APK that needs no computer): Download for Android, a QR code to fetch it from a computer, its SHA-256, how Android asks to allow the install, and why it is a download and not a store. A self-hosted g1t's page sends people to g1t.sh/download for the app, beside its own connect code. A release is a tag mobile-v<version>: .g1t/workflows/mobile-release.yml has EAS build the preview APK and publishes it to g1t.sh/downloads/mobile/<version>/ with mobile/latest.json last, which the page reads; scripts/mobile-release.mjs does the same by hand (build, fetch, manifest, publish), with tests, in CI beside the desktop's. The downloads route serves the APK as one, saved rather than shown. expo-updates is in, on the preview channel, so installed copies take most changes over the air (eas update) with no new install, and preview builds count their own versionCode so each release installs over the last. Node 24 is the version: .nvmrc, engines (>=24, as the self-hosted image already ran), EAS builds on 24.21.0, and the README, deploying and the app's README say so. Every check was run on 24.21.0 from a clean install. The download page's SHA-256 is shown whole rather than in a native title, as the site's hints rule asks. The phone app guide says how to install it, why it is a download, how updates arrive and how a release is made; CONTRIBUTING's Deploying has the phone app beside the desktop app.

Claude-Session:
https://claude.ai/code/session_01MQE64a4WNN14F8GELCiuc9
Claude and Claude Opus 5.5committed Parent5780a76Browse files
16 files+514−410/16 viewed
+2−2
117117 # tested here, built into installers by desktop-release.yml.
118118 - name: Check and test
119119 run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --quiet
120− - name: The release script's tests
121− run: node --test scripts/desktop-release.test.mjs
120+ - name: The release scripts' tests
121+ run: node --test scripts/desktop-release.test.mjs scripts/mobile-release.test.mjs
+74−0
1+# Releases the phone app for Android (apps/mobile) on g1t.sh/download,
2+# while it is not in Google Play: EAS builds the `preview` profile (g1t
3+# (beta), a signed release APK), and the APK is published to
4+# g1t.sh/downloads/mobile/ (the g1t-downloads R2 bucket), with latest.json
5+# pointed at it. Google Play and the App Store wait on Flagon's D-U-N-S
6+# number; until then this is how the app is given out.
7+#
8+# A release is a tag `mobile-v<version>`, where the version is the one in
9+# apps/mobile/app.config.ts; or run it by hand, optionally naming a build
10+# EAS already made. scripts/mobile-release.mjs does the work.
11+#
12+# Most changes reach installed apps without a release: `eas update
13+# --channel preview` (expo-updates). A release is for a change to the
14+# app's native side (a new module, app.config.ts) or its version.
15+#
16+# Needs the repository secret EXPO_TOKEN (an Expo access token with access
17+# to the g1t project), CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as the
18+# deploy has them.
19+name: Mobile release
20+
21+on:
22+ push:
23+ tags: ["mobile-v*"]
24+ workflow_dispatch:
25+ inputs:
26+ build:
27+ description: "An EAS build id to publish instead of building again"
28+ type: string
29+ notes:
30+ description: "A line about the release, shown in the manifest"
31+ type: string
32+
33+permissions:
34+ contents: read
35+
36+concurrency:
37+ group: mobile-release
38+ cancel-in-progress: false
39+
40+env:
41+ CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
42+ WRANGLER_SEND_METRICS: "false"
43+
44+jobs:
45+ release:
46+ name: Build and publish to g1t.sh/downloads/mobile
47+ runs-on: ubuntu-latest
48+ environment: production
49+ timeout-minutes: 60
50+ steps:
51+ - uses: actions/checkout@v5
52+ - name: The tag names this version
53+ run: |
54+ version="$(sed -n 's/^\s*version: "\(.*\)",/\1/p' apps/mobile/app.config.ts | head -1)"
55+ if [ "${GITHUB_REF_TYPE}" = tag ] && [ "mobile-v$version" != "${GITHUB_REF_NAME}" ]; then
56+ echo "::error::The tag is ${GITHUB_REF_NAME}, but apps/mobile is $version"; exit 1
57+ fi
58+ - name: Install Wrangler
59+ run: npm ci --workspaces=false --no-audit --no-fund
60+ - name: Build on EAS
61+ if: ${{ !inputs.build }}
62+ env:
63+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
64+ run: node scripts/mobile-release.mjs build
65+ - name: Fetch the APK
66+ env:
67+ EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
68+ run: node scripts/mobile-release.mjs fetch ${{ inputs.build && format('--build {0}', inputs.build) || '' }}
69+ - name: The manifest
70+ run: node scripts/mobile-release.mjs manifest --notes "${{ inputs.notes }}"
71+ - name: Publish
72+ env:
73+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
74+ run: node scripts/mobile-release.mjs publish
+1−0
1+24
+10−0
109109 same by hand. The app loads the live site, so a change to the site needs
110110 no release; only a change under `apps/desktop` does. The guide is
111111 [The desktop app](https://docs.g1t.sh/guides/desktop/).
112+- **The phone app** for Android is released on g1t.sh/download while it
113+ is not in Google Play (that waits on Flagon's D-U-N-S number): bump
114+ `version` in `apps/mobile/app.config.ts` when its native side changes,
115+ merge, and push a `mobile-v<version>` tag.
116+ `.g1t/workflows/mobile-release.yml` has EAS build the APK and publishes
117+ it; `scripts/mobile-release.mjs` does the same by hand. A change to the
118+ app's code alone needs no release: `eas update --channel preview` in
119+ `apps/mobile` reaches installed copies. Its server half (chat scopes,
120+ `/-/notify`, notify's phone push) deploys with the site and services.
121+ The guide is [The phone app](https://docs.g1t.sh/guides/mobile/).
112122
113123 ## Speed
114124
+2−2
183183 ### On Cloudflare
184184
185185 You need a Cloudflare account on the Workers Paid plan (Cloudflare
186−Artifacts, where repositories are stored, requires it), Node 22.22 or newer (`engines` in `package.json`; g1t is built on
187−Node 24), Rust with the `wasm32-unknown-unknown` target, and
186+Artifacts, where repositories are stored, requires it), Node 24 (`.nvmrc`, and `engines` in `package.json`), Rust with the
187+`wasm32-unknown-unknown` target, and
188188 Docker to build the sandbox image.
189189
190190 ```sh
+1−1
99 [Run g1t yourself](/guides/self-hosting/) instead.
1010
1111 You need a Cloudflare account on the Workers Paid plan (Cloudflare
12−Artifacts, where repositories are kept, requires it), Node 22.22 or newer,
12+Artifacts, where repositories are kept, requires it), Node 24,
1313 Rust with the `wasm32-unknown-unknown` target, and Docker to build the
1414 runner's image.
1515
+52−7
11 ---
22 title: The phone app
3−description: g1t on Android and iOS, built with Expo. Chat with people and agents, live, with notifications on your phone, and Code and Customize, signed in with g1t's own OAuth. Not in the stores yet; this is how it works and how to build it.
3+description: g1t on Android and iOS, built with Expo. Chat with people and agents, live, with notifications on your phone, and Code and Customize, signed in with g1t's own OAuth. Android first, downloaded from g1t.sh while it waits for Google Play.
44 ---
55
66 The phone app is g1t on Android and iOS, with its own screens for what
99 and seeing the skills, connectors and plugins your workspace can use in
1010 Customize. Everything else opens on the site, in your phone's browser.
1111
12−It is not in Google Play or the App Store yet. Android comes first. Until
13−then, it is built from `apps/mobile` in
14−[g1t's repository](https://g1t.sh/flagon-io/g1t), as
15−[For operators](#for-operators-building-and-releasing) says.
12+## Install it
13+
14+Android comes first. Get it from [g1t.sh/download](https://g1t.sh/download),
15+under **On your phone**:
16+
17+1. On your Android phone, choose **Download for Android**. On a computer,
18+ scan the code there with the phone's camera instead.
19+2. Open the downloaded file. Android asks once to allow your browser to
20+ install apps: allow it.
21+3. Open **g1t (beta)** and choose **Sign in with g1t**.
22+
23+The page shows each release's SHA-256, to check the file you have is the
24+one g1t published.
25+
26+**Why a download, and not Google Play?** Google Play and the App Store
27+publish a company's apps only once it is verified with a D-U-N-S number,
28+and Flagon's is on its way. Until then the Android app is published on
29+g1t.sh, signed by g1t, as [the desktop app](/guides/desktop/) is. It moves
30+into Google Play as soon as it can. iOS has no way in but the App Store,
31+so it follows then.
1632
33+**Updates** reach the app by themselves: most arrive the next time you
34+open it, with nothing to install. A change that needs a new install is a
35+new release on the download page; installing it over the one you have
36+keeps you signed in.
37+
1738 The bar along the bottom is the site's phone bar:
1839
1940 | Tab | What is on it |
141162
142163 ## What's not there yet
143164
144−- **Store releases.** Google Play first, then the App Store.
165+- **Google Play and the App Store**, once Flagon's D-U-N-S number
166+ arrives ([above](#install-it)). Until then, Android from the download
167+ page; iOS waits for the App Store.
145168 - **Home, Notifications, Agents and Artifacts** as tabs of their own.
146169 Their pages open on the site meanwhile.
147170 - **Formatting in Code.** Descriptions and comments show their Markdown
167190
168191 The three install side by side. `eas submit --platform android` sends
169192 the latest production build to Google Play's internal testing track as a
170−draft.
193+draft, once there is a Play account to send it to.
194+
195+### Releasing on g1t.sh/download
196+
197+Until the app is in Google Play, the `preview` build is the one people
198+install, from g1t.sh/download. A release is a tag `mobile-v<version>`,
199+where the version is `version` in `apps/mobile/app.config.ts`: push it,
200+and the `Mobile release` workflow (`.g1t/workflows/mobile-release.yml`)
201+has EAS build the APK, then publishes it to the `g1t-downloads` bucket
202+at `g1t.sh/downloads/mobile/<version>/` and points `mobile/latest.json`
203+at it, which the download page reads. `scripts/mobile-release.mjs` does
204+the same by hand (`build`, `fetch`, `manifest`, `publish`).
205+
206+| Secret or variable | What it is |
207+| --- | --- |
208+| `EXPO_TOKEN` | An Expo access token with access to the g1t project, from expo.dev |
209+| `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | As the deploy has them |
210+
211+Most changes need no release: `eas update --channel preview` in
212+`apps/mobile` sends the app's new code to every installed copy of that
213+version, which takes it the next time it opens. A release is for a change
214+to the app's native side (a new native module, `app.config.ts`) or its
215+`version`, which is also what decides which updates a copy takes.
+24−1
9797
9898 ## Run it
9999
100−You need Node 22 (the repository's).
100+You need Node 24 (the repository's `.nvmrc`; EAS builds on 24.21.0, `eas.json`).
101101
102102 ### In Expo Go (quickest)
103103
185185 iOS is the same with `--platform ios`, an Apple Developer account and the
186186 bundle id `sh.g1t.app`; EAS makes the certificates.
187187
188+### Releasing on g1t.sh/download (until Google Play)
189+
190+Google Play and the App Store wait on Flagon's D-U-N-S number. Until then
191+the `preview` build, `g1t (beta)`, is what people install, from
192+g1t.sh/download:
193+
194+```sh
195+# a new native side or version: bump `version` in app.config.ts, then
196+git tag mobile-v0.1.0 && git push origin mobile-v0.1.0 # .g1t/workflows/mobile-release.yml
197+# or by hand, from the repository's root:
198+node scripts/mobile-release.mjs build # EAS builds the APK
199+node scripts/mobile-release.mjs fetch # the newest finished preview build
200+node scripts/mobile-release.mjs manifest
201+node scripts/mobile-release.mjs publish # to g1t.sh/downloads/mobile/, latest.json last
202+
203+# code alone: over the air to every installed copy of this version
204+cd apps/mobile && npx eas-cli update --channel preview --message "What changed"
205+```
206+
207+The workflow needs `EXPO_TOKEN` (expo.dev → Access tokens) beside the
208+deploy's Cloudflare secrets. Preview builds count their own versionCode
209+(`autoIncrement`), so each installs over the last.
210+
188211 ### Push notifications on Android
189212
190213 Expo's push service sends to Android through Firebase Cloud Messaging,
+21−7
55 },
66 "build": {
77 "base": {
8− "node": "22.22.0"
8+ "node": "24.21.0"
99 },
1010 "development": {
1111 "extends": "base",
1212 "developmentClient": true,
1313 "distribution": "internal",
14− "env": { "APP_VARIANT": "development" },
15− "android": { "buildType": "apk" }
14+ "env": {
15+ "APP_VARIANT": "development"
16+ },
17+ "android": {
18+ "buildType": "apk"
19+ }
1620 },
1721 "preview": {
1822 "extends": "base",
1923 "distribution": "internal",
2024 "channel": "preview",
21− "env": { "APP_VARIANT": "preview" },
22− "android": { "buildType": "apk" }
25+ "env": {
26+ "APP_VARIANT": "preview"
27+ },
28+ "android": {
29+ "buildType": "apk"
30+ },
31+ "autoIncrement": true
2332 },
2433 "production": {
2534 "extends": "base",
2635 "channel": "production",
2736 "autoIncrement": true,
28− "android": { "buildType": "app-bundle" }
37+ "android": {
38+ "buildType": "app-bundle"
39+ }
2940 }
3041 },
3142 "submit": {
3243 "production": {
33− "android": { "track": "internal", "releaseStatus": "draft" }
44+ "android": {
45+ "track": "internal",
46+ "releaseStatus": "draft"
47+ }
3448 }
3549 }
3650 }
+1−0
3939 "expo-splash-screen": "~57.0.9",
4040 "expo-status-bar": "~57.0.1",
4141 "expo-system-ui": "~57.0.4",
42+ "expo-updates": "~57.0.25",
4243 "expo-web-browser": "~57.0.3",
4344 "lucide-react-native": "^1.55.0",
4445 "react": "19.2.3",
+106−16
4141
4242 type Platform = DesktopDownload["platform"];
4343
44+/** The phone app's newest Android release (scripts/mobile-release.mjs): `mobile/latest.json`. */
45+export type MobileRelease = {
46+ version: string;
47+ build: number | null;
48+ pub_date: string;
49+ notes?: string;
50+ downloads: { platform: "android"; kind: "apk"; name: string; size: number; sha256: string }[];
51+};
52+
53+/** A manifest as read from the bucket, or null when it is not one. */
54+async function readJson<T>(bucket: R2Bucket | undefined, key: string, check: (value: Partial<T>) => boolean): Promise<T | null> {
55+ try {
56+ const found = bucket ? await bucket.get(key) : null;
57+ if (!found) return null;
58+ const read = (await found.json()) as Partial<T>;
59+ return read && check(read) ? (read as T) : null;
60+ } catch (error) {
61+ console.error(`download: ${key} could not be read`, error);
62+ return null;
63+ }
64+}
65+
4466 /** Which platform a browser says it is on; null when it does not say. */
4567 export function platformOf(userAgent: string | null): Platform | null {
4668 if (!userAgent) return null;
7193 } catch (error) {
7294 console.error("download: the release could not be read", error);
7395 }
96+ const mobile = await readJson<MobileRelease>(bucket, "mobile/latest.json", (read) => typeof read.version === "string" && Array.isArray(read.downloads));
7497 const { site } = addresses();
75− return { release, platform: platformOf(request.headers.get("user-agent")), site, hosted: site === HOSTED_ADDRESSES.site };
98+ const userAgent = request.headers.get("user-agent") ?? "";
99+ return {
100+ release,
101+ platform: platformOf(userAgent),
102+ android: /Android/i.test(userAgent),
103+ mobile,
104+ site,
105+ hosted: site === HOSTED_ADDRESSES.site,
106+ };
76107 }
77108
78109 const PLATFORMS: { id: Platform; name: string; icon: ReactNode; note: string }[] = [
107138 ];
108139
109140 export default function DownloadPage() {
110− const { release, platform, site, hosted } = useLoaderData<typeof loader>();
141+ const { release, platform, site, hosted, mobile, android } = useLoaderData<typeof loader>();
111142 const ordered = platform ? [...PLATFORMS.filter((p) => p.id === platform), ...PLATFORMS.filter((p) => p.id !== platform)] : PLATFORMS;
112143 const yours = platform && release ? release.downloads.filter((d) => d.platform === platform) : [];
113144 const featured = yours.find((d) => d.kind === "installer" || d.kind === "dmg" || d.kind === "appimage") ?? yours[0] ?? null;
220251 </p>
221252 </section>
222253 )}
223− <PhoneApp site={site} hosted={hosted} />
254+ <PhoneApp site={site} hosted={hosted} mobile={mobile} android={android} />
224255 </div>
225256 </TrustPage>
226257 );
228259
229260 /**
230261 * The phone app: Chat with people and agents, live, and notifications on
231− * the phone. On a g1t of your own, a link and its QR code point the app
232− * here, so nobody types the address; on g1t.sh the app starts here anyway.
262+ * the phone. Not in Google Play or the App Store yet (both wait on
263+ * Flagon's D-U-N-S number), so the Android app is offered here as an APK,
264+ * with how to install it and why it is not in a store. On a g1t of your
265+ * own, a link and its QR code point the app here, so nobody types the
266+ * address; on g1t.sh the app starts here anyway.
233267 */
234−function PhoneApp({ site, hosted }: { site: string; hosted: boolean }) {
268+function PhoneApp({ site, hosted, mobile, android }: { site: string; hosted: boolean; mobile: MobileRelease | null; android: boolean }) {
235269 const link = connectLink(site);
236270 const host = new URL(site).host;
271+ const apk = mobile?.downloads.find((download) => download.kind === "apk") ?? null;
272+ // The release's own address on g1t.sh, wherever this page is: the app is g1t's, served from g1t.sh.
273+ const apkUrl = apk && mobile ? `${HOSTED_ADDRESSES.site}/downloads/mobile/${mobile.version}/${apk.name}` : null;
237274 return (
238275 <section className="mt-14">
239276 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
242279 </h2>
243280 <p className="mt-1 max-w-2xl text-sm text-muted">
244281 Chat with your team and its agents, live, with notifications for messages to you, mentions, replies and agents waiting on you;
245− review and approve pull requests on the way. Android comes first; it is in testing now, and the stores are next.
282+ review and approve pull requests on the way. Android first, with iOS to follow.
246283 </p>
247− {hosted ? (
248− <p className="mt-4 text-sm text-muted">
249− How it works, and how to build it yourself meanwhile, is in{" "}
250− <a href="https://docs.g1t.sh/guides/mobile/" className="text-accent hover:underline">
251− the phone app guide
252− </a>
253− .
254− </p>
284+
285+ {apk && apkUrl && mobile ? (
286+ <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center">
287+ {!android && (
288+ <div className="shrink-0">
289+ <QrCode value={apkUrl} label="Download the g1t app for Android" size={152} />
290+ </div>
291+ )}
292+ <div className="min-w-0">
293+ <p className="font-medium">g1t for Android, beta {mobile.version}</p>
294+ <p className="mt-1 text-sm text-muted">
295+ {android
296+ ? "Download it, open the file, and allow your browser to install apps when Android asks."
297+ : "Scan this with your Android phone's camera to download it there, then open the file and allow your browser to install apps when Android asks."}{" "}
298+ It installs as <span className="text-fg-soft">g1t (beta)</span>, and takes most updates by itself.
299+ </p>
300+ <ButtonLink to={apkUrl} reloadDocument className="mt-3">
301+ <Download size={16} />
302+ Download for Android{apk.size ? ` (${size(apk.size)})` : ""}
303+ </ButtonLink>
304+ <p className="mt-2 font-mono text-xs break-all text-faint">SHA-256 {apk.sha256}</p>
305+ </div>
306+ </Card>
307+ ) : hosted ? (
308+ <Card className="mt-4 p-5">
309+ <p className="font-medium">The first Android build is on its way.</p>
310+ <p className="mt-1 text-sm text-muted">It will be here to download as soon as it is published.</p>
311+ </Card>
255312 ) : (
313+ <Card className="mt-4 p-5">
314+ <p className="font-medium">Get the app from g1t.sh</p>
315+ <p className="mt-1 text-sm text-muted">
316+ The app is the same for every g1t: download it from{" "}
317+ <a href={`${HOSTED_ADDRESSES.site}/download`} className="text-accent hover:underline">
318+ g1t.sh/download
319+ </a>
320+ , then point it here with the code below.
321+ </p>
322+ </Card>
323+ )}
324+
325+ <div className="mt-4 rounded-xl border border-line bg-surface p-5 text-sm">
326+ <p className="font-medium text-fg">Why a download, and not Google Play or the App Store?</p>
327+ <p className="mt-1 text-muted">
328+ Both stores publish a company's apps only once the company is verified with a D-U-N-S number, and Flagon's is on its way. Until it
329+ arrives, the Android app is published {hosted ? "here" : "on g1t.sh"}, signed by us, as the desktop app is. Android asks once to allow installing it from your
330+ browser; that is the only difference. It moves into Google Play as soon as it can, and iOS follows through the App Store, which has no
331+ other way in.
332+ </p>
333+ </div>
334+
335+ {!hosted && (
256336 <Card className="mt-4 flex flex-col gap-5 p-5 sm:flex-row sm:items-center">
257− <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} />
337+ <div className="shrink-0">
338+ <QrCode value={link} label={`Open the g1t app on ${host}`} size={152} />
339+ </div>
258340 <div className="min-w-0">
259341 <p className="font-medium">Use the app with {host}</p>
260342 <p className="mt-1 text-sm text-muted">
269351 </div>
270352 </Card>
271353 )}
354+
355+ <p className="mt-4 text-sm text-muted">
356+ How it works, and what it does with a g1t of your own, is in{" "}
357+ <a href="https://docs.g1t.sh/guides/mobile/" className="text-accent hover:underline">
358+ the phone app guide
359+ </a>
360+ .
361+ </p>
272362 </section>
273363 );
274364 }
+7−3
66 * Releases: `g1t.sh/downloads/<tool>/<version>/<file>`, served from the
77 * g1t-downloads R2 bucket. `runner` is the self-hosted runner
88 * (scripts/runner-release.mjs), `cli` the g1t CLI (scripts/cli-release.mjs),
9− * `desktop` the desktop app (scripts/desktop-release.mjs). `latest/<file>`
9+ * `desktop` the desktop app (scripts/desktop-release.mjs), `mobile` the
10+ * phone app's Android APK (scripts/mobile-release.mjs). `latest/<file>`
1011 * is the newest release's, as its `latest.json` names it; the runner's
1112 * `latest.json` and `latest.json.sig` are what runners check before
1213 * updating, and the desktop app's `latest.json` names each platform's
1314 * installer with its signature, as the app's updater reads it.
1415 */
15−const TOOLS = new Set(["runner", "cli", "desktop"]);
16+const TOOLS = new Set(["runner", "cli", "desktop", "mobile"]);
1617 const TYPES: Record<string, string> = {
1718 json: "application/json",
1819 sig: "text/plain; charset=utf-8",
1920 txt: "text/plain; charset=utf-8",
21+ apk: "application/vnd.android.package-archive",
2022 };
23+/** Typed files that are still downloaded, not shown: an APK is installed from the phone's downloads. */
24+const SAVED = new Set(["apk"]);
2125
2226 async function object(key: string): Promise<R2ObjectBody | null> {
2327 const bucket = env.DOWNLOADS;
4852 // A version's files never change; what `latest` points to does.
4953 "cache-control": path.startsWith("latest") ? "public, max-age=300" : "public, max-age=31536000, immutable",
5054 });
51− if (!TYPES[extension]) headers.set("content-disposition", `attachment; filename="${name}"`);
55+ if (!TYPES[extension] || SAVED.has(extension)) headers.set("content-disposition", `attachment; filename="${name}"`);
5256 return new Response(found.body, { headers });
5357 }
+0−0

Binary or large file; its contents are not shown.

+2−2
33 "private": true,
44 "type": "module",
55 "engines": {
6− "node": ">=22.22.0"
6+ "node": ">=24"
77 },
88 "workspaces": [
99 "apps/*",
1515 "deploy": "npm run deploy -w @g1t/web",
1616 "test:deploy": "node --test \"scripts/deploy/*.test.mjs\"",
1717 "test:ops": "node --test \"scripts/ops/*.test.mjs\"",
18− "test:release": "node --test \"scripts/desktop-release.test.mjs\""
18+ "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\""
1919 },
2020 "devDependencies": {
2121 "typescript": "^5.9.3",
+173−0
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
33+import { spawnSync } from "node:child_process";
34+import { createHash } from "node:crypto";
35+import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
36+import { dirname, join } from "node:path";
37+import { fileURLToPath } from "node:url";
38+
39+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
40+const APP = join(ROOT, "apps/mobile");
41+const BUCKET = "g1t-downloads";
42+/** The EAS command line, pinned so a release does not change with its newest version. */
43+const EAS = ["--yes", "eas-cli@24.12.1"];
44+const PROFILE = "preview";
45+
46+/** The app's version, as app.config.ts says it. */
47+export 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. */
55+export function apkName(v) {
56+ return `g1t-${v}-android.apk`;
57+}
58+
59+const 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+ */
65+export 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. */
74+export 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+
80+function 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+
86+function build() {
87+ run("npx", [...EAS, "build", "--platform", "android", "--profile", PROFILE, "--non-interactive", "--wait"], { cwd: APP });
88+}
89+
90+async 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+
111+function 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+
126+function 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+
145+if (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+}
+38−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { apkName, manifestFor, newestBuild, version } from "./mobile-release.mjs";
5+
6+test("the version is the app's, and the APK is named by it", () => {
7+ assert.match(version(), /^\d+\.\d+\.\d+$/);
8+ assert.equal(apkName("0.2.0"), "g1t-0.2.0-android.apk");
9+});
10+
11+test("the manifest lists the APK with its size and SHA-256, and SHA256SUMS matches", () => {
12+ const { manifest, sums } = manifestFor({
13+ version: "0.2.0",
14+ build: 7,
15+ notes: "",
16+ files: [{ name: "g1t-0.2.0-android.apk", size: 42, sha256: "ab".repeat(32) }],
17+ now: new Date("2026-10-11T00:00:00Z"),
18+ });
19+ assert.deepEqual(manifest, {
20+ version: "0.2.0",
21+ build: 7,
22+ pub_date: "2026-10-11T00:00:00.000Z",
23+ notes: undefined,
24+ downloads: [{ platform: "android", kind: "apk", name: "g1t-0.2.0-android.apk", size: 42, sha256: "ab".repeat(32) }],
25+ });
26+ assert.equal(sums, `${"ab".repeat(32)} g1t-0.2.0-android.apk\n`);
27+});
28+
29+test("the newest finished build with an APK is the one fetched", () => {
30+ const list = [
31+ { id: "a", status: "FINISHED", createdAt: "2026-10-10T00:00:00Z", artifacts: { buildUrl: "https://x/a.apk" } },
32+ { id: "b", status: "ERRORED", createdAt: "2026-10-12T00:00:00Z", artifacts: {} },
33+ { id: "c", status: "FINISHED", createdAt: "2026-10-11T00:00:00Z", artifacts: { buildUrl: "https://x/c.apk" } },
34+ ];
35+ assert.equal(newestBuild(list)?.id, "c");
36+ assert.equal(newestBuild([]), null);
37+ assert.equal(newestBuild(null), null);
38+});