PEAR app icon (glass pear) + 2026.7.9 + ASC rename admin action #14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: iOS TestFlight Admin | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| cert_action: | |
| description: "Optional cert maintenance: list = inventory; revoke = revoke certs matching cert_name_filter" | |
| required: false | |
| default: "" | |
| cert_type: | |
| description: "Certificate type filter for revoke (e.g. DEVELOPMENT, IOS_DEVELOPMENT)" | |
| required: false | |
| default: "DEVELOPMENT" | |
| cert_name_filter: | |
| description: "Only revoke certs whose displayName contains this substring (required for revoke)" | |
| required: false | |
| default: "" | |
| new_app_name: | |
| description: "Rename the App Store Connect app (App Information > Name) to this value" | |
| required: false | |
| default: "" | |
| push: | |
| branches: | |
| - pear-ios-hello-testflight | |
| paths: | |
| - .github/workflows/ios-testflight-admin.yml | |
| permissions: | |
| contents: read | |
| jobs: | |
| cert-maintenance: | |
| name: Certificate maintenance | |
| if: github.event_name == 'workflow_dispatch' && inputs.cert_action != '' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| env: | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} | |
| CERT_ACTION: ${{ inputs.cert_action }} | |
| CERT_TYPE: ${{ inputs.cert_type }} | |
| CERT_NAME_FILTER: ${{ inputs.cert_name_filter }} | |
| steps: | |
| - name: Install deps | |
| run: pip install pyjwt cryptography requests | |
| - name: Inventory / revoke certificates | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import base64, os, time, sys | |
| import jwt, requests | |
| raw = os.environ["ASC_KEY_CONTENT"].strip() | |
| key = raw.replace("\\n", "\n") if "BEGIN PRIVATE KEY" in raw else base64.b64decode(raw).decode() | |
| token = jwt.encode( | |
| {"iss": os.environ["ASC_ISSUER_ID"], "aud": "appstoreconnect-v1", "exp": int(time.time()) + 600}, | |
| key, algorithm="ES256", headers={"kid": os.environ["ASC_KEY_ID"]}) | |
| headers = {"Authorization": f"Bearer {token}"} | |
| resp = requests.get( | |
| "https://api.appstoreconnect.apple.com/v1/certificates", | |
| params={"limit": 200}, headers=headers, timeout=30) | |
| resp.raise_for_status() | |
| certs = resp.json().get("data", []) | |
| print(f"{len(certs)} certificates on the account:") | |
| for cert in certs: | |
| attrs = cert.get("attributes", {}) | |
| print(f" id={cert['id']} type={attrs.get('certificateType')} " | |
| f"name={attrs.get('displayName')!r} expires={attrs.get('expirationDate')}") | |
| action = os.environ.get("CERT_ACTION", "list") | |
| if action != "revoke": | |
| print("\ncert_action=list — inventory only, nothing revoked.") | |
| sys.exit(0) | |
| cert_type = os.environ.get("CERT_TYPE", "DEVELOPMENT") | |
| name_filter = os.environ.get("CERT_NAME_FILTER", "").strip() | |
| if not name_filter: | |
| print("::error::revoke requires a non-empty cert_name_filter — refusing to revoke blindly.") | |
| sys.exit(1) | |
| targets = [ | |
| cert for cert in certs | |
| if cert.get("attributes", {}).get("certificateType") == cert_type | |
| and name_filter.lower() in str(cert.get("attributes", {}).get("displayName", "")).lower() | |
| ] | |
| print(f"\nRevoking {len(targets)} {cert_type} certs matching {name_filter!r}:") | |
| for cert in targets: | |
| attrs = cert.get("attributes", {}) | |
| del_resp = requests.delete( | |
| f"https://api.appstoreconnect.apple.com/v1/certificates/{cert['id']}", | |
| headers=headers, timeout=30) | |
| status = "revoked" if del_resp.status_code == 204 else f"FAILED {del_resp.status_code}: {del_resp.text[:200]}" | |
| print(f" {cert['id']} {attrs.get('displayName')!r} -> {status}") | |
| PY | |
| configure-testflight: | |
| name: Configure TestFlight | |
| if: github.event_name == 'push' || (inputs.cert_action == '' && inputs.new_app_name == '') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| env: | |
| BUNDLE_ID: ${{ secrets.IOS_BUNDLE_ID }} | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} | |
| INTERNAL_TESTER_EMAIL: [email protected] | |
| INTERNAL_TESTER_FIRST_NAME: Alex | |
| INTERNAL_TESTER_LAST_NAME: Markson | |
| INTERNAL_GROUP_NAME: Internal Testers | |
| EXTERNAL_GROUP_NAME: Public Beta | |
| PUBLIC_LINK_LIMIT: "100" | |
| BETA_REVIEW_CONTACT_PHONE: ${{ secrets.BETA_REVIEW_CONTACT_PHONE }} | |
| steps: | |
| - name: Validate secrets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for name in BUNDLE_ID ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_CONTENT; do | |
| if [[ -z "${!name:-}" ]]; then | |
| echo "::error::Missing required secret/env: ${name}" | |
| exit 1 | |
| fi | |
| done | |
| - name: Configure App Store Connect TestFlight | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| node <<'NODE' | |
| const crypto = require("crypto"); | |
| const baseUrl = "https://api.appstoreconnect.apple.com/v1"; | |
| const required = [ | |
| "BUNDLE_ID", | |
| "ASC_KEY_ID", | |
| "ASC_ISSUER_ID", | |
| "ASC_KEY_CONTENT", | |
| "INTERNAL_TESTER_EMAIL", | |
| ]; | |
| for (const name of required) { | |
| if (!process.env[name]) throw new Error(`Missing ${name}`); | |
| } | |
| function base64url(value) { | |
| return Buffer.from(value) | |
| .toString("base64") | |
| .replace(/=/g, "") | |
| .replace(/\+/g, "-") | |
| .replace(/\//g, "_"); | |
| } | |
| function keyContent() { | |
| const raw = process.env.ASC_KEY_CONTENT.trim(); | |
| if (raw.includes("BEGIN PRIVATE KEY")) return raw.replace(/\\n/g, "\n"); | |
| return Buffer.from(raw, "base64").toString("utf8"); | |
| } | |
| function makeJwt() { | |
| const now = Math.floor(Date.now() / 1000); | |
| const header = { | |
| alg: "ES256", | |
| kid: process.env.ASC_KEY_ID, | |
| typ: "JWT", | |
| }; | |
| const payload = { | |
| iss: process.env.ASC_ISSUER_ID, | |
| iat: now, | |
| exp: now + 18 * 60, | |
| aud: "appstoreconnect-v1", | |
| }; | |
| const body = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`; | |
| const signature = crypto.sign("sha256", Buffer.from(body), { | |
| key: keyContent(), | |
| dsaEncoding: "ieee-p1363", | |
| }); | |
| return `${body}.${base64url(signature)}`; | |
| } | |
| const token = makeJwt(); | |
| async function request(method, path, body = undefined, options = {}) { | |
| const url = path.startsWith("http") ? path : `${baseUrl}${path}`; | |
| const response = await fetch(url, { | |
| method, | |
| headers: { | |
| Authorization: `Bearer ${token}`, | |
| "Content-Type": "application/json", | |
| }, | |
| body: body ? JSON.stringify(body) : undefined, | |
| }); | |
| const text = await response.text(); | |
| let json = null; | |
| if (text) { | |
| try { | |
| json = JSON.parse(text); | |
| } catch { | |
| json = { raw: text }; | |
| } | |
| } | |
| if (!response.ok) { | |
| const message = json?.errors?.map((err) => `${err.status || response.status} ${err.code || ""} ${err.title || ""}: ${err.detail || ""}`).join(" | ") || text || response.statusText; | |
| const error = new Error(`${method} ${url} failed: ${message}`); | |
| error.status = response.status; | |
| error.json = json; | |
| if (options.allowFailure) { | |
| console.log(`::warning::${error.message}`); | |
| return { ok: false, status: response.status, json }; | |
| } | |
| throw error; | |
| } | |
| return { ok: true, status: response.status, json }; | |
| } | |
| function query(path, params) { | |
| const url = new URL(`${baseUrl}${path}`); | |
| for (const [key, value] of Object.entries(params)) { | |
| if (value !== undefined && value !== null && value !== "") { | |
| url.searchParams.set(key, value); | |
| } | |
| } | |
| return `${path}${url.search}`; | |
| } | |
| async function first(path, params) { | |
| const result = await request("GET", query(path, params)); | |
| return result.json?.data?.[0] || null; | |
| } | |
| async function all(path, params) { | |
| const result = await request("GET", query(path, params)); | |
| return result.json?.data || []; | |
| } | |
| async function ignoreConflict(promise, label) { | |
| try { | |
| return await promise; | |
| } catch (error) { | |
| if (error.status === 409) { | |
| console.log(`${label}: already exists or already attached.`); | |
| return { ok: false, status: 409, json: error.json }; | |
| } | |
| throw error; | |
| } | |
| } | |
| async function ensureBetaGroupAllBuilds(group) { | |
| if (group.attributes?.hasAccessToAllBuilds) return group; | |
| const result = await request("PATCH", `/betaGroups/${group.id}`, { | |
| data: { | |
| type: "betaGroups", | |
| id: group.id, | |
| attributes: { | |
| hasAccessToAllBuilds: true, | |
| }, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Enabled all-build access for beta group "${group.attributes?.name}" (${group.id}).`); | |
| return result.json.data; | |
| } | |
| console.log(`Could not enable all-build access for beta group "${group.attributes?.name}" (${group.id}); continuing with current Apple state.`); | |
| return group; | |
| } | |
| async function ensureUserInvitation(appId) { | |
| const email = process.env.INTERNAL_TESTER_EMAIL; | |
| const existingUser = await request("GET", query("/users", { "filter[username]": email }), undefined, { allowFailure: true }); | |
| if (existingUser.ok && existingUser.json?.data?.length) { | |
| const user = existingUser.json.data[0]; | |
| console.log(`Internal tester Apple ID is already an App Store Connect user: ${email} (user ${user.id}).`); | |
| return user; | |
| } | |
| const existingInvitation = await request("GET", query("/userInvitations", { "filter[email]": email }), undefined, { allowFailure: true }); | |
| if (existingInvitation.ok && existingInvitation.json?.data?.length) { | |
| const invite = existingInvitation.json.data[0]; | |
| console.log(`Internal tester invitation already exists for ${email} (invitation ${invite.id}).`); | |
| return null; | |
| } | |
| const payload = { | |
| data: { | |
| type: "userInvitations", | |
| attributes: { | |
| email, | |
| firstName: process.env.INTERNAL_TESTER_FIRST_NAME || "Alex", | |
| lastName: process.env.INTERNAL_TESTER_LAST_NAME || "Markson", | |
| roles: ["DEVELOPER"], | |
| allAppsVisible: false, | |
| provisioningAllowed: false, | |
| }, | |
| relationships: { | |
| visibleApps: { | |
| data: [{ type: "apps", id: appId }], | |
| }, | |
| }, | |
| }, | |
| }; | |
| const result = await ignoreConflict( | |
| request("POST", "/userInvitations", payload), | |
| `Internal tester invitation for ${email}`, | |
| ); | |
| if (result.ok) { | |
| console.log(`Sent App Store Connect invitation to ${email} scoped to app ${appId}.`); | |
| } | |
| return null; | |
| } | |
| async function ensureInternalBetaGroup(appId, name) { | |
| const groups = await all("/betaGroups", { | |
| "filter[app]": appId, | |
| "filter[name]": name, | |
| limit: "10", | |
| }); | |
| const existing = groups.find((group) => group.attributes?.name === name); | |
| if (existing) { | |
| console.log(`Found internal beta group "${name}" (${existing.id}). isInternalGroup=${existing.attributes?.isInternalGroup}, hasAccessToAllBuilds=${existing.attributes?.hasAccessToAllBuilds}`); | |
| if (!existing.attributes?.isInternalGroup) { | |
| throw new Error(`Beta group "${name}" exists but is not an internal group.`); | |
| } | |
| return await ensureBetaGroupAllBuilds(existing); | |
| } | |
| const result = await request("POST", "/betaGroups", { | |
| data: { | |
| type: "betaGroups", | |
| attributes: { | |
| name, | |
| isInternalGroup: true, | |
| hasAccessToAllBuilds: true, | |
| }, | |
| relationships: { | |
| app: { data: { type: "apps", id: appId } }, | |
| }, | |
| }, | |
| }); | |
| console.log(`Created internal beta group "${name}" (${result.json.data.id}).`); | |
| return await ensureBetaGroupAllBuilds(result.json.data); | |
| } | |
| async function ensureBetaTester(appId) { | |
| const email = process.env.INTERNAL_TESTER_EMAIL; | |
| const existing = await request("GET", query("/betaTesters", { | |
| "filter[email]": email, | |
| limit: "10", | |
| }), undefined, { allowFailure: true }); | |
| if (existing.ok && existing.json?.data?.length) { | |
| const tester = existing.json.data[0]; | |
| console.log(`Found beta tester ${email} (${tester.id}).`); | |
| return tester; | |
| } | |
| const result = await request("POST", "/betaTesters", { | |
| data: { | |
| type: "betaTesters", | |
| attributes: { | |
| email, | |
| firstName: process.env.INTERNAL_TESTER_FIRST_NAME || "Alex", | |
| lastName: process.env.INTERNAL_TESTER_LAST_NAME || "Markson", | |
| }, | |
| relationships: { | |
| apps: { data: [{ type: "apps", id: appId }] }, | |
| }, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Created beta tester ${email} (${result.json.data.id}).`); | |
| return result.json.data; | |
| } | |
| const retry = await request("GET", query("/betaTesters", { | |
| "filter[email]": email, | |
| limit: "10", | |
| }), undefined, { allowFailure: true }); | |
| if (retry.ok && retry.json?.data?.length) { | |
| const tester = retry.json.data[0]; | |
| console.log(`Found beta tester ${email} after create conflict (${tester.id}).`); | |
| return tester; | |
| } | |
| throw new Error(`Could not find or create beta tester record for ${email}.`); | |
| } | |
| async function attachTesterToGroup(testerId, groupId) { | |
| const result = await request("POST", `/betaGroups/${groupId}/relationships/betaTesters`, { | |
| data: [{ type: "betaTesters", id: testerId }], | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Attached beta tester ${testerId} to beta group ${groupId}.`); | |
| return result; | |
| } | |
| if (result.status === 409) { | |
| console.log(`Beta tester ${testerId} is already attached to beta group ${groupId}.`); | |
| return result; | |
| } | |
| throw new Error(`Could not attach beta tester ${testerId} to beta group ${groupId}.`); | |
| } | |
| async function logGroupTesterMembership(groupId, testerId) { | |
| const result = await request("GET", `/betaGroups/${groupId}/relationships/betaTesters?limit=200`, undefined, { allowFailure: true }); | |
| const attached = result.ok && result.json?.data?.some((tester) => tester.type === "betaTesters" && tester.id === testerId); | |
| console.log(`Internal tester membership: tester=${testerId}, group=${groupId}, attached=${attached ? "yes" : "unknown/no"}.`); | |
| } | |
| async function sendBetaTesterInvitation(appId, testerId) { | |
| const result = await request("POST", "/betaTesterInvitations", { | |
| data: { | |
| type: "betaTesterInvitations", | |
| relationships: { | |
| app: { data: { type: "apps", id: appId } }, | |
| betaTester: { data: { type: "betaTesters", id: testerId } }, | |
| }, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Sent TestFlight beta tester invitation for tester=${testerId}, app=${appId}.`); | |
| return; | |
| } | |
| if (result.status === 409) { | |
| console.log(`TestFlight beta tester invitation already exists for tester=${testerId}, app=${appId}.`); | |
| return; | |
| } | |
| console.log(`Could not send TestFlight beta tester invitation for tester=${testerId}, app=${appId}; Apple returned status ${result.status}.`); | |
| } | |
| async function ensureBetaGroup(appId, name) { | |
| const groups = await all("/betaGroups", { | |
| "filter[app]": appId, | |
| "filter[name]": name, | |
| limit: "10", | |
| }); | |
| const existing = groups.find((group) => group.attributes?.name === name); | |
| if (existing) { | |
| console.log(`Found beta group "${name}" (${existing.id}). publicLink=${existing.attributes?.publicLink || "not available yet"}`); | |
| return existing; | |
| } | |
| const payload = { | |
| data: { | |
| type: "betaGroups", | |
| attributes: { name }, | |
| relationships: { | |
| app: { data: { type: "apps", id: appId } }, | |
| }, | |
| }, | |
| }; | |
| const result = await request("POST", "/betaGroups", payload); | |
| console.log(`Created beta group "${name}" (${result.json.data.id}).`); | |
| return result.json.data; | |
| } | |
| async function attachBuildToGroup(buildId, groupId) { | |
| return await ignoreConflict( | |
| request("POST", `/betaGroups/${groupId}/relationships/builds`, { | |
| data: [{ type: "builds", id: buildId }], | |
| }, { allowFailure: true }), | |
| `Build ${buildId} on beta group ${groupId}`, | |
| ); | |
| } | |
| async function markExportCompliance(buildId) { | |
| const result = await request("PATCH", `/builds/${buildId}`, { | |
| data: { | |
| type: "builds", | |
| id: buildId, | |
| attributes: { | |
| usesNonExemptEncryption: false, | |
| }, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Marked build ${buildId} as not using non-exempt encryption.`); | |
| } | |
| } | |
| async function submitBetaReview(buildId) { | |
| const payload = { | |
| data: { | |
| type: "betaAppReviewSubmissions", | |
| relationships: { | |
| build: { data: { type: "builds", id: buildId } }, | |
| }, | |
| }, | |
| }; | |
| const result = await request("POST", "/betaAppReviewSubmissions", payload, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Submitted build ${buildId} for Beta App Review (submission ${result.json.data.id}).`); | |
| } | |
| } | |
| async function ensureBetaAppLocalization(appId) { | |
| const attributes = { | |
| description: "OpenClaw PEAR hello-world TestFlight build for validating automated iOS deployment from OpenClaw.", | |
| feedbackEmail: "[email protected]", | |
| }; | |
| const existing = await request("GET", `/apps/${appId}/betaAppLocalizations`, undefined, { allowFailure: true }); | |
| if (existing.ok && existing.json?.data?.length) { | |
| const localization = existing.json.data[0]; | |
| const result = await request("PATCH", `/betaAppLocalizations/${localization.id}`, { | |
| data: { | |
| type: "betaAppLocalizations", | |
| id: localization.id, | |
| attributes, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Updated beta app localization ${localization.id}.`); | |
| } | |
| return; | |
| } | |
| const result = await request("POST", "/betaAppLocalizations", { | |
| data: { | |
| type: "betaAppLocalizations", | |
| attributes: { | |
| locale: "en-US", | |
| ...attributes, | |
| }, | |
| relationships: { | |
| app: { data: { type: "apps", id: appId } }, | |
| }, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Created beta app localization ${result.json.data.id}.`); | |
| } | |
| } | |
| async function ensureBetaBuildLocalization(buildId) { | |
| const attributes = { | |
| whatsNew: "Hello-world TestFlight deployment smoke test from OpenClaw.", | |
| }; | |
| const existing = await request("GET", `/builds/${buildId}/betaBuildLocalizations`, undefined, { allowFailure: true }); | |
| if (existing.ok && existing.json?.data?.length) { | |
| const localization = existing.json.data[0]; | |
| const result = await request("PATCH", `/betaBuildLocalizations/${localization.id}`, { | |
| data: { | |
| type: "betaBuildLocalizations", | |
| id: localization.id, | |
| attributes, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Updated beta build localization ${localization.id}.`); | |
| } | |
| return; | |
| } | |
| const result = await request("POST", "/betaBuildLocalizations", { | |
| data: { | |
| type: "betaBuildLocalizations", | |
| attributes: { | |
| locale: "en-US", | |
| ...attributes, | |
| }, | |
| relationships: { | |
| build: { data: { type: "builds", id: buildId } }, | |
| }, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Created beta build localization ${result.json.data.id}.`); | |
| } | |
| } | |
| async function ensureBetaAppReviewDetail(appId) { | |
| const detail = await request("GET", `/apps/${appId}/betaAppReviewDetail`, undefined, { allowFailure: true }); | |
| if (!detail.ok || !detail.json?.data?.id) return; | |
| const attributes = { | |
| contactFirstName: "Alex", | |
| contactLastName: "Markson", | |
| contactEmail: "[email protected]", | |
| ...(process.env.BETA_REVIEW_CONTACT_PHONE ? { contactPhone: process.env.BETA_REVIEW_CONTACT_PHONE } : {}), | |
| notes: "This is a minimal SwiftUI hello-world build used to validate the OpenClaw iOS TestFlight deployment pipeline. No login is required.", | |
| }; | |
| const result = await request("PATCH", `/betaAppReviewDetails/${detail.json.data.id}`, { | |
| data: { | |
| type: "betaAppReviewDetails", | |
| id: detail.json.data.id, | |
| attributes, | |
| }, | |
| }, { allowFailure: true }); | |
| if (result.ok) { | |
| console.log(`Updated beta app review detail ${detail.json.data.id}.`); | |
| } | |
| } | |
| async function enablePublicLink(groupId) { | |
| const limit = Number.parseInt(process.env.PUBLIC_LINK_LIMIT || "100", 10); | |
| const payload = { | |
| data: { | |
| type: "betaGroups", | |
| id: groupId, | |
| attributes: { | |
| publicLinkEnabled: true, | |
| publicLinkLimitEnabled: true, | |
| publicLinkLimit: Number.isFinite(limit) ? limit : 100, | |
| }, | |
| }, | |
| }; | |
| const result = await request("PATCH", `/betaGroups/${groupId}`, payload, { allowFailure: true }); | |
| if (result.ok) { | |
| const link = result.json.data.attributes?.publicLink; | |
| console.log(`Public link ${link ? `enabled: ${link}` : "requested; Apple has not returned a link yet."}`); | |
| } | |
| } | |
| async function main() { | |
| const app = await first("/apps", { "filter[bundleId]": process.env.BUNDLE_ID, limit: "1" }); | |
| if (!app) throw new Error(`No App Store Connect app found for bundle id ${process.env.BUNDLE_ID}`); | |
| console.log(`App: ${app.attributes?.name || "(unnamed)"} (${app.id}), bundle ${process.env.BUNDLE_ID}`); | |
| const builds = await all("/builds", { | |
| "filter[app]": app.id, | |
| "sort": "-uploadedDate", | |
| "limit": "10", | |
| }); | |
| if (!builds.length) throw new Error(`No builds found for app ${app.id}.`); | |
| const latest = builds[0]; | |
| console.log(`Latest build: id=${latest.id}, version=${latest.attributes?.version}, processingState=${latest.attributes?.processingState}, uploadedDate=${latest.attributes?.uploadedDate}`); | |
| await ensureUserInvitation(app.id); | |
| await ensureBetaAppLocalization(app.id); | |
| await ensureBetaBuildLocalization(latest.id); | |
| await ensureBetaAppReviewDetail(app.id); | |
| await markExportCompliance(latest.id); | |
| const internalGroup = await ensureInternalBetaGroup(app.id, process.env.INTERNAL_GROUP_NAME || "Internal Testers"); | |
| const internalTester = await ensureBetaTester(app.id); | |
| await attachTesterToGroup(internalTester.id, internalGroup.id); | |
| await logGroupTesterMembership(internalGroup.id, internalTester.id); | |
| await sendBetaTesterInvitation(app.id, internalTester.id); | |
| const refreshedInternalGroup = await request("GET", `/betaGroups/${internalGroup.id}`); | |
| console.log(`Internal testing summary: group=${internalGroup.id}, tester=${internalTester.id}, latestBuild=${latest.id}, hasAccessToAllBuilds=${refreshedInternalGroup.json.data.attributes?.hasAccessToAllBuilds}.`); | |
| const externalGroup = await ensureBetaGroup(app.id, process.env.EXTERNAL_GROUP_NAME || "Public Beta"); | |
| await attachBuildToGroup(latest.id, externalGroup.id); | |
| await submitBetaReview(latest.id); | |
| await enablePublicLink(externalGroup.id); | |
| const refreshedGroup = await request("GET", `/betaGroups/${externalGroup.id}`); | |
| console.log(`External group summary: name=${refreshedGroup.json.data.attributes?.name}, publicLink=${refreshedGroup.json.data.attributes?.publicLink || "not available yet"}, publicLinkEnabled=${refreshedGroup.json.data.attributes?.publicLinkEnabled}`); | |
| } | |
| main().catch((error) => { | |
| console.error(`::error::${error.stack || error.message}`); | |
| process.exit(1); | |
| }); | |
| NODE | |
| rename-app: | |
| name: Rename App Store Connect app | |
| if: github.event_name == 'workflow_dispatch' && inputs.new_app_name != '' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| env: | |
| BUNDLE_ID: ${{ secrets.IOS_BUNDLE_ID }} | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} | |
| NEW_APP_NAME: ${{ inputs.new_app_name }} | |
| steps: | |
| - name: Install deps | |
| run: pip install pyjwt cryptography requests | |
| - name: Rename app in App Store Connect | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import base64, os, time, sys | |
| import jwt, requests | |
| raw = os.environ["ASC_KEY_CONTENT"].strip() | |
| key = raw.replace("\\n", "\n") if "BEGIN PRIVATE KEY" in raw else base64.b64decode(raw).decode() | |
| token = jwt.encode( | |
| {"iss": os.environ["ASC_ISSUER_ID"], "aud": "appstoreconnect-v1", "exp": int(time.time()) + 600}, | |
| key, algorithm="ES256", headers={"kid": os.environ["ASC_KEY_ID"]}) | |
| headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} | |
| api = "https://api.appstoreconnect.apple.com/v1" | |
| new_name = os.environ["NEW_APP_NAME"].strip() | |
| resp = requests.get(f"{api}/apps", | |
| params={"filter[bundleId]": os.environ["BUNDLE_ID"]}, | |
| headers=headers, timeout=30) | |
| resp.raise_for_status() | |
| apps = resp.json()["data"] | |
| if not apps: | |
| print(f"::error::No app found for bundle id {os.environ['BUNDLE_ID']}") | |
| sys.exit(1) | |
| app = apps[0] | |
| print(f"App {app['id']} current name: {app['attributes'].get('name')!r}") | |
| resp = requests.get(f"{api}/apps/{app['id']}/appInfos", headers=headers, timeout=30) | |
| resp.raise_for_status() | |
| infos = resp.json()["data"] | |
| # An app carries one editable appInfo pre-release; released apps gain a | |
| # read-only READY_FOR_SALE copy — patch the editable one. | |
| editable = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED", | |
| "METADATA_REJECTED", "WAITING_FOR_REVIEW", "IN_REVIEW"} | |
| info = next((i for i in infos | |
| if i["attributes"].get("appStoreState") in editable), infos[0]) | |
| resp = requests.get(f"{api}/appInfos/{info['id']}/appInfoLocalizations", | |
| headers=headers, timeout=30) | |
| resp.raise_for_status() | |
| locs = resp.json()["data"] | |
| if not locs: | |
| print("::error::No appInfoLocalizations found") | |
| sys.exit(1) | |
| ok = True | |
| for loc in locs: | |
| locale = loc["attributes"].get("locale") | |
| old = loc["attributes"].get("name") | |
| patch = {"data": {"type": "appInfoLocalizations", "id": loc["id"], | |
| "attributes": {"name": new_name}}} | |
| r = requests.patch(f"{api}/appInfoLocalizations/{loc['id']}", | |
| json=patch, headers=headers, timeout=30) | |
| if r.status_code == 200: | |
| print(f"{locale}: {old!r} -> {new_name!r} OK") | |
| else: | |
| ok = False | |
| print(f"::error::{locale}: rename failed {r.status_code}: {r.text[:500]}") | |
| sys.exit(0 if ok else 1) | |
| PY |