Skip to content

Commit 1ff6eb6

Browse files
authored
Merge pull request #8798 from Couchers-org/mobile/feature/ota-staging-publish
Add staging OTA publish pipeline
2 parents 1dc9afb + 2d21617 commit 1ff6eb6

3 files changed

Lines changed: 127 additions & 2 deletions

File tree

app/.gitlab-ci.yml

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -859,7 +859,9 @@ build:mobile-ota:
859859
script:
860860
- cd app/mobile
861861
- npm ci
862-
- npm run build:protos
862+
# protos are generated by the `protos` job and pulled in via `needs`; copy the
863+
# grpc-web TS stubs in rather than re-downloading protoc and regenerating.
864+
- mkdir -p proto && cp -a ../proto/gen/ts/proto/. proto/
863865
- npx expo config --type public --json > ota-expo-config.json
864866
- |
865867
for P in $OTA_PLATFORMS; do
@@ -981,6 +983,78 @@ build:devtool-native:
981983
- app/proto/**/*
982984
- app/mobile/**/*
983985

986+
# Staging OTA auto-deploy. Mirrors the web staging deploy (build a static bundle,
987+
# publish to dev-assets, serve from there): bundle the staging variant, stage the
988+
# (unsigned) Expo Updates output, upload bundle+assets to the dev-assets CDN under
989+
# an immutable per-SHA prefix, and print the native_ota_bundles JSON. The staging
990+
# backend (dev-api.couchershq.org/native/ota/manifest) builds the served manifest
991+
# from that flag, with asset URLs pointing at what this job uploaded. Runs on
992+
# develop only. Production publishing goes through the tools/ ops deploy.
993+
build:mobile-ota-staging:
994+
needs: ["protos"]
995+
stage: build
996+
image: node:22
997+
inherit:
998+
default: false
999+
variables:
1000+
APP_VARIANT: staging
1001+
EXPO_PUBLIC_COUCHERS_ENV: preview
1002+
EXPO_PUBLIC_API_BASE_URL: https://dev-api.couchershq.org
1003+
EXPO_PUBLIC_WEB_BASE_URL: https://next.couchershq.org
1004+
OTA_PLATFORMS: "ios android"
1005+
script:
1006+
- cd app/mobile
1007+
- npm ci
1008+
# protos are generated by the `protos` job and pulled in via `needs`; copy the
1009+
# grpc-web TS stubs in rather than re-downloading protoc and regenerating.
1010+
- mkdir -p proto && cp -a ../proto/gen/ts/proto/. proto/
1011+
- npx expo config --type public --json > ota-expo-config.json
1012+
- |
1013+
for P in $OTA_PLATFORMS; do
1014+
rm -rf dist
1015+
npx expo export --platform "$P"
1016+
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))')
1017+
echo "fingerprint($P) = $RV"
1018+
# The asset URLs baked into the manifest must resolve to the upload location
1019+
# below; the CDN host maps <sha>--ota-staging -> the /ota-staging/<sha>/ prefix.
1020+
node scripts/ota-stage.mjs --platform "$P" --runtime-version "$RV" --base-url "https://$CI_COMMIT_SHORT_SHA--ota-staging.$PREVIEW_DOMAIN" --dist dist --out ota-staging-out --expo-config ota-expo-config.json
1021+
done
1022+
- node scripts/ota-growthbook-flag.mjs --out ota-staging-out --platforms "$OTA_PLATFORMS" | tee ota-staging-out/native_ota_bundles.json
1023+
artifacts:
1024+
paths:
1025+
- app/mobile/ota-staging-out/
1026+
expire_in: 1 week
1027+
rules:
1028+
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
1029+
# TEMP: run on this branch to validate the pipeline; revert before merge.
1030+
- if: $CI_COMMIT_BRANCH == "mobile/feature/ota-staging-publish"
1031+
1032+
deploy:mobile-ota-staging:
1033+
needs: ["build:mobile-ota-staging"]
1034+
stage: deploy
1035+
image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
1036+
inherit:
1037+
# no docker login
1038+
default: false
1039+
variables:
1040+
OTA_PLATFORMS: "ios android"
1041+
script:
1042+
- |
1043+
for P in $OTA_PLATFORMS; do
1044+
D="app/mobile/ota-staging-out/$P"
1045+
# Only the bundle + assets go on the CDN; the manifest is served by the backend.
1046+
aws s3 cp "$D/bundle.hbc" "s3://$AWS_PREVIEW_BUCKET/ota-staging/$CI_COMMIT_SHORT_SHA/$P/bundle.hbc" --content-type application/javascript
1047+
if [ -d "$D/assets" ]; then
1048+
aws s3 cp "$D/assets/" "s3://$AWS_PREVIEW_BUCKET/ota-staging/$CI_COMMIT_SHORT_SHA/$P/assets/" --recursive
1049+
fi
1050+
done
1051+
- echo "Bundle + assets uploaded. Paste this into the staging GrowthBook 'native_ota_bundles' flag:"
1052+
- cat app/mobile/ota-staging-out/native_ota_bundles.json
1053+
rules:
1054+
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
1055+
# TEMP: run on this branch to validate the pipeline; revert before merge.
1056+
- if: $CI_COMMIT_BRANCH == "mobile/feature/ota-staging-publish"
1057+
9841058
preview:protos:
9851059
needs: ["protos"]
9861060
stage: preview

