Skip to content

Commit 215e915

Browse files
aapelivclaude
andcommitted
mobile: post Dev Tool OTA preview to the PR from GitLab CI
Add a preview:pr-comment job that runs after the OTA S3 upload (needs: preview:mobile-ota) so every link it posts is already live, resolves the GitHub PR from the commit SHA, generates a QR (Python) it hosts on our own dev-assets/CloudFront, and upserts a sticky preview comment. Replaces the GitHub Action approach, which couldn't order itself after the upload. Structured so non-OTA preview sections can be added later. No-ops without GITHUB_PREVIEW_TOKEN or an open PR so it never reds the pipeline. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9e78577 commit 215e915

2 files changed

Lines changed: 194 additions & 0 deletions

File tree

app/.gitlab-ci.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,7 @@ build:mobile-ota:
876876
changes:
877877
- app/proto/**/*
878878
- app/mobile/**/*
879+
- app/scripts/**/*
879880

880881
preview:mobile-ota:
881882
needs: ["build:mobile-ota"]
@@ -906,6 +907,33 @@ preview:mobile-ota:
906907
changes:
907908
- app/proto/**/*
908909
- app/mobile/**/*
910+
- app/scripts/**/*
911+
912+
# Aggregate per-PR preview links into one sticky GitHub PR comment. Runs after the
913+
# upload jobs (needs) so every link/QR it posts is already live on CloudFront.
914+
# Currently surfaces the mobile Dev Tool OTA preview; add more sections (web,
915+
# coverage, ...) in app/scripts/pr_preview_comment.py as previews grow. No-ops when
916+
# GITHUB_PREVIEW_TOKEN is unset or there's no open PR, so it never reds the pipeline.
917+
preview:pr-comment:
918+
needs:
919+
- job: preview:mobile-ota
920+
artifacts: false
921+
stage: preview
922+
image: python:3.12-slim
923+
inherit:
924+
default: false
925+
variables:
926+
OTA_PLATFORMS: "ios"
927+
script:
928+
- pip install --quiet --no-cache-dir "qrcode[pil]" requests boto3
929+
- python app/scripts/pr_preview_comment.py
930+
rules:
931+
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH == $RELEASE_BRANCH)
932+
- if: ($BUILD_MOBILE == "true") && ($CI_COMMIT_BRANCH != $RELEASE_BRANCH)
933+
changes:
934+
- app/proto/**/*
935+
- app/mobile/**/*
936+
- app/scripts/**/*
909937

910938
preview:protos:
911939
needs: ["protos"]

