Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions app/.gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,152 @@ deploy:mobile-ota-staging:
# TEMP: run on this branch to validate the pipeline; revert before merge.
- if: $CI_COMMIT_BRANCH == "mobile/v1.1.20"

# Rebuild and submit the *production* store app when its fingerprint changes — the
# native counterpart to the OTA publish below. A signed OTA only applies to an
# installed build whose runtimeVersion (Expo fingerprint) matches, so a native dep
# / app.config.js / Expo SDK change (the ~5% case OTA can't serve) needs a fresh
# store build carrying the new fingerprint. scripts/production-build.sh builds on
# EAS and auto-submits:
# iOS -> App Store Connect (lands in TestFlight + as an available build; it
# is NOT submitted for App Store review and no release notes change —
# do that by hand in App Store Connect).
# Android -> the Play *internal* testing track (submit profile `production`,
# track: internal); promote to public production by hand in the Play
# Console.
# Neither path makes anything public on its own. It no-ops when the freshly
# computed fingerprint matches the last-built app, recorded per platform under
# s3://$AWS_PREVIEW_BUCKET/production-builds/. Requires the EXPO_TOKEN CI variable.
build:production-native:
needs: ["protos"]
stage: build
image: node:22
inherit:
default: false
variables:
# Compute the fingerprint as the production variant so it matches the EAS build
# (app.config.js branches on APP_VARIANT) and the OTA publish job's fingerprint.
APP_VARIANT: production
before_script:
- apt-get update -qq && apt-get install -y --no-install-recommends awscli curl
script:
- cd app/mobile
- npm ci
- npm run build:protos
- npm install -g eas-cli
# Separate lines (not a loop) so an iOS failure fails the job instead of being
# masked by a later Android success.
- bash scripts/production-build.sh ios
- bash scripts/production-build.sh android
rules:
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
# TEMP: run on this branch to validate the pipeline; revert before merge.
- if: $CI_COMMIT_BRANCH == "mobile/v1.1.20"

# ─── Production native OTA publish (decoupled from the backend/web deploy) ───
#
# Mirrors the web static release flow (build a bundle -> upload a tarball to the
# preview CDN -> record a manifest in the deploy bucket), but on its OWN manifest
# stream (deploy/native-ota-manifests/) and WITHOUT depending on any release:*
# job, so a JS-only over-the-air update ships without a backend/web deploy.
#
# CI only stages UNSIGNED bundles. Signing and the push to the prod CDN happen
# later, in the tools/ deploy lambda's publish_native_ota action — the private
# signing key never touches CI. The manifest's asset URLs are baked with the
# literal placeholder __OTA_BUNDLE_BASE__; the publish step substitutes the real
# prod CDN base (<cdn-root>/<ota-version>, e.g. v1.2.18355.fc38c23d) before
# signing, so it alone owns where the bundle lives and the URLs can't drift from
# the upload path.
build:mobile-ota-prod:
needs: ["protos"]
stage: build
image: node:22
inherit:
default: false
variables:
APP_VARIANT: production
EXPO_PUBLIC_COUCHERS_ENV: prod
EXPO_PUBLIC_API_BASE_URL: https://api.couchers.org
EXPO_PUBLIC_WEB_BASE_URL: https://couchers.org
OTA_PLATFORMS: "ios android"
script:
- cd app/mobile
- npm ci
# protos are generated by the `protos` job and pulled in via `needs`; copy the
# grpc-web TS stubs in rather than re-downloading protoc and regenerating.
- mkdir -p proto && cp -a ../proto/gen/ts/proto/. proto/
- npx expo config --type public --json > ota-expo-config.json
- mkdir -p native-ota-dist
- |
for P in $OTA_PLATFORMS; do
rm -rf dist
npx expo export --platform "$P"
RV=$(npx expo-updates fingerprint:generate --platform "$P" 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).hash))')
echo "fingerprint($P) = $RV"
# Asset URLs are baked with the __OTA_BUNDLE_BASE__ placeholder; the publish
# lambda replaces it with the real prod CDN base before signing.
node scripts/ota-stage.mjs --platform "$P" --runtime-version "$RV" --base-url '__OTA_BUNDLE_BASE__' --dist dist --out ota-prod-out --expo-config ota-expo-config.json
# Tar the staged per-platform output (manifest, manifest.content-type,
# bundle.hbc, assets/) so the publish lambda pulls one immutable artifact.
tar -C ota-prod-out/"$P" -czf "native-ota-dist/native-ota-$P-$CI_PIPELINE_ID-$CI_COMMIT_SHA.tar.gz" .
done
artifacts:
paths:
- app/mobile/native-ota-dist/
expire_in: 1 week
rules:
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
# TEMP: run on this branch to validate the pipeline; revert before merge.
- if: $CI_COMMIT_BRANCH == "mobile/v1.1.20"

