# pear-git CI — build a signed, installable Android APK on push.
#
# Two different systems share the name "release" (same distinction hardops documents):
#
# pgh publish . -> pgh-sidecar's ReleaseRegistry. Content-addresses the tree for
# `pgh app install`. Metadata only — never a file.
# this ci.yaml -> the forge's build-release. Whatever is listed under `artifacts:`
# lands on /git/repos/favela-android/releases as a download.
#
# A phone downloads an .apk, so it comes from the second one. favela-android is NOT a pgh
# app (package.json says so) — there is no `pgh publish` step here.
#
# ⛔ CI runs in a `git archive | tar -x` extraction: no .git, no node_modules, and no PATH
# guarantees. Every host path below is absolute for that reason, following hyper-tps's
# ci.yaml — the other toolchain-heavy repo in this fleet (it drives a GPU Godot export the
# same way). Git metadata arrives via injected env vars, not `git rev-list`.
#
# Version comes from the ref: a tag is used verbatim, a branch becomes <branch>-<shortsha>.
# So a release named 0.0.1-test1 means pushing that TAG — never hand-editing a version field.
steps:
- name: deps
# The archive has no node_modules, and this closure cannot be resolved from the registry:
# favela-web / hyper-proto-peer / atproto-identity are `file:../` siblings. So stage the
# live checkout's tree, which is the documented pattern for toolchain-heavy repos.
#
# `cp -a` preserves the sibling symlinks, but they are RELATIVE (`../../favela-web`) and
# resolve to nothing from a temp dir — so re-point them at absolute paths. Copying with -L
# instead would dereference the whole tree and duplicate every sibling.
#
# ⛔ DERIVED, NOT A HARDCODED LIST. This was `for sib in favela-web hyper-proto-peer
# atproto-identity`, and the day an import reached a FOURTH sibling the build broke:
# `atproto-store` was linked in on 2026-08-09, the appview-ingestion commit made it
# reachable from lib/node.js, and bare-pack died with `MODULE_NOT_FOUND: Cannot find module
# 'atproto-store/lib/overlay-repo-store.js'` — a staging list that goes stale as the code
# moves, which is the same failure this repo already fixed once for addons by reading the
# bundle header instead of naming them. The symlinks themselves say which siblings exist,
# so ask them.
#
# Then FAIL HERE on anything still dangling. Otherwise the first symptom is a bare-pack
# module error two steps later, which reads like a source problem and is not.
run: |
set -e
SRC=/storage/projects/code/hypercore-kb/hyper-fleeky
cp -a "$SRC/favela-android/node_modules" .
for link in node_modules/*; do
target=$(readlink "$link" 2>/dev/null) || continue
case "$target" in
../../*) ln -sfn "$SRC/$(basename "$target")" "$link" ;;
esac
done
# `if ... fi`, NOT `[ -L "$l" ] && [ ! -e "$l" ] && echo`: under `set -e` the assignment
# takes the exit status of the substitution, and a trailing false `&&` chain on the last
# iteration is non-zero — so the healthy case aborted the step in 5.9s. An `if` with no
# else is 0 when its condition is false.
broken=$(for l in node_modules/*; do if [ -L "$l" ] && [ ! -e "$l" ]; then echo "$l"; fi; done)
if [ -n "$broken" ]; then echo "dangling sibling symlink(s) after re-pointing:"; echo "$broken"; exit 1; fi
test -e node_modules/favela-web/package.json
timeout: 900
- name: prebuild
# Packs lib/node.js into assets/worklet.bundle, then stages exactly the addons that
# bundle demands and fails if one has no prebuild (otherwise it surfaces as a dlopen
# crash on a device, with no build-time signal). bare-pack is installed globally under
# nvm, so PATH has to be set explicitly here.
run: |
set -e
export PATH=/home/fleeky/.nvm/versions/node/v24.13.0/bin:$PATH
npm run prebuild:android
# Assert the path the APP ACTUALLY LOADS: app/worklet.js reads the Android asset
# (RNFS.readFileAssets('worklet.bundle')), and pack:worklet + stage-addons.mjs both write
# and read it there. This said `app/worklet.bundle` — the abandoned Metro-asset route,
# which nothing has written since the bundle handoff changed — so it would have failed the
# build even with the deps step fixed. An assertion pointed at a path nobody writes tests
# the assertion, not the build.
test -s android/app/src/main/assets/worklet.bundle
timeout: 900
- name: assemble
# NDK 27 is not optional: RN 0.86.2's C++ uses std::format and NDK 26's libc++ has no
# implementation of it. android/build.gradle pins 27.1.12297006.
#
# The keystore is durable and lives outside the repo — app/build.gradle defaults to
# $HOME/android-toolchain/favela-spike.keystore, which resolves because CI runs as fleeky.
# It is deliberately NOT in git.
run: |
set -e
export JAVA_HOME=/home/fleeky/android-toolchain/jdk-17.0.13+11
export ANDROID_HOME=/home/fleeky/android-toolchain/sdk
export PATH=/home/fleeky/.nvm/versions/node/v24.13.0/bin:$PATH
# Every new-architecture library's codegen dir is an input to CMake configure, and it lives in
# THAT LIBRARY's build dir — a clean checkout has not generated it yet, so configure fails with
# `add_subdirectory given source .../codegen/jni/ which is not an existing directory`, followed by
# `Cannot specify link libraries for target react_codegen_<Name>Spec`.
#
# ⛔ UNQUALIFIED, so it runs in EVERY subproject that has the task. This named
# `:react-native-bare-kit:` alone — the same going-stale-as-the-code-moves list the deps step above
# was already fixed for — and it broke in exactly that way the day a SECOND such library arrived:
# react-native-webview was added on 2026-08-11 for the WebView UI and assemble died in ninja.
# Autolinking already knows which libraries exist; ask it rather than maintaining a copy.
/home/fleeky/android-toolchain/gradle-8.13/bin/gradle -p android generateCodegenArtifactsFromSchema
/home/fleeky/android-toolchain/gradle-8.13/bin/gradle -p android assembleRelease
# The artifact path IS the public URL and the filename is what the user's browser saves, so
# publish it under a name a person can recognise rather than the gradle output path.
cp android/app/build/outputs/apk/release/app-release.apk favela-android.apk
# The worklet bundle is published ALONGSIDE the APK so an installed phone can update its JS
# without a 153 MB sideload — pgh-runtime fetches this file and verifies it against tree.json
# (written by `npm run gen:tree` in the prebuild step). Native code still needs a new APK.
cp android/app/src/main/assets/worklet.bundle worklet.bundle
# The APK gets a tree too, and a SEPARATE one. pgh-runtime installs a TREE — it hashes what it is
# handed and refuses bytes that do not reproduce the root hash — so until now the 154 MB APK was
# the one artifact nothing verified, whoever fetched it. This is the precondition for ever fetching
# it over the swarm instead of as a single unresumable HTTPS GET.
#
# ⛔ NOT merged into tree.json. A pgh tree holds many entries, so it would encode fine and would
# brick every phone already in the field: the deployed bundleStore.materialize keeps the LAST file
# entry it writes as the bundle to boot, so a two-entry tree stages a 154 MB APK as the worklet
# bundle. The OTA client on a handset is exactly what cannot be fixed remotely.
npm run gen:apk-tree
ls -l favela-android.apk worklet.bundle tree.json apk.tree.json
timeout: 2700
# ── the worklet bundle, into the RELEASE REGISTRY, so a phone can fetch it P2P ─────────────────
#
# The `artifacts:` block below publishes files the forge serves over HTTPS at
# /git/releases/<repo>/<version>/<file>. That is how the phone has always updated, and it is the
# problem: 7rnx.net/git is LOAD-BALANCED, a release lands on the backend that built it and replicates
# to the other asynchronously, and the phone asks whichever answers. Measured 2026-08-25 — 0.1.6
# replicated in ~15s, 0.1.7 had NOT replicated after 600s and the phone correctly reported no update.
# Measured before that on 2026-08-18, a 153 MB APK never reached the second backend at all.
#
# `pgh publish` is the other half, and the one every other hyper-fleeky app already uses: it
# content-addresses the tree into the ReleaseRegistry, which a standalone device replicates over
# HYPERSWARM by the publisher's registry key — no sidecar, no HTTPS, no backend to be on the wrong side
# of. Proven end-to-end from a sidecar-free process before this step was written: resolve 13.1s cold,
# getTree 6ms, getContent 19ms (pgh/lib/resolve-release.js path 2 + NetworkFetcher).
#
# ⛔ THE BUNDLE ONLY, NOT THE APK. The APK is 154 MB per build and the content store keeps every one;
# `releases` was already 6.74 GiB with nothing scheduling gc (hyper-social #47 §2). Fetching the APK
# P2P needs PackageInstaller + REQUEST_INSTALL_PACKAGES anyway (#54 §8) — apk.tree.json is published
# for that and nothing consumes it yet, so putting 154 MB into the store now would buy nothing.
#
# ⛔ THIS TREE HAS TWO ENTRIES (package.json + worklet.bundle), because pgh publish requires a manifest.
# The bundle must therefore be selected BY NAME on the phone, never by position — see the note in the
# assemble step above about materialize keeping the LAST file entry, which is exactly the trap that
# would otherwise stage a manifest as the worklet bundle.
- name: pgh publish (worklet bundle → release registry, for P2P OTA)
run: |
set -e
rm -rf .pgh-publish && mkdir -p .pgh-publish
cp worklet.bundle .pgh-publish/worklet.bundle
# Version comes from the repo's package.json — the same field pgh publish reads for every other
# app, so a release is identified the way the rest of the fleet identifies one.
VERSION=$(node -p "require('./package.json').version")
cat > .pgh-publish/package.json <<JSON
{
"name": "favela-android",
"version": "$VERSION",
"pgh": {
"upgrade": "pgh://1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86/favela-android",
"runtime": "bare"
}
}
JSON
cat .pgh-publish/package.json
# --accept-conflict: republishing one version with different content is EXPECTED here, because a
# branch rebuild re-runs without a version bump. Same reason hyper-proto-peer's publish carries it.
pgh publish .pgh-publish --accept-conflict "automated CI publish of the favela-android worklet bundle; version tracks package.json and a branch rebuild republishes it"
# Prove the registry actually has it, in the build log, rather than assuming the publish landed.
pgh releases latest favela-android || true
timeout: 300
artifacts:
# ⛔ FILES ONLY — collectArtifacts uses readFile, with no directory walk and no globbing.
- favela-android.apk
# The OTA pair. tree.json is what makes the bundle FETCHABLE at all: pgh-runtime installs a TREE, and
# hashes the entries it is handed against the root hash before it will accept them — so a release with
# a bundle and no manifest is unfetchable by design, which is the state this repo shipped in.
#
# ⚠ ~37 MB per release on top of the 153 MB APK. `releases` was already 6.74 GiB with gc({keepN:3})
# existing and nothing scheduling it (hyper-social #47 §2); this makes that trajectory steeper.
- worklet.bundle
- tree.json
# The APK's manifest — 289 bytes, and the only thing that makes that 154 MB fetchable-and-verifiable
# by anything other than a browser. Nothing consumes it yet; publishing it first is what lets a
# swarm-side fetcher be written without another build round-trip.
- apk.tree.json