scripts/build.sh (entry) + scripts/utils.sh (library, ~4700 lines, 124
functions). This is the part that turns one TOML table into a signed APK and a
Magisk module zip.
bash scripts/build.sh configs/stable_build.json # CWD must be the repo root
bash scripts/build.sh clean # remove temp/, build/, build.mdbuild.shsources its siblingutils.shexplicitly and exportsRVB_UTILS_SHso pooled children can re-source it.- Requires
jq,java,zip;python3is optional (release notes and TOML fallbacks). Repo tools live inbin/:aapt2,htmlq,toml/tq(per-arch binaries),apksigner.jar,dexlib2.jar,paccer.jar. - Everything transient goes under
temp/(gitignored); everything shippable goes tobuild/.build.jsonis the machine record,build.mdthe human one. - The engine never fails the whole run for one app: per-app failures log and
continue; only "no output at all" aborts (
All builds failed.).
toml_prep converts the config to JSON (native tq binary, python3 fallback)
and the file splits into a main table (file-level defaults: patches-version,
patches-source, cli-source, brand, variant, arch, author, …) and one
table per app. For each enabled table build.sh:
- Inherits every key from the file-level default when the app omits it.
- Resolves
patches-version = "both"from the file being built — a beta-named config is the beta pool (configs/beta_build.json,*.beta.toml). - Validates the enum keys hard (
arch,build-mode,include-stock,*-source-host, booleaninclusive-patches) and rejects quote-less patch lists, becauselist_argssplits on quoted tokens. - Refuses
inclusive-patchestogether withexclusive-patches— they are opposites, and the ambiguity would be resolved by argument order. - Calls
get_prebuiltsto fetch the CLI jar and every patch bundle, then builds theapp_argsassociative array, including the aggregatedpatches_refandchangelog_urlderived from the exact bundle resolved for this build. arch = bothfans out into two builds,arm64-v8aandarm-v7a, each with its own module-id suffix (-arm64/-arm).- Beta builds get
-betaappended to the module id automatically, so a phone's module updater never crosses channels.
get_prebuilts must be called directly rather than inside $( ): it writes the
__PREBUILTS_CACHE__ global, and a subshell would discard it.
The only knob is the env var set in build.yml
(PARALLEL_JOBS: "6"); no config file can change it. 1 — the historical
default — runs the original sequential path untouched. Above that, each table
build becomes a fresh bash -c child that re-sources utils.sh:
- per-job globals (
PATCHER_*,PATCH_OUTPUT, every in-process cache) are therefore isolated by construction, which is what makes concurrency safe; - children write
temp/queue/<id>.logplus anrcfile; the parent replays finished logs inside their own::group::in completion order, so the Actions log stays as clean as the serial one; - the wrapper runs
set +eso it can record a failing child's rc, and a child killed externally without an rc file gets a synthesised137rather than hanging the drain; INTkills in-flight children before running the normal abort sweep.
The =() initialisers on the job arrays are required: bash 5.3 treats a bare
declare -gA as unset under set -u. Why the knob is a workflow env value and not
a config key: decisions/0005.
There is no second pool for downloads either — a prewarm pass was built and reverted
for adding surface without a measured gain
(decisions/0004).
- Identity — resolve display name/slug, package name (a
pkg-nameof its own, or inferred from a GitHub/archive release-tag URL). - Patch selection —
inclusive-patchesis expanded here into explicit patch names via_all_patch_names, with excluded names removed from the expansion rather than passed as both include and exclude. Downstream code keeps reading oneincluded-patchesstring and never learns the flag. Per-bundle-e/-dlists are joined byjoin_args(the single escaping point, which is what makes apostrophes in patch names survivable) and|-separated per bundle, so multi-source apps can address each bundle individually. - Version resolution —
_resolve_list_and_versionimplements the precedence: explicitversionfrom the config (a tag, orexp/latest/beta) → the version the patch bundle advertises underauto(a tested compatibility guarantee) →state/app_versions.json(only when the CLI advertises nothing) → live latest from the source. The targetversionCodeis derived from patch metadata only, andhas_compatible_patchesgates the build on the bundle actually covering the resolved version. - Stock acquisition — see the source order below. Bundles
(
.xapk/.apkm/.apks) are kept whole and handed to morphe untouched whenRVB_MORPHE_PASSTHROUGH=true(morphe merges natively, and some APKs misbehave afterapkeditor's rewrite + re-sign); otherwisemerge_splitsflattens them.verify_downloaded_apkthen checks the payload really is the requested package/version/arch before anything is patched. An arch-honesty gate follows: the artifact's real ABIs are read off its bytes (_artifact_abis) and, unless it carries the requested arch or is universal/arch-agnostic, the download is rejected and the run falls through to the next source — the arch goes unbuilt if no source supplies it, so no file is ever named for an ABI it does not contain (decisions/0007). APKPure/APKCombo/Uptodown links carry no ABI in the URL, so the first build to want one fetches it once and records its bytes intemp/urlindex; a later job that resolves the same link adopts the stored blob (right arch) or skips the source (wrong arch) with no network hit. Universal bundles are cached under the shared-allkey and reused by both arch jobs from that single fetch. - Arch trimming — bundles go through
_trim_bundle_for_arch(config members filtered by ABI); plain APKs get foreignlib/<abi>/*entries removed withzip -d. Result is cached as<prefix>-<version>-<arch>.stripped.<ext>, andall/universalships the raw bundle. - Patching —
patch_apkdispatches on the tool kind resolved by the patcher registry (see below) and records which patches the tool reports as applied. - Naming and metadata —
aapt2/aaptre-reads the patched manifest, so a patcher that rewrote the package id is recorded honestly; output is<file-prefix>-v<version>-<arch>.apk;write_build_infoappends the record that becomes the release manifest. - Module mode (
build-modemodule/both) — themodule/template is copied to a scratch dir,module_configwritesconfig(PKG_NAME/PKG_VER/MODULE_ARCH),module_propwritesmodule.propand — only in CI, never for local builds — anupdateJsonURL built byupdate_json_path(). Output:<file-prefix>-module-v<version>-<arch>.zip. - Finalisation —
merge_build_infofolds per-job fragments intobuild.json, scratch state is swept,generate_release_notes.pywritesbuild.mdfor the release body.
DL_SRCS in utils.sh is the order every app is attempted
in; the first source that yields a verified artifact carrying the requested arch
(see the arch-honesty gate above) wins:
| # | Source | Notes |
|---|---|---|
| 1 | cache_repo |
nullcpy/apks — release per package name, download-only (never used to list versions) → cache-repo.md |
| 2 | direct |
a straight file URL in the config |
| 3 | github |
release assets, filtered by github-release-regex / github-regex, arch-mapped |
| 4 | archive |
archive.org item, the long-term fallback for delisted versions |
| 5 | apkmirror |
universal-bundle strategy; package/version read from the HTML |
| 6 | uptodown |
|
| 7 | apkpure |
XAPK handling in _apkpure_install_xapk |
| 8 | apkcombo |
trusts the served filename over its object key |
Supporting machinery:
- Anti-bot routing — a Cloudflare-bypass sidecar (
CF_SOLVER_URL, theghcr.io/sarperavci/cloudflarebypassforscrapingservice inbuild.yml), asked about the effective URL after redirects, not the request URL;curl_cffi(scripts/cf_get.py) for TLS-fingerprint walls. - Transfer guards —
_reqsets connect and absolute ceilings plus a low- speed stall guard, because a mirror that trickles would otherwise hold a build slot forever. - Locks — per-package download locks under
temp/dllocksstop parallel jobs fetching the same APK twice; rejected downloads sweep their sibling bundle files so the post-loop scan cannot adopt a partial artifact. - Response caches —
__DL_RESP_CACHE__and friends keep a run from re-scraping the same page per architecture.
| Layer | Location | Keyed by | Written by |
|---|---|---|---|
| Actions cache | temp/apks on the runner |
apks-<hash of size+name manifest> |
build.yml restore/save |
| Shared APK cache | nullcpy/apks releases |
tag = package name | the engine, after a successful fresh download (UPLOAD_APKS_REPO, GH_TOKEN=APKS_REPO_TOKEN) |
| Prebuilt tools | temp/<host>__<owner>__<repo>-rv |
patch source / CLI release | get_prebuilts |
| In-process | __PREBUILTS_CACHE__, __PATCH_VER_CACHE__, __PKG_VERS_CACHE__, __DL_RESP_CACHE__ |
per job | memoised lookups |
build_cache_cleanup.sh keeps temp/apks under an 8 GB watermark with tiered
retention (30/14/7/3 days) so the Actions cache stays below GitHub's 10 GB
per-repo limit. update_usage_tracker.py posts the versions actually consumed
(temp/used_versions.txt) back to the cache repo, whose own weekly retention pass
keeps the 10 newest versions per package and everything used in the last 30 days —
see cache-repo.md.
.github/scripts/patchers.sh (sourced by the engine, overridable with
RVB_PATCHERS_SH for tests) owns resolve_patcher and the PATCHER_* flags:
which tool kind this is, whether it lists patches, whether it needs a mount arg,
how its output is recovered. .github/scripts/patchers.py answers the CI-side
question needs-bks (does this config contain an Xposed module that requires
Bouncy Castle for BKS keystores?). Adding a tool means editing the registry, not
build_rv.
| Env var | Default | Purpose |
|---|---|---|
RVB_KEYSTORE / RVB_KEYSTORE_P12 |
ks.keystore / ks-p12.keystore |
signing identity; CI writes them from KEYSTORE_B64 / KEYSTORE_P12_B64 via install_keystore.sh |
RVB_KEYSTORE_PASS / RVB_KEY_ALIAS |
upstream defaults | overridden by secrets in CI |
RVB_MORPHE_PASSTHROUGH |
true |
keep bundles whole for morphe instead of merging at download time |
RVB_INSTAFEL_FALLBACK_COMMIT, RVB_INSTAFEL_DEFAULT_PATCHES |
see source | used when the InstaFel CLI manifest has no commit hash or a config omits included-patches |
Signature identity is not cosmetic: patched apps that lose the expected signer
cannot update in place, so check_sig exists and the keystore is a CI secret
rather than a repository default.
The tool-decision branches of utils.sh are covered by the offline trace
harness — fixtures, stubbed curl/java, golden argv files, verified on every
push to build.sh/utils.sh. See
.github/traces/README.md. Helper-level tests for
cache and bundle functions sit beside it; behavioural tests for individual shell
fixes live in temp/ (gitignored) by convention, and the ones worth keeping are
listed in contributing.md.