Skip to content

Commit f1c8162

Browse files
committed
ios: update changelog in testflight
1 parent d1b903b commit f1c8162

File tree

3 files changed

+261
-5
lines changed

3 files changed

+261
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,4 @@ ui/StatusQ/src/StatusQ/Core/TestConfig.qml
113113
mobile/bin/
114114
mobile/lib/
115115
mobile/build/
116+
scripts/node_modules/

ci/Jenkinsfile.ios

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ pipeline {
2121
description: 'Level of verbosity based on nimbus-build-system setup.',
2222
choices: ['0','1','2','3']
2323
)
24+
string(
25+
name: 'TESTFLIGHT_POLL_TIMEOUT',
26+
description: 'TestFlight build polling timeout in minutes.',
27+
defaultValue: '30'
28+
)
29+
string(
30+
name: 'TESTFLIGHT_POLL_INTERVAL',
31+
description: 'TestFlight build polling interval in seconds.',
32+
defaultValue: '30'
33+
)
2434
}
2535

2636
options {
@@ -69,6 +79,8 @@ pipeline {
6979
STATUS_IOS_APP_ARTIFACT = "pkg/${utils.pkgFilename(ext: 'ipa', arch: getArch(), version: env.VERSION, type: env.APP_TYPE)}"
7080
STATUS_IOS_APP = "${WORKSPACE}/mobile/bin/ios/qt6/Status.app"
7181
STATUS_IOS_IPA = "${WORKSPACE}/mobile/bin/ios/qt6/Status.ipa"
82+
TESTFLIGHT_POLL_TIMEOUT = "${params.TESTFLIGHT_POLL_TIMEOUT}"
83+
TESTFLIGHT_POLL_INTERVAL = "${params.TESTFLIGHT_POLL_INTERVAL}"
7284
}
7385

7486
stages {
@@ -108,19 +120,33 @@ pipeline {
108120
}
109121

110122
stage('Upload to TestFlight') {
111-
when {
112-
expression { return isReleaseBranch }
113-
}
114123
steps {
115124
script {
116-
app.uploadToTestFlight(ipaPath: env.STATUS_IOS_APP_ARTIFACT)
125+
def changelog = ""
126+
if (env.CHANGE_ID) {
127+
def shortCommit = env.GIT_COMMIT.take(7)
128+
changelog = "PR #${env.CHANGE_ID} - ${shortCommit}\nBuild: #${env.BUILD_NUMBER}\n\nView PR: https://github.com/status-im/status-desktop/pull/${env.CHANGE_ID}"
129+
} else if (isReleaseBranch) {
130+
changelog = "Release ${env.VERSION}\nBuild: #${env.BUILD_NUMBER}"
131+
} else {
132+
def shortCommit = env.GIT_COMMIT.take(7)
133+
changelog = "Branch: ${env.BRANCH_NAME}\nCommit: ${shortCommit}\nBuild: #${env.BUILD_NUMBER}"
134+
}
135+
136+
app.uploadToTestFlight(
137+
ipaPath: env.STATUS_IOS_APP_ARTIFACT,
138+
buildVersion: env.VERSION,
139+
changelog: changelog,
140+
pollTimeout: env.TESTFLIGHT_POLL_TIMEOUT,
141+
pollInterval: env.TESTFLIGHT_POLL_INTERVAL
142+
)
117143
}
118144
}
119145
}
120146

121147
stage('Parallel Upload') {
122148
parallel {
123-
stage('Upload') {
149+
stage('Upload to S3') {
124150
steps {
125151
script {
126152
env.PKG_URL = s5cmd.upload(env.STATUS_IOS_APP_ARTIFACT)

scripts/testflight-changelog.mjs

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env node
2+
3+
import { readFileSync } from 'fs'
4+
import https from 'https'
5+
import jwt from 'jsonwebtoken'
6+
7+
const APP_BUNDLE_ID = 'app.status.mobile'
8+
9+
const ASC_KEY_ID = process.env.ASC_KEY_ID
10+
const ASC_ISSUER_ID = process.env.ASC_ISSUER_ID
11+
const ASC_KEY_FILE = process.env.ASC_KEY_FILE
12+
const BUILD_VERSION = process.env.BUILD_VERSION
13+
const CHANGELOG = process.env.CHANGELOG
14+
const POLL_TIMEOUT_MINUTES = parseInt(process.env.POLL_TIMEOUT_MINUTES || '30', 10)
15+
const POLL_INTERVAL_SECONDS = parseInt(process.env.POLL_INTERVAL_SECONDS || '30', 10)
16+
17+
if (!ASC_KEY_ID || !ASC_ISSUER_ID || !ASC_KEY_FILE) {
18+
console.error('ERROR: Missing required environment variables (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_FILE)')
19+
process.exit(1)
20+
}
21+
22+
if (!BUILD_VERSION || !CHANGELOG) {
23+
console.error('ERROR: Missing BUILD_VERSION or CHANGELOG environment variable')
24+
process.exit(1)
25+
}
26+
27+
function generateJWT() {
28+
const privateKey = readFileSync(ASC_KEY_FILE, 'utf8')
29+
30+
const payload = {
31+
iss: ASC_ISSUER_ID,
32+
iat: Math.floor(Date.now() / 1000),
33+
exp: Math.floor(Date.now() / 1000) + (20 * 60),
34+
aud: 'appstoreconnect-v1'
35+
}
36+
37+
const token = jwt.sign(payload, privateKey, {
38+
algorithm: 'ES256',
39+
header: {
40+
alg: 'ES256',
41+
kid: ASC_KEY_ID,
42+
typ: 'JWT'
43+
}
44+
})
45+
46+
return token
47+
}
48+
49+
function apiRequest(path, options = {}) {
50+
return new Promise((resolve, reject) => {
51+
const jwt = generateJWT()
52+
53+
const reqOptions = {
54+
hostname: 'api.appstoreconnect.apple.com',
55+
path: path,
56+
method: options.method || 'GET',
57+
headers: {
58+
'Authorization': `Bearer ${jwt}`,
59+
'Content-Type': 'application/json',
60+
...options.headers
61+
}
62+
}
63+
64+
const req = https.request(reqOptions, (res) => {
65+
let data = ''
66+
67+
res.on('data', (chunk) => {
68+
data += chunk
69+
})
70+
71+
res.on('end', () => {
72+
if (res.statusCode >= 200 && res.statusCode < 300) {
73+
resolve(JSON.parse(data))
74+
} else {
75+
reject(new Error(`API request failed: ${res.statusCode} - ${data}`))
76+
}
77+
})
78+
})
79+
80+
req.on('error', reject)
81+
82+
if (options.body) {
83+
req.write(JSON.stringify(options.body))
84+
}
85+
86+
req.end()
87+
})
88+
}
89+
90+
async function findApp() {
91+
console.log(`Finding app with bundle ID: ${APP_BUNDLE_ID}`)
92+
const response = await apiRequest(`/v1/apps?filter[bundleId]=${APP_BUNDLE_ID}`)
93+
94+
if (!response.data || response.data.length === 0) {
95+
throw new Error(`App not found with bundle ID: ${APP_BUNDLE_ID}`)
96+
}
97+
98+
return response.data[0].id
99+
}
100+
101+
async function findBuild(appId) {
102+
const response = await apiRequest(`/v1/builds?filter[app]=${appId}&filter[version]=${BUILD_VERSION}&sort=-uploadedDate&limit=1`)
103+
104+
if (!response.data || response.data.length === 0) {
105+
return null
106+
}
107+
108+
return response.data[0].id
109+
}
110+
111+
async function pollForBuild(appId, timeoutMinutes = 30, pollIntervalSeconds = 30) {
112+
const timeoutMs = timeoutMinutes * 60 * 1000
113+
const pollIntervalMs = pollIntervalSeconds * 1000
114+
const startTime = Date.now()
115+
116+
console.log(`Polling for build version ${BUILD_VERSION}...`)
117+
console.log(`Timeout: ${timeoutMinutes} minutes, Poll interval: ${pollIntervalSeconds} seconds`)
118+
119+
let attempt = 0
120+
while (Date.now() - startTime < timeoutMs) {
121+
attempt++
122+
const elapsedMinutes = ((Date.now() - startTime) / 1000 / 60).toFixed(1)
123+
124+
console.log(`Attempt ${attempt} (${elapsedMinutes}/${timeoutMinutes} min): Checking for build...`)
125+
126+
const buildId = await findBuild(appId)
127+
128+
if (buildId) {
129+
console.log(`Build found: ${buildId}`)
130+
return buildId
131+
}
132+
133+
const remainingMs = timeoutMs - (Date.now() - startTime)
134+
if (remainingMs < pollIntervalMs) {
135+
break
136+
}
137+
138+
console.log(`Build not ready yet, waiting ${pollIntervalSeconds} seconds...`)
139+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
140+
}
141+
142+
throw new Error(`Timeout: Build version ${BUILD_VERSION} not found after ${timeoutMinutes} minutes`)
143+
}
144+
145+
async function createBetaBuildLocalization(buildId, changelog) {
146+
console.log(`Setting changelog for build: ${buildId}`)
147+
148+
const body = {
149+
data: {
150+
type: 'betaBuildLocalizations',
151+
attributes: {
152+
locale: 'en-US',
153+
whatsNew: changelog
154+
},
155+
relationships: {
156+
build: {
157+
data: {
158+
type: 'builds',
159+
id: buildId
160+
}
161+
}
162+
}
163+
}
164+
}
165+
166+
try {
167+
const response = await apiRequest('/v1/betaBuildLocalizations', {
168+
method: 'POST',
169+
body: body
170+
})
171+
console.log('Changelog set successfully')
172+
return response
173+
} catch (error) {
174+
if (error.message.includes('409')) {
175+
console.log('Localization already exists, updating...')
176+
return await updateBetaBuildLocalization(buildId, changelog)
177+
}
178+
throw error
179+
}
180+
}
181+
182+
async function updateBetaBuildLocalization(buildId, changelog) {
183+
const response = await apiRequest(`/v1/builds/${buildId}/betaBuildLocalizations`)
184+
185+
if (!response.data || response.data.length === 0) {
186+
throw new Error('No existing localization found to update')
187+
}
188+
189+
const localizationId = response.data[0].id
190+
191+
const body = {
192+
data: {
193+
type: 'betaBuildLocalizations',
194+
id: localizationId,
195+
attributes: {
196+
whatsNew: changelog
197+
}
198+
}
199+
}
200+
201+
await apiRequest(`/v1/betaBuildLocalizations/${localizationId}`, {
202+
method: 'PATCH',
203+
body: body
204+
})
205+
206+
console.log('Changelog updated successfully')
207+
}
208+
209+
async function main() {
210+
try {
211+
console.log('Setting TestFlight changelog...')
212+
console.log(`Changelog: ${CHANGELOG}`)
213+
214+
const appId = await findApp()
215+
console.log(`App ID: ${appId}`)
216+
217+
const buildId = await pollForBuild(appId, POLL_TIMEOUT_MINUTES, POLL_INTERVAL_SECONDS)
218+
console.log(`Build ID: ${buildId}`)
219+
220+
await createBetaBuildLocalization(buildId, CHANGELOG)
221+
222+
console.log('TestFlight changelog set successfully')
223+
} catch (error) {
224+
console.error('Failed to set TestFlight changelog:', error.message)
225+
process.exit(1)
226+
}
227+
}
228+
229+
main()

0 commit comments

Comments
 (0)