app/mobile/app.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const updates =
8585
APP_VARIANT === "devtool"
8686
? { enabled: true }
8787
: APP_VARIANT === "staging"
88-
? { url: "https://dev-api.couchershq.org/mobile/ota/manifest" }
88+
? { url: "https://dev-api.couchershq.org/native/ota/manifest" }
8989
: { url: "https://u.expo.dev/fb4fc9aa-d8b2-45a5-82aa-be05e99b0413" };
9090

9191
export default {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env node
2+
// Turn staged `ota-stage.mjs` output into the `native_ota_bundles` GrowthBook
3+
// flag value the backend's OTA manifest endpoint reads
4+
// (couchers/servicers/bugs.py: GetMobileUpdateManifest). The backend builds the
5+
// served manifest from this value, so the launch asset / asset URLs here are what
6+
// the device fetches — they must point at wherever the bundle was uploaded.
7+
//
8+
// Usage:
9+
// node scripts/ota-growthbook-flag.mjs --out ota-staging-out --platforms "ios android"
10+
//
11+
// Prints a JSON object keyed by platform; paste it into the GrowthBook
12+
// `native_ota_bundles` flag for the target environment.
13+
14+
import fs from "node:fs";
15+
import path from "node:path";
16+
17+
function parseArgs(argv) {
18+
const args = {};
19+
for (let i = 0; i < argv.length; i += 2) {
20+
const key = argv[i];
21+
if (!key.startsWith("--")) throw new Error(`Unexpected argument: ${key}`);
22+
args[key.slice(2)] = argv[i + 1];
23+
}
24+
return args;
25+
}
26+
27+
function main() {
28+
const args = parseArgs(process.argv.slice(2));
29+
const outRoot = path.resolve(args.out ?? "ota-out");
30+
const platforms = (args.platforms ?? "ios android")
31+
.split(/\s+/)
32+
.filter(Boolean);
33+
34+
const flag = {};
35+
for (const platform of platforms) {
36+
const manifestPath = path.join(outRoot, platform, "manifest.json");
37+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
38+
flag[platform] = {
39+
id: manifest.id,
40+
// For operator reference: the published bundle's fingerprint. The installed
41+
// store build must share it, or the JS won't be compatible.
42+
runtime_version: manifest.runtimeVersion,
43+
launch_asset: manifest.launchAsset,
44+
assets: manifest.assets,
45+
};
46+
}
47+
48+
process.stdout.write(JSON.stringify(flag, null, 2) + "\n");
49+
}
50+
51+
main();

0 commit comments

Comments
 (0)