app/scripts/pr_preview_comment.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
"""Post (or update) a sticky preview comment on the GitHub PR for this pipeline.
3+
4+
Runs in GitLab CI after the preview/upload jobs so every link it posts is already
5+
live. Currently surfaces the per-branch mobile Dev Tool OTA preview (QR + deep
6+
link); each preview is a section, so web/coverage/etc. can be appended as the
7+
pipeline grows. No-ops (exit 0) when there is no token or no open PR, so it never
8+
turns a pipeline red.
9+
"""
10+
11+
import io
12+
import os
13+
import sys
14+
import urllib.parse
15+
16+
import boto3
17+
import qrcode
18+
import requests
19+
20+
MARKER = "<!-- couchers-preview-bot -->"
21+
GITHUB_API = "https://api.github.com"
22+
23+
24+
def env(name, default=None, *, required=False):
25+
value = os.environ.get(name, default)
26+
if required and not value:
27+
sys.exit(f"missing required env var {name}")
28+
return value
29+
30+
31+
def gh_headers(token):
32+
return {
33+
"Authorization": f"Bearer {token}",
34+
"Accept": "application/vnd.github+json",
35+
"X-GitHub-Api-Version": "2022-11-28",
36+
}
37+
38+
39+
def deep_link(manifest_url):
40+
return "couchers-devtool://expo-development-client/?url=" + urllib.parse.quote(
41+
manifest_url, safe=""
42+
)
43+
44+
45+
def make_qr_png(data):
46+
qr = qrcode.QRCode(
47+
border=2, box_size=8, error_correction=qrcode.constants.ERROR_CORRECT_M
48+
)
49+
qr.add_data(data)
50+
qr.make(fit=True)
51+
buf = io.BytesIO()
52+
qr.make_image(fill_color="black", back_color="white").save(buf, format="PNG")
53+
return buf.getvalue()
54+
55+
56+
def mobile_ota_section(s3, bucket, short_sha, domain, platforms):
57+
lines = [
58+
"### Mobile Dev Tool preview",
59+
"",
60+
"Scan with your phone camera (or tap the deep link on the device) to open this "
61+
"branch in the installed **Dev Tool** dev client.",
62+
]
63+
for platform in platforms:
64+
manifest_url = f"https://{short_sha}--ota.{domain}/{platform}/manifest"
65+
link = deep_link(manifest_url)
66+
s3.put_object(
67+
Bucket=bucket,
68+
Key=f"ota/{short_sha}/{platform}/qr.png",
69+
Body=make_qr_png(link),
70+
ContentType="image/png",
71+
)
72+
qr_url = f"https://{short_sha}--ota.{domain}/{platform}/qr.png"
73+
lines += [
74+
"",
75+
f"**{platform}**",
76+
"",
77+
f'<img src="{qr_url}" alt="QR to open the {platform} build" width="180" height="180" />',
78+
"",
79+
"<details><summary>Deep link</summary>",
80+
"",
81+
"```",
82+
link,
83+
"```",
84+
"</details>",
85+
]
86+
return "\n".join(lines)
87+
88+
89+
def build_body(sections, sha, pipeline_url):
90+
parts = [MARKER, "## Preview builds", ""]
91+
parts += [section for section in sections if section]
92+
footer = f"commit `{sha[:8]}`"
93+
if pipeline_url:
94+
footer += f" · [pipeline]({pipeline_url})"
95+
parts += ["", "---", f"<sub>{footer}</sub>"]
96+
return "\n".join(parts)
97+
98+
99+
def find_open_pr(repo, sha, token):
100+
resp = requests.get(
101+
f"{GITHUB_API}/repos/{repo}/commits/{sha}/pulls",
102+
headers=gh_headers(token),
103+
timeout=30,
104+
)
105+
resp.raise_for_status()
106+
for pr in resp.json():
107+
if pr.get("state") == "open":
108+
return pr["number"]
109+
return None
110+
111+
112+
def upsert_comment(repo, pr, body, token):
113+
resp = requests.get(
114+
f"{GITHUB_API}/repos/{repo}/issues/{pr}/comments",
115+
headers=gh_headers(token),
116+
params={"per_page": 100},
117+
timeout=30,
118+
)
119+
resp.raise_for_status()
120+
existing = next((c for c in resp.json() if MARKER in (c.get("body") or "")), None)
121+
if existing:
122+
resp = requests.patch(
123+
f"{GITHUB_API}/repos/{repo}/issues/comments/{existing['id']}",
124+
headers=gh_headers(token),
125+
json={"body": body},
126+
timeout=30,
127+
)
128+
else:
129+
resp = requests.post(
130+
f"{GITHUB_API}/repos/{repo}/issues/{pr}/comments",
131+
headers=gh_headers(token),
132+
json={"body": body},
133+
timeout=30,
134+
)
135+
resp.raise_for_status()
136+
return resp.json().get("html_url")
137+
138+
139+
def main():
140+
token = env("GITHUB_PREVIEW_TOKEN")
141+
if not token:
142+
print("GITHUB_PREVIEW_TOKEN not set - skipping preview comment.")
143+
return
144+
145+
repo = env("GITHUB_REPO", "Couchers-org/couchers")
146+
sha = env("CI_COMMIT_SHA", required=True)
147+
short_sha = env("CI_COMMIT_SHORT_SHA", required=True)
148+
domain = env("PREVIEW_DOMAIN", required=True)
149+
pipeline_url = env("CI_PIPELINE_URL", "")
150+
platforms = env("OTA_PLATFORMS", "ios").split()
151+
152+
pr = find_open_pr(repo, sha, token)
153+
if not pr:
154+
print(f"No open PR for {sha} - skipping preview comment.")
155+
return
156+
157+
s3 = boto3.client("s3")
158+
bucket = env("AWS_PREVIEW_BUCKET", required=True)
159+
160+
sections = [mobile_ota_section(s3, bucket, short_sha, domain, platforms)]
161+
url = upsert_comment(repo, pr, build_body(sections, sha, pipeline_url), token)
162+
print(f"Posted preview comment to PR #{pr}: {url}")
163+
164+
165+
if __name__ == "__main__":
166+
main()

0 commit comments

Comments
 (0)