preview:mobile-ota-prod:
needs: ["build:mobile-ota-prod"]
stage: preview
image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
inherit:
# no docker login
default: false
script:
- aws s3 cp app/mobile/native-ota-dist/ s3://$AWS_PREVIEW_BUCKET/native-ota/$CI_COMMIT_SHORT_SHA/ --recursive
- echo "Native OTA bundles staged at https://$CI_COMMIT_SHORT_SHA--native-ota.$PREVIEW_DOMAIN/"
rules:
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
# TEMP: run on this branch to validate the pipeline; revert before merge.
- if: $CI_COMMIT_BRANCH == "mobile/v1.1.20"

# Own manifest stream, built and uploaded independently of build:deploy-manifest /
# deploy:deploy-info so a native OTA is never gated on a backend/web release.
build:native-ota-manifest:
needs: []
stage: build
variables:
GIT_DEPTH: 0 # needed for COMMIT_NUMBER / DISPLAY_VERSION in default before_script
script:
- sh app/deployment/build_native_ota_manifest.sh artifacts/native-ota-info
artifacts:
paths:
- artifacts/native-ota-info
rules:
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
# TEMP: run on this branch to validate the pipeline; revert before merge.
- if: $CI_COMMIT_BRANCH == "mobile/v1.1.20"

deploy:native-ota-info:
needs:
- job: build:native-ota-manifest
- job: preview:mobile-ota-prod
artifacts: false
stage: deploy
image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
inherit:
# no docker login
default: false
script:
- aws s3 cp artifacts/native-ota-info/ "s3://$AWS_DEPLOY_BUCKET/" --recursive
rules:
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
# TEMP: run on this branch to validate the pipeline; revert before merge.
- if: $CI_COMMIT_BRANCH == "mobile/v1.1.20"

preview:protos:
needs: ["protos"]
stage: preview
Expand Down
61 changes: 61 additions & 0 deletions app/deployment/build_native_ota_manifest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/bin/sh
#
# Builds a native OTA deployment manifest JSON file.
#
# This is the mobile over-the-air counterpart to build_manifest.sh, kept on its
# OWN manifest stream (deploy/native-ota-manifests/) and produced/uploaded
# independently of the backend/web release, so a JS-only OTA can be published
# without a platform deploy. It records the per-platform bundle tarballs that
# build:mobile-ota-prod produced and preview:mobile-ota-prod uploaded to the
# preview CDN. The tools/ publish_native_ota lambda later pulls a chosen
# manifest, signs the bundle's Expo manifest, and pushes it to the prod CDN.
#
# Expected environment variables (set by GitLab CI and default before_script):
# CI_COMMIT_SHA, CI_COMMIT_SHORT_SHA, CI_COMMIT_REF_NAME, CI_COMMIT_TIMESTAMP,
# CI_PIPELINE_ID, SLUG, COMMIT_NUMBER, DISPLAY_VERSION, PREVIEW_DOMAIN
#
# Usage:
# build_native_ota_manifest.sh <output_dir>
#
# Output:
# <output_dir>/deploy/native-ota-manifests/<padded_commit_number>-<short_sha>.json

set -eu

OUTPUT_DIR="$1"

PADDED=$(printf "%06d" "$COMMIT_NUMBER")
MANIFEST_KEY="deploy/native-ota-manifests/${PADDED}-${CI_COMMIT_SHORT_SHA}.json"
OUTPUT_PATH="${OUTPUT_DIR}/${MANIFEST_KEY}"

mkdir -p "$(dirname "$OUTPUT_PATH")"

