OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / .pear-git / ci.yaml

255 lines · yaml ref:
# 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-surface-android/releases as a download.
#
# A phone downloads an .apk, so it comes from the second one. favela-surface-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-surface-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
      # ⛔ favela-PEER, not favela-web. This line arrived from favela-android asserting ITS core
      # sibling, and this repo has no favela-web at all — so `deps` failed in 4.8s with an empty
      # output and `exit code 1`, which reads like a staging problem and is not. Note it sits
      # directly under a comment about not hardcoding a sibling list; the loop above was derived and
      # this assertion was not. Asserting the one sibling the worklet cannot boot without.
      test -e node_modules/favela-peer/package.json
      test -e node_modules/favela-surface/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
      # ⛔ The surface's browser files must be IN the bundle. bare-pack packs only imports, and the
      # surface once read static/ off disk at runtime — inside the bundle that was every /static/*.js
      # a 404 on the phone, so photos went up with no pyramid and reached bsky without the image.
      bare scripts/verify-bundle-static.mjs 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-surface-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
      # ⛔ VERIFY THE ARTIFACT, NOT THE INTENT. Every check before this point inspects a DECISION —
      # what stage-addons staged, what the keep-list kept, what gradle says it excluded. All three
      # reported success for 0.0.32 and 0.0.33 and produced an APK that crash-looped on its own bundle:
      # a kept library was excluded anyway by a cross-ABI pattern, and the addon that linked against it
      # could not dlopen. bare reports that as ADDON_NOT_FOUND against the addon that was present, so
      # the handset names the wrong library and there is no adb on it to say otherwise.
      #
      # This opens the APK and walks every shipped lib's ELF NEEDED entries. It is the last thing
      # between a build and a phone, and it fails the build rather than the device.
      node scripts/verify-apk-addons.mjs favela-surface-android.apk
      ls -l favela-surface-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
      # ── the native addons, so a release can change them without a 154 MB sideload ────────────────
      #
      # A `linked:` bundle NAMES the sonames the APK must supply, which is why every native change has
      # needed a new APK. lib/loader.js registers these under exactly those names in Bare's Addon.cache,
      # which the `linked:` branch of Addon.resolve consults BEFORE dlopen-by-soname — so a staged addon
      # wins and an unstaged one still falls through to the APK.
      #
      # ⛔ ALL OF THEM, NOT THE CHANGED ONES. A publisher cannot know which APK any given device holds,
      # so "only what changed" has no definable baseline. Publishing the full demanded set costs nothing
      # after the first release: the tree is content-addressed, so an addon whose bytes are unchanged is
      # already in the store and is never fetched again. It also guarantees bundle and addons match.
      #
      # Named by soname because THE FILENAME IS THE CONTRACT — the loader maps a staged file straight to
      # `linked:<filename>` and never parses the bundle. bare-link already writes them under exactly
      # that name, so this copies rather than renames.
      #
      # arm64-v8a alone, matching abiFilters. A second ABI belongs in by-arch/ with pgh-runtime's
      # skipPath, which PghRuntime does not expose yet — see hyper-social #69.
      mkdir -p .pgh-publish/addons
      node -e '
        const fs = require("fs"), path = require("path")
        const buf = fs.readFileSync("worklet.bundle")
        const nl = buf.indexOf(10)
        const len = parseInt(buf.subarray(0, nl).toString(), 10)
        const demanded = JSON.parse(buf.subarray(nl, nl + len).toString().trim()).addons.map(a => a.replace(/^linked:/, ""))
        // ⛔ THE KEEP-LIST WHEN THERE IS ONE, because the header names only what the JS module graph
        // imports and an addon can LINK against a .so no module imports. These addons carry no RUNPATH,
        // so a DT_NEEDED soname is resolved out of the app namespace — the APK, plus whatever is already
        // loaded. lib/loader.js dlopens the staged set by absolute path before the bundle runs, so a
        // dependency staged here is in the process by soname when its dependent needs it; one that is
        // missing is a dlopen failure the device reports against the wrong library.
        const keepPath = "android/app/addon-keep.json"
        let want = demanded
        if (fs.existsSync(keepPath)) {
          const keep = JSON.parse(fs.readFileSync(keepPath, "utf8"))["arm64-v8a"]
          if (Array.isArray(keep) && keep.length) want = [...new Set([...demanded, ...keep])]
        }
        const src = "node_modules/react-native-bare-kit/android/src/main/addons/arm64-v8a"
        let n = 0
        for (const name of want) {
          const from = path.join(src, name)
          if (!fs.existsSync(from)) throw new Error("demanded addon not staged: " + name)
          fs.copyFileSync(from, path.join(".pgh-publish/addons", name))
          n++
        }
        console.log("staged " + n + " addon(s) for publication")
      '
      # 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-surface-android",
        "version": "$VERSION",
        "pgh": {
          "upgrade": "pgh://1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86/favela-surface-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-surface-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-surface-android || true
    timeout: 300

artifacts:
  # ⛔ FILES ONLY — collectArtifacts uses readFile, with no directory walk and no globbing.
  - favela-surface-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