Why Mobile Releases Should Start From Tags, Not Every Commit
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.
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: falseThis 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.
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.