BASE="https://${CI_COMMIT_SHORT_SHA}--native-ota.${PREVIEW_DOMAIN}"

# Descriptive, immutable release name the publish step uses as the CDN path
# segment, e.g. v1.2.18355.fc38c23d — the release display version (app/version
# + commit number) plus the short commit hash. Built from clean components so it
# keeps this form regardless of which branch the bundle was built on.
OTA_VERSION="$(cat app/version).${COMMIT_NUMBER}.${CI_COMMIT_SHORT_SHA}"

cat > "$OUTPUT_PATH" <<EOF
{
"commit_sha": "${CI_COMMIT_SHA}",
"commit_short_sha": "${CI_COMMIT_SHORT_SHA}",
"commit_number": ${COMMIT_NUMBER},
"slug": "${SLUG}",
"display_version": "${DISPLAY_VERSION}",
"ota_version": "${OTA_VERSION}",
"branch": "${CI_COMMIT_REF_NAME}",
"timestamp": "${CI_COMMIT_TIMESTAMP}",
"pipeline_id": "${CI_PIPELINE_ID}",
"native_ota": {
"ios": "${BASE}/native-ota-ios-${CI_PIPELINE_ID}-${CI_COMMIT_SHA}.tar.gz",
"android": "${BASE}/native-ota-android-${CI_PIPELINE_ID}-${CI_COMMIT_SHA}.tar.gz"
}
}
EOF

cat "$OUTPUT_PATH"
echo ""
echo "Written to $OUTPUT_PATH"
2 changes: 2 additions & 0 deletions app/mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ yarn-error.*
# macOS
.DS_Store
*.pem
# but the OTA code-signing certificate (public) is committed; the private key is not
!certs/certificate.pem

# local env files
.env*.local
Expand Down
15 changes: 11 additions & 4 deletions app/mobile/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,22 @@ const intentFilters = variant.linkHost
// baked in and manifests are served unsigned over HTTPS.
//
// Staging points at our self-hosted OTA backend (cut 1: validating the Expo
// Updates protocol + native<->backend transport, unsigned). Production has OTA
// disabled until the self-hosted backend is proven on staging, then it points
// here too.
// Updates protocol + native<->backend transport, unsigned).
//
// Production requires a code-signed manifest: the tools/ publish lambda signs
// each bundle with the private key, and this cert (its public half) lets the
// device reject anything not signed by it. keyid/alg must match the lambda's
// OTA_SIGNING_KEY_ID and rsa-v1_5-sha256.
const updates =
APP_VARIANT === "devtool"
? { enabled: true }
: APP_VARIANT === "staging"
? { url: "https://dev-api.couchershq.org/native/ota/manifest" }
: { enabled: false };
: {
url: "https://api.couchers.org/native/ota/manifest",
codeSigningCertificate: "./certs/certificate.pem",
codeSigningMetadata: { keyid: "main", alg: "rsa-v1_5-sha256" },
};

