Bassam Ismail
How to Cut a 30-Minute Mobile Pipeline Without Skipping Proof
Engineering

How to Cut a 30-Minute Mobile Pipeline Without Skipping Proof

6 min read

On the third failed release attempt, the team watched another 30 minutes disappear into native builds, only to expose a defect the pipeline could have caught near the start. The tempting response to a 30-minute mobile pipeline was to delete checks. We shortened the failure path instead: reject invalid shared code before renting expensive native capacity.

TL;DR

Put blocking shared verification ahead of native work, then run iOS and Android builds concurrently against the same verified export. Carry a manifest that identifies the revision, dependencies, export, workflow run, and toolchains, while treating caches as replaceable inputs rather than proof.

Measure wasted work

Wall-clock duration does not describe the whole problem. I also measure how much work continues after a defect could have been detected.

I use this compact comparison:

expected wasted work = Σ(stage cost × probability that an earlier-detectable defect reaches that stage)

If each native build takes 20 minutes and 10% of revisions contain a defect detectable by a two-minute shared gate, starting both builds immediately creates 20 × 0.10 + 20 × 0.10 = 4 expected runner-minutes of avoidable work per revision. The figures are examples, not measurements from this release. The useful part is the decision rule.

Parallelism still has a cost. If three isolated two-minute verification jobs begin together, a quick failure in one does not recover the compute already spent by the other two. I parallelize them when the reduction in clean-run latency outweighs that expected waste. Runner upgrades come later, after separating execution time from queueing, late failures, and repeated work.

The release contract determined the dependency order here. Both native artifacts came from one source revision that had passed static analysis, automated checks, and export. Neither platform depended on the other, so the graph needed one fork:

RELEASE DEPENDENCY ORDERSHARED VERIFICATIONtypechecktestsexport + manifestPARALLEL NATIVE BUILDSiOS: verify inputs, build, signAndroid: verify inputs, build, signDISTRIBUTIONplatform uploads[ Both native jobs consume the export referenced by the verification manifest ]

Restructure the 30-minute mobile pipeline

A vendor-neutral configuration can express the proof boundary without pretending to be deployable syntax for every CI service:

jobs:
  typecheck:
    run: npm run typecheck
 
  tests:
    run: npm run test:ci
 
  export_and_manifest:
    needs: [typecheck, tests]
    run:
      - npm run export:ci
      - ./ci/write-verification-manifest dist/export verification-manifest.json
    artifacts:
      - dist/export
      - verification-manifest.json
 
  ios:
    needs: [export_and_manifest]
    run:
      - ./ci/verify-release-inputs verification-manifest.json dist/export
      - ./ci/build-ios dist/export
      - ./ci/sign-ios
 
  android:
    needs: [export_and_manifest]
    run:
      - ./ci/verify-release-inputs verification-manifest.json dist/export
      - ./ci/build-android dist/export
      - ./ci/sign-android

The commands beginning with ./ci/ represent repository scripts whose implementations depend on the build system. This sample proves only the intended job dependency and handoff: both native jobs wait for verification, validate the manifest, and build from the referenced export. It does not prove that distribution succeeded or that the resulting application was installed.

The corresponding package scripts can remain ordinary:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test:ci": "jest --ci --runInBand",
    "export:ci": "npx expo export --platform all --output-dir dist/export",
    "verify:release": "npm run typecheck && npm run test:ci && npm run export:ci"
  }
}

Locally, an engineer can run:

npm ci
npm run verify:release

npm ci installs from the committed lockfile. The sequential command favors diagnosis and reproducibility; CI may separate independent checks when its measured failure rates justify the extra concurrency. Passing these commands proves that this source tree completed the named checks. It does not establish native signing, upload acceptance, or runtime behavior.

Export belongs inside verification because it exercises bundle generation, configuration, imports, and assets that static analysis or unit tests may not cover. A failed export means downstream native work lacks the application bundle it expects.

Make the manifest concrete

The verification job emits a machine-readable manifest alongside the export artifact. It records the source revision, dependency-lock digest, export digest, workflow-run identity, and toolchain versions:

