Bassam Ismail
Why Mobile Releases Should Start From Tags, Not Every Commit
Engineering

Why Mobile Releases Should Start From Tags, Not Every Commit

6 min read

An ordinary merge exposed the flaw in our design: it could accidentally become a signed release. The pipeline had blurred code verification with authorization to distribute. We therefore made mobile release tags the explicit promotion event for one reviewed revision, while ordinary commits remained unprivileged.

TL;DR

Use mobile release tags to request promotion, then verify the tagged revision in a job with no distribution credentials. Put signing and delivery in separate platform jobs that reference a protected environment, so credentials become available only after its rules pass.

Mobile release tags as an authority boundary

Continuous integration asks whether a change is fit to merge. Mobile promotion asks whether a named revision may use signing identities and enter an external distribution system. Those questions need different permissions even when they run similar checks.

VERIFICATION AND PROMOTIONcommit or PRunprivileged checksprotected tagpromotion requestfresh verificationno release secretsprotected environmentplatform signingdestination outcome[ Release authority begins only after verification and environment approval ]

A tag is useful because it creates a named, auditable event tied to a version. It is not inherently trustworthy. Repository controls must prevent unauthorized creation, movement, or deletion of release tags, and the pipeline must confirm that the ref resolves to the revision being promoted.

The security boundary must also exist in the execution model. A shell test inside a credential-bearing job is too late because many build systems inject secrets when the job starts. That test can detect a bad ref, but it cannot retract credentials already exposed to the process.

I separate the workflow into an unprivileged verification job and credential-bearing promotion jobs. The first job validates the event, checks the tag policy, installs locked dependencies, and repeats the required gates. It receives no signing or destination secrets. The privileged jobs run only after it succeeds and reference a protected environment.

GitHub documents that environment protection rules run before a job can access environment secrets. Deployment branch and tag rules can also restrict which refs may deploy. Repository tag protection still matters because an environment rule controls deployment eligibility, not the entire lifecycle of the tag.

Here is the execution shape. The abbreviated platform scripts must perform native compilation, signing, hashing, and delivery; the workflow alone proves only job separation, runner selection, dependency ordering, and environment placement.

name: mobile-promotion
 
on:
  push:
    tags:
      - "v*"
 
permissions:
  contents: read
 
jobs:
  verify-release:
    runs-on: ubuntu-latest
    outputs:
      release_tag: ${{ steps.tag.outputs.release_tag }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref }}
          fetch-depth: 0
      - id: tag
        env:
          RELEASE_TAG: ${{ github.ref_name }}
        run: |
          set -eu
          node scripts/validate-release-tag.mjs "$RELEASE_TAG"
          git rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}" >/dev/null
          printf 'release_tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test -- --runInBand
 
  promote-ios:
    needs: verify-release
    runs-on: macos-latest
    environment: mobile-production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref }}
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx expo prebuild --non-interactive --platform ios
      - env:
          RELEASE_TAG: ${{ needs.verify-release.outputs.release_tag }}
          IOS_SIGNING_CREDENTIAL: ${{ secrets.IOS_SIGNING_CREDENTIAL }}
        run: ./scripts/build-and-deliver-ios.sh "$RELEASE_TAG"
 
  promote-android:
    needs: verify-release
    runs-on: ubuntu-latest
    environment: mobile-production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref }}
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx expo prebuild --non-interactive --platform android
      - env:
          RELEASE_TAG: ${{ needs.verify-release.outputs.release_tag }}
          ANDROID_SIGNING_CREDENTIAL: ${{ secrets.ANDROID_SIGNING_CREDENTIAL }}
        run: ./scripts/build-and-deliver-android.sh "$RELEASE_TAG"

The iOS job uses a macOS-capable builder because iOS compilation depends on Apple tooling. Android runs independently on a suitable Linux builder. Both jobs reference the protected environment, but each should receive only its own narrowly scoped credentials. Where a destination supports workload identity or another short-lived exchange, I prefer that over stored long-lived keys. The sample does not prove that the scripts scope credentials correctly or that either destination accepted an artifact.

The tag validator should use a pinned parser rather than a permissive shell glob. It must encode the team’s decision about prereleases and build metadata, then reject tags outside that policy. If releases are limited to revisions reachable from an approved branch, that check belongs in the unprivileged job or repository control plane as well.

Record evidence, not optimism

Fresh verification matters because an earlier green result may have used another revision, toolchain, or dependency set. For React Native work that generates native projects, expo prebuild is itself a build input. An unchanged tag does not guarantee an unchanged native project if the image, packages, configuration, or remote dependencies drift.

For each attempt, I record the tag and commit SHA, gate results, immutable build-image identity, lockfile digest, toolchain versions, relevant configuration identifiers, and the final artifact digest for each platform. Any dependency that cannot be pinned is recorded as a limitation. Destination requests and confirmed outcomes remain separate fields.

release:
  tag: v1.4.2
  commit: 8f4c2f92c6e27a61d0480f2b2b55cd5d80e6a450
  verification: passed
  inputs:
    lockfile_sha256: "<digest>"
    build_image: "<immutable-image-id>"
  artifacts:
    ios:
      sha256: "<digest>"
      destination_status: requested
    android:
      sha256: "<digest>"
      destination_status: requested
  ota_enabled: false

This record proves only what its evidence supports. requested must not silently become published; a destination response must establish acceptance of the matching digest. Installation and end-user behavior sit beyond that proof boundary.

Retries use the same version only when the revision and recorded inputs remain unchanged. An infrastructure failure can then resume without pretending that a different build is the original one. A source correction needs a new commit and tag. If one destination succeeds while the other fails, the record preserves the partial outcome for an operator to assess.

Defer OTA until the native path works

OTA adds another promotion route but still depends on a compatible native application. I would not introduce it before native generation, platform signing, and destination reporting have been exercised successfully.

RELEASE DEPENDENCYLATEROTA approval and compatibilityNATIVE PROMOTIONiOS signingAndroid signingdestination evidenceFOUNDATIONtag verificationlocked inputs[ OTA policy depends on a proven native base ]

The eventual OTA policy must define eligible runtime versions, approval authority, and recovery when JavaScript expects native capability absent from an installed binary. The provider and manual release flag were unresolved in this design, so claiming a complete OTA mechanism would be fiction.

The operational cost is real. Protected environments add approval latency, macOS capacity can constrain iOS throughput, and release ownership needs coverage when an approver is unavailable. Reproducibility also remains bounded by any remote input the team cannot pin. Those costs are preferable to giving an ordinary verification job standing authority to sign and distribute software.

FAQ

Can the verification job use signing secrets if it promises not to call them?

No. Once a job can read a secret, a script convention is not a security boundary. Keep verification unprivileged and place credentials only in jobs gated by the protected environment.

May an infrastructure retry reuse the same release tag?

Yes, if the commit and recorded inputs are unchanged and policy reserves the tag after first observation. Any source or material input change requires a new release decision.

Does a successful promotion job prove that users received the release?

No. It proves only the completed stages and recorded destination response. Installation and user-visible behavior require evidence from later boundaries.

More to read