
Self-hosted distribution for CI-built apps: uploads get per-build pages with QR/install, GitHub Action uploads via OIDC to Cloudflare, in-app SDK checks, downloads and installs updates, build stamping.
Self-hosted distribution for Android CI builds. CI uploads a build, Drop gives it a page with a QR code and an install button, and apps built with the Drop SDK can check for newer builds and install them in place. It is a replacement for Firebase App Distribution that you run on your own Cloudflare account, usually within the free tier.
Status: design. Nothing is built yet. Decisions are recorded in
docs/adrand the vocabulary inCONTEXT.md.
Android only. iOS is deferred to a later iteration; desktop platforms are out of scope (ADR 0002).
| Component | What it is |
|---|---|
| Server | A Cloudflare Worker with R2 for binaries and D1 for metadata (ADR 0001) |
| Build pages | A web page per build: app icon, version, commit, pull request and CI run links, QR code, download button |
| Uploads | A GitHub Action, authenticated with the workflow's OIDC token; files go straight to R2 (ADR 0005) |
| Update API | Answers "is there a newer build on my channel?" for an installed app |
| SDK | Kotlin Multiplatform modules (Android target only) an app adds to its internal builds to check for, download and install updates (ADR 0012) |
| Gradle plugin | Adds the SDK to internal variants, generates its configuration and stamps each build with its channel and CI run (ADR 0013) |
Each deployment of Drop accepts uploads only from the GitHub accounts its config lists, and within them only from the repositories they install the deployment's GitHub App on; anyone can deploy their own (ADR 0004). Products from public repositories are public; private ones require GitHub sign-in and read access to the repository (ADR 0003, ADR 0010).
Your deployment is its own small, private repository that holds only configuration and deploys a pinned release of Drop (ADR 0016). You need Node.js 24 or later, git, and the GitHub CLI signed in.
Create it:
curl -fsSL https://raw.githubusercontent.com/r0adkll/drop/main/deployment/init.sh \
| sh -s -- my-drop --github you/drop-deploymentConfigure it: in my-drop/drop.config.json, set the URL Drop will serve (a hostname whose DNS
is on your Cloudflare account) and the accounts it trusts:
Set up: ./drop setup walks through the rest and skips whatever is already done:
drop.config.lock.json;Secrets stay in the gitignored .dev.vars and never appear on screen.
Push: commit drop.config.json and drop.config.lock.json and push. Every push to main
deploys. To upgrade Drop, change the ref in .github/workflows/deploy.yml.
Install the App: each listed account installs it from the link setup prints, choosing all of its repositories or only some in GitHub's settings. Changing that choice needs no redeploy.
Add the upload Action to those repositories' workflows, as below.
A workflow in one of those repositories uploads with the Drop Action. Its only setup is permission to request a GitHub OIDC token; there are no secrets to store:
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
id-token: write # lets the Action prove which repository and run the Build came from
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: 21 }
- run: ./gradlew assembleInternal
- uses: r0adkll/drop/action@v0
with:
server: https://drop.example.com
apk: app/build/outputs/apk/internal/app-internal.apkA Build's headline is its pull request title, else its commit message's first line. To say more,
pass notes, such as a release's description: its first line becomes the headline and the rest is
shown as What's new on the Build page and in the Update sheet.
- uses: r0adkll/drop/action@v0
with:
server: https://drop.example.com
apk: app/build/outputs/apk/beta/app-beta.apk
notes: ${{ github.event.release.body }}A Wear OS app shares its phone app's application ID, so it is the same Product. Upload its APK with a second step in the same job; the Action sees from the APK that it is a watch app, and phones and watches are each offered only their own Builds (ADR 0017):
- uses: r0adkll/drop/action@v0
with:
server: https://drop.example.com
apk: wear/build/outputs/apk/internal/wear-internal.apkHow the server checks uploads is described in docs/design.md.
With the SDK, an installed Build finds newer Builds on its Channel and updates itself, and a closed pull request's Build is offered the default Channel's newest. It needs Android 8 (API 26) or later.
Sign every Build with one internal key. Android updates an app only with a Build signed by the same key, and a CI runner makes a fresh debug key on every run, so debug-signed CI Builds can never update each other. Keep one internal keystore as a CI secret and sign every variant you distribute through Drop with it. Drop never holds or configures keys (ADR 0006). Builds from Google Play are re-signed by Play, so they can't update to or from Drop's.
Apply the Gradle plugin. It gives the variants you choose the SDK and every other variant a no-op with the same API, so the install permission never reaches a store build. Until the Gradle Plugin Portal lists it, it resolves from Maven Central:
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
google()
}
}// app/build.gradle.kts
plugins {
id("com.android.application")
id("com.r0adkll.drop") version "0.1.0"
}
drop {
serverUrl = "https://drop.example.com"
// Which variants get the SDK; the rest get the no-op. Default: the debug build type.
enableFor { it.flavorName == "internal" }
}
dependencies {
// The ready-made Update sheet (optional).
implementation("com.r0adkll.drop:drop-ui:0.1.0")
}On CI the plugin stamps each Build with its Channel and run, which is how Drop recognises an
installed Build. A local build has no Stamp and follows drop { channel } (default main), or
per variant drop { channelFor { if (it.flavorName == "beta") "beta" else null } }. Store
variants get nothing in their manifest.
Create one Drop per process and check when it suits your app:
class App : Application() {
val drop by lazy { Drop(this) } // reads the plugin's settings from the manifest
}
lifecycleScope.launch {
when (val result = app.drop.check()) {
is UpdateCheckResult.Available -> showUpdateBadge(result.update)
else -> Unit
}
}drop.state is a StateFlow (Update available, downloading, ready to install, installing,
failed) that survives process death, so a download that finished in the background is still
ready on the next launch. The SDK posts no notifications; your app decides how to prompt.
Show the Update sheet when the tester acts. It offers the Update, asks for Install unknown apps the first time, downloads, verifies and installs, and explains reinstalls, ended pull requests and Google Play Protect's warning (design):
// Compose: it inherits your MaterialTheme
if (showSheet) DropUpdateSheet(drop = app.drop, onDismiss = { showSheet = false })
// Views
activity.showDropUpdateSheet(app.drop)Or build your own UI from check(), download(), canInstall(),
installPermissionIntent() and install().
Channels. Runs on the default branch upload to main and pull requests to pr/<number>.
Any other run names its Channel with a job-level DROP_CHANNEL, which both the upload Action and
the plugin's Stamp read, so they agree:
jobs:
beta:
env:
DROP_CHANNEL: betaVersion codes. Drop orders Builds by GitHub run, not version code
(ADR 0009). Android still refuses a lower version code,
so an Update with one is offered as a reinstall. drop { versionCodeFromCommits = true } sets the
version code to the first-parent commit count, which only grows and never puts a pull request's
Build above the main Build its merge produces (CI needs fetch-depth: 0).
The sample app shows the whole loop, and its CI uploads it to a deployment.
There is no tester-side app for now: testers install from build pages and apps update themselves
through the SDK (ADR 0011). Milestones,
channel behaviour and retention are in docs/design.md.
Self-hosted distribution for Android CI builds. CI uploads a build, Drop gives it a page with a QR code and an install button, and apps built with the Drop SDK can check for newer builds and install them in place. It is a replacement for Firebase App Distribution that you run on your own Cloudflare account, usually within the free tier.
Status: design. Nothing is built yet. Decisions are recorded in
docs/adrand the vocabulary inCONTEXT.md.
Android only. iOS is deferred to a later iteration; desktop platforms are out of scope (ADR 0002).
| Component | What it is |
|---|---|
| Server | A Cloudflare Worker with R2 for binaries and D1 for metadata (ADR 0001) |
| Build pages | A web page per build: app icon, version, commit, pull request and CI run links, QR code, download button |
| Uploads | A GitHub Action, authenticated with the workflow's OIDC token; files go straight to R2 (ADR 0005) |
| Update API | Answers "is there a newer build on my channel?" for an installed app |
| SDK | Kotlin Multiplatform modules (Android target only) an app adds to its internal builds to check for, download and install updates (ADR 0012) |
| Gradle plugin | Adds the SDK to internal variants, generates its configuration and stamps each build with its channel and CI run (ADR 0013) |
Each deployment of Drop accepts uploads only from the GitHub accounts its config lists, and within them only from the repositories they install the deployment's GitHub App on; anyone can deploy their own (ADR 0004). Products from public repositories are public; private ones require GitHub sign-in and read access to the repository (ADR 0003, ADR 0010).
Your deployment is its own small, private repository that holds only configuration and deploys a pinned release of Drop (ADR 0016). You need Node.js 24 or later, git, and the GitHub CLI signed in.
Create it:
curl -fsSL https://raw.githubusercontent.com/r0adkll/drop/main/deployment/init.sh \
| sh -s -- my-drop --github you/drop-deploymentConfigure it: in my-drop/drop.config.json, set the URL Drop will serve (a hostname whose DNS
is on your Cloudflare account) and the accounts it trusts:
{
"url": "https://drop.example.com",
"owners": ["your-org", "you"]
}Set up: ./drop setup walks through the rest and skips whatever is already done:
drop.config.lock.json;Secrets stay in the gitignored .dev.vars and never appear on screen.
Push: commit drop.config.json and drop.config.lock.json and push. Every push to main
deploys. To upgrade Drop, change the ref in .github/workflows/deploy.yml.
Install the App: each listed account installs it from the link setup prints, choosing all of its repositories or only some in GitHub's settings. Changing that choice needs no redeploy.
Add the upload Action to those repositories' workflows, as below.
A workflow in one of those repositories uploads with the Drop Action. Its only setup is permission to request a GitHub OIDC token; there are no secrets to store:
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
id-token: write # lets the Action prove which repository and run the Build came from
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: 21 }
- run: ./gradlew assembleInternal
- uses: r0adkll/drop/action@v0
with:
server: https://drop.example.com
apk: app/build/outputs/apk/internal/app-internal.apkA Build's headline is its pull request title, else its commit message's first line. To say more,
pass notes, such as a release's description: its first line becomes the headline and the rest is
shown as What's new on the Build page and in the Update sheet.
- uses: r0adkll/drop/action@v0
with:
server: https://drop.example.com
apk: app/build/outputs/apk/beta/app-beta.apk
notes: ${{ github.event.release.body }}A Wear OS app shares its phone app's application ID, so it is the same Product. Upload its APK with a second step in the same job; the Action sees from the APK that it is a watch app, and phones and watches are each offered only their own Builds (ADR 0017):
- uses: r0adkll/drop/action@v0
with:
server: https://drop.example.com
apk: wear/build/outputs/apk/internal/wear-internal.apkHow the server checks uploads is described in docs/design.md.
With the SDK, an installed Build finds newer Builds on its Channel and updates itself, and a closed pull request's Build is offered the default Channel's newest. It needs Android 8 (API 26) or later.
Sign every Build with one internal key. Android updates an app only with a Build signed by the same key, and a CI runner makes a fresh debug key on every run, so debug-signed CI Builds can never update each other. Keep one internal keystore as a CI secret and sign every variant you distribute through Drop with it. Drop never holds or configures keys (ADR 0006). Builds from Google Play are re-signed by Play, so they can't update to or from Drop's.
Apply the Gradle plugin. It gives the variants you choose the SDK and every other variant a no-op with the same API, so the install permission never reaches a store build. Until the Gradle Plugin Portal lists it, it resolves from Maven Central:
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
google()
}
}// app/build.gradle.kts
plugins {
id("com.android.application")
id("com.r0adkll.drop") version "0.1.0"
}
drop {
serverUrl = "https://drop.example.com"
// Which variants get the SDK; the rest get the no-op. Default: the debug build type.
enableFor { it.flavorName == "internal" }
}
dependencies {
// The ready-made Update sheet (optional).
implementation("com.r0adkll.drop:drop-ui:0.1.0")
}On CI the plugin stamps each Build with its Channel and run, which is how Drop recognises an
installed Build. A local build has no Stamp and follows drop { channel } (default main), or
per variant drop { channelFor { if (it.flavorName == "beta") "beta" else null } }. Store
variants get nothing in their manifest.
Create one Drop per process and check when it suits your app:
class App : Application() {
val drop by lazy { Drop(this) } // reads the plugin's settings from the manifest
}
lifecycleScope.launch {
when (val result = app.drop.check()) {
is UpdateCheckResult.Available -> showUpdateBadge(result.update)
else -> Unit
}
}drop.state is a StateFlow (Update available, downloading, ready to install, installing,
failed) that survives process death, so a download that finished in the background is still
ready on the next launch. The SDK posts no notifications; your app decides how to prompt.
Show the Update sheet when the tester acts. It offers the Update, asks for Install unknown apps the first time, downloads, verifies and installs, and explains reinstalls, ended pull requests and Google Play Protect's warning (design):
// Compose: it inherits your MaterialTheme
if (showSheet) DropUpdateSheet(drop = app.drop, onDismiss = { showSheet = false })
// Views
activity.showDropUpdateSheet(app.drop)Or build your own UI from check(), download(), canInstall(),
installPermissionIntent() and install().
Channels. Runs on the default branch upload to main and pull requests to pr/<number>.
Any other run names its Channel with a job-level DROP_CHANNEL, which both the upload Action and
the plugin's Stamp read, so they agree:
jobs:
beta:
env:
DROP_CHANNEL: betaVersion codes. Drop orders Builds by GitHub run, not version code
(ADR 0009). Android still refuses a lower version code,
so an Update with one is offered as a reinstall. drop { versionCodeFromCommits = true } sets the
version code to the first-parent commit count, which only grows and never puts a pull request's
Build above the main Build its merge produces (CI needs fetch-depth: 0).
The sample app shows the whole loop, and its CI uploads it to a deployment.
There is no tester-side app for now: testers install from build pages and apps update themselves
through the SDK (ADR 0011). Milestones,
channel behaviour and retention are in docs/design.md.
{ "url": "https://drop.example.com", "owners": ["your-org", "you"] }