{
  "schemaVersion": 1,
  "sourceRevision": "8f32c1d",
  "dependencyLockSha256": "8f0ec8d7d43d9d56d2f6d71d55a6b8a34f37f4a4e4a302f7ff1779824d0bc71d",
  "export": {
    "path": "dist/export",
    "sha256": "3c89409592426a0079e41f7c32302e96c4c8b6673308107602f84a6f2b68a2d0"
  },
  "workflowRun": {
    "provider": "ci",
    "runId": "58271",
    "attempt": 1
  },
  "toolchains": {
    "node": "24.5.0",
    "jdk": "21"
  }
}

The verification job computes the export digest after writing the complete export. Each native job checks that its checked-out revision and lockfile match the manifest, confirms the expected workflow-run identity, recalculates the digest of the downloaded export, and consumes that exact directory. A mismatch stops the build.

This unsigned manifest prevents accidental mixing of verification from one run with an export or source tree from another. The export digest binds the manifest data to those bytes only in the mismatch-detection sense. It is not supply-chain proof because an attacker able to replace both files can recalculate the digest. Signing the manifest, protecting the signing identity, and verifying that signature in native jobs are separate hardening steps.

Treat caches as disposable acceleration

GitHub’s documentation distinguishes dependency caches from workflow artifacts: caches accelerate reusable dependencies and outputs, while artifacts preserve files produced by a workflow. Restored caches must be treated as untrusted input, and jobs should remain able to regenerate them.

That distinction matters here. The verified export and its manifest are workflow outputs to preserve and pass between jobs. Package downloads may use caches keyed by relevant dependency inputs:

caches:
  javascript:
    key: js-${os}-${node_version}-${npm_version}-${package_lock_sha256}
    paths:
      - .npm-cache
  ios_dependencies:
    key: ios-${xcode_version}-${podfile_lock_sha256}
    paths:
      - ios/Pods
  android_dependencies:
    key: android-${jdk_version}-${gradle_version}-${gradle_locks_sha256}
    paths:
      - .gradle-cache

These placeholders must be mapped to the chosen CI system. The keys describe invalidation intent; they do not prove cache completeness or correctness.

Compiled-output caches are harder. Source files, plugins, environment variables, SDK behavior, and undeclared configuration can influence compiled products. Matching a key or manifest field does not establish compiled-output lineage. I use narrow keys, regenerate on uncertainty, and schedule clean-build sampling that compares behavior or outputs where deterministic comparison is possible. This costs runner time and still cannot detect every undeclared input, which is an unresolved limitation of compiled-cache reuse.

Preserve release and retry semantics

The accelerated path must still produce production-equivalent packages. Signing and configured distribution uploads remain downstream of each platform build. Development packages and OTA promotion can serve other workflows, but they do not prove the production native packaging path. Upload acceptance also stops short of proving installation or end-user behavior.

Retries should reuse evidence only while its identity remains stable. A transient upload failure can retry the upload of the same signed artifact. A platform build retry may reuse the verified export after rechecking its manifest. Any change to source, the dependency lock, export, workflow identity, or toolchain requires new verification; a deterministic verification failure should not restart unchanged native work.

The available evidence does not show that this design reaches a particular target duration. For the 30-minute mobile pipeline, I would record queue time, execution time, cache status, and failure category per stage, then adjust the dependency graph or runner class where the data shows repeated cost. Clean-build sampling and artifact retention add operational expense, but they make cache behavior observable instead of trusted by convention.

FAQ

Can the iOS and Android jobs start together?

Yes, after both have access to the same verified export and manifest. Neither platform job needs the other platform’s output.

Does the manifest make cached native output trustworthy?

No. It detects mismatched declared inputs and export bytes, but it cannot prove that a compiled cache captured every relevant input; regeneration and clean-build sampling remain necessary.

When should verification run again during a retry?

Run it again when the source revision, dependency lock, export, workflow identity, or toolchain changes. A downstream upload retry can reuse the same signed artifact if those inputs and the artifact itself remain unchanged.

More to read