Skip to content

Publish on merge

Every merge to your default branch publishes a build to the fleet's artifact store, tagged with the app it is. Nothing is tested; nothing waits for a device.

This is the half of nightly testing nobody thinks about, and skipping it is how a schedule ends up guarding a build older than the code it is meant to guard.

Why not just pin a hash

A schedule that names a literal sha256 tests that build forever. One of these spent six days guarding an APK older than the code it was meant to guard, and it was green the whole time, because the build it tested really was fine.

A schedule that asks for "sha256": "latest" tests whatever was published most recently, and never has to be edited again.

The contract is one header

An artifact uploaded with x-artifact-app belongs to that app, and latest resolves to the newest one:

curl -X POST http://fleet-host.local:8788/artifacts \
  -H 'content-type: application/octet-stream' \
  -H 'x-artifact-name: app-debug.apk' \
  -H 'x-artifact-app: your-app-android' \
  -H 'x-artifact-build: 1.4.2-903' \
  -H 'x-artifact-platform: android' \
  --data-binary @app/build/outputs/apk/debug/app-debug.apk

Those headers are what stamp the publish ordering that latest reads. There is no /artifacts/:sha/publish endpoint and none is needed.

An app with no published build is skipped, not failed

If nothing has ever been published for an app name, its nightly is skipped rather than failed. A job pinned to a build that does not exist would fail in a way indistinguishable from the app being broken.

The workflow

Copy collector/ci/publish-build.yml into your app repository as .github/workflows/publish-build.yml and change the three values marked CHANGE ME.

name: publish-build-to-fleet
on:
  push:
    branches: [main]

jobs:
  publish:
    runs-on: [self-hosted]     # must be able to reach the collector
    steps:
      - uses: actions/checkout@v4
        with:
          # `git describe` needs history; the default shallow clone has none,
          # so the build string would be a bare sha with no version in it.
          fetch-depth: 0

      # CHANGE ME (1/3): however this app builds.
      - name: Build
        run: ./gradlew :app:assembleDebug

      - name: Publish to the fleet
        run: |
          npx --yes tsx scripts/ci-upload.ts \
            --collector "${{ secrets.FLEET_COLLECTOR_URL }}" \
            --app       your-app-android \
            --build     "$(git describe --tags --always)" \
            --platform  android \
            --artifact  app/build/outputs/apk/debug/app-debug.apk
        # CHANGE ME (2/3): --app must match the `app.name` in the fleet
        #   schedule that tests it, or the nightly will never see the build.
        # CHANGE ME (3/3): --artifact is the path this build actually produces.

--app must match the app.name in the schedule that tests it. That is the single most common way to wire this up and see nothing happen: the build publishes fine, the nightly runs fine, and they are talking about different names.

The schedule that consumes it

{
  "schema": 1,
  "job_id": "nightly-ui",
  "workload": "ui-test",
  "executor": "host",
  "app": { "name": "your-app-android", "sha256": "latest" },
  "suite": { "kind": "maestro", "flows": "your-app/smoke.yaml" },
  "targets": { "match": "os ~ 'android'", "exclusive": true },
  "lease": { "ttl_s": 1800 }
}

Put it in scripts/seed-schedules.ts rather than only in the database, and npm run seed:schedules. That is idempotent and preserves the on/off state of anything already there.

New schedules arrive disabled. Enable it from the dashboard when you have watched it run once by hand.

Chaining a build to its own test

If a machine agent does your building, the whole thing can be one enqueue — job chains do the waiting:

[
  { "job_id": "build-903", "workload": "build", "executor": "device",
    "params": { "repo": "git@github.com:you/app.git", "ref": "main",
                "kind": "gradle", "task": "assembleDebug", "app": "your-app-android" } },

  { "job_id": "install-903", "workload": "install", "executor": "host",
    "depends_on": ["build-903"],
    "app": { "name": "your-app-android", "build": "903",
             "sha256": "${jobs.build-903.artifact}" } },

  { "job_id": "ui-903", "workload": "ui-test", "executor": "host",
    "depends_on": ["install-903"],
    "app": { "name": "your-app-android", "build": "903",
             "sha256": "${jobs.build-903.artifact}" },
    "suite": { "kind": "maestro", "flows": "your-app/smoke.yaml" } }
]

A broken build fails the install and the UI test with it, naming the cause, rather than leaving them in waiting until somebody notices next week.