export default {
name: variant.name,
Expand Down
18 changes: 18 additions & 0 deletions app/mobile/certs/certificate.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC3TCCAcWgAwIBAgIJXxmaYUSc1wmtMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV
BAMTDENvdWNoZXJzLm9yZzAgFw0yNjA1MjUxNjU2MjZaGA8yMTI2MDUyNTE2NTYy
NlowFzEVMBMGA1UEAxMMQ291Y2hlcnMub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAue5pylwy90g9rMH2rr0ohpR686MWzvVsIcWd1aDQBw1xTm3v
2nuADp5/djn4FP7/kdRgIdY6Z/ktHZwCUAp0txMDTZBlzrAgUu/pfI0oUAu10j3+
2IbSCzHusDuaManwYQnILxi1ics0krSeKkg1xypbcLj50zuI7ACayN8vLLbqFwX+
OrVuTHhiRM3kXd89IxWoigaKRmmZ/EQrEmzqInvS5XN57wum5cIPiQR4c6IjuSjf
jz3QtT9Gmw2P5uyR2kY10fedBvN+HblfpeC0GD+/EgCI+DdnqxzM8hewfFM+D79p
QB+5upEDGXH0Ll8O90gdQb3+dnZkHqsaMNXGEQIDAQABoyowKDAOBgNVHQ8BAf8E
BAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwMwDQYJKoZIhvcNAQELBQADggEB
AAuf4G7cxlkwMsr+GAdzM8tanAtkem/edVgXZ3SAeamn+hW+fp1DD7TFrghi0xTq
KVVg0RgTTQ2Nnd7cJexGKiATZ0+810KUWnYL43ZwjOErYcWYtP8TOlk63CDlSgvj
r8hvW+lXWXHpWQy0hJ7Azs56g+ufbxsQrJ5+qIzUSUu1S+XME5zFXeh/q8fzCmvE
pFeyhRHioZyqHKnBd1fdoJ0MoxbsxV5F5KJT95VIFZi3NtbaQgNUHK/K2PW8frzY
D/qvsIRBNjpcMQOOrotXpkXwPaKZgPtoCD4h5EnIflIMP5Ka1JrCgkCSuKypyBcS
8FD9CNIt7L9rOJudDtGyQUE=
-----END CERTIFICATE-----
64 changes: 64 additions & 0 deletions app/mobile/scripts/production-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Rebuild and submit the production store app on EAS when its fingerprint changes.
#
# A signed OTA only applies to an installed build whose runtimeVersion (the Expo
# fingerprint) matches it. So when a native change moves the production
# fingerprint — a new native module, an app.config.js change, or an Expo SDK bump
# — we need a fresh store build carrying it before an OTA on that fingerprint can
# land; pure JS/TS changes ship over the air. We record the last-built fingerprint
# per platform in S3 and compare; on a change we build and auto-submit, recording
# the marker only once that succeeds so a failed build is retried.
#
# iOS: eas build --auto-submit -> App Store Connect. The build lands in
# TestFlight and as an available build; it is NOT submitted for App
# Store review and no release notes are changed — do that by hand.
# Android: eas build --auto-submit -> the Play *internal* testing track (the
# `production` submit profile sets track: internal). Promote to the
# public production track by hand in the Play Console.
#
# Neither platform makes anything public on its own.
#
# Usage: production-build.sh <ios|android>
# Env: EXPO_TOKEN EAS auth (build + submit scope)
# AWS_PREVIEW_BUCKET the couchers-dev-assets bucket (holds the markers)
# plus the AWS creds the aws CLI reads from the environment
# Run from app/mobile (eas.json, the project, and node_modules must be present).
set -euo pipefail

PLATFORM="${1:?usage: production-build.sh <ios|android>}"
case "$PLATFORM" in
ios | android) ;;
*)
echo "platform must be ios or android (got $PLATFORM)" >&2
exit 1
;;
esac

MARKER="s3://${AWS_PREVIEW_BUCKET}/production-builds/${PLATFORM}.fingerprint"

# Read a single JSON field from stdin with node (no jq in the node:22 image).
json_field() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);process.stdout.write(String(eval("j"+process.argv[1])??""))})' "$1"; }

# Fingerprint of the working tree — the same value EAS stamps as runtimeVersion
# and the OTA publish job bakes into the manifest. APP_VARIANT=production (set by
# the job) selects the production bundle id / scheme / signed updates config.
CURRENT="$(npx expo-updates fingerprint:generate --platform "$PLATFORM" 2>/dev/null | json_field '.hash')"
echo "current $PLATFORM fingerprint: $CURRENT"

PREVIOUS="$(aws s3 cp "$MARKER" - 2>/dev/null || true)"
echo "last-built $PLATFORM fingerprint: ${PREVIOUS:-<none>}"

if [ -n "$PREVIOUS" ] && [ "$PREVIOUS" = "$CURRENT" ]; then
echo "Fingerprint unchanged for $PLATFORM — the live production build still matches, skipping."
exit 0
fi

echo "Fingerprint changed for $PLATFORM — building and submitting a new production app."
# --auto-submit uses the submit profile named like the build profile (production):
# iOS -> App Store Connect, Android -> the internal track.
eas build --platform "$PLATFORM" --profile production --auto-submit --non-interactive

# Record the new fingerprint only after a successful build+submit, so a failure is
# retried on the next pipeline instead of being marked done.
printf '%s' "$CURRENT" | aws s3 cp - "$MARKER"
echo "Recorded $PLATFORM fingerprint $CURRENT — production build is up to date."
Loading