Skip to content

PEAR app icon (glass pear) + 2026.7.9 + ASC rename admin action #14

PEAR app icon (glass pear) + 2026.7.9 + ASC rename admin action

PEAR app icon (glass pear) + 2026.7.9 + ASC rename admin action #14

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