@daemux/store-automator 0.10.72 → 0.10.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/ci.config.yaml.template +5 -0
- package/templates/github/IOS_NATIVE_CI_SETUP.md +80 -0
- package/templates/github/workflows/deploy.yml +18 -0
- package/templates/ios-native-ci.config.yaml.template +30 -0
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +167 -0
- package/templates/github/workflows/ios-native-release.yml +0 -66
- package/templates/scripts/ci/ios-native/set_testflight_whats_new.py +0 -146
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "App Store & Google Play automation for Flutter apps",
|
|
8
|
-
"version": "0.10.
|
|
8
|
+
"version": "0.10.74"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "store-automator",
|
|
13
13
|
"source": "./plugins/store-automator",
|
|
14
14
|
"description": "3 agents for app store publishing: reviewer, meta-creator, media-designer",
|
|
15
|
-
"version": "0.10.
|
|
15
|
+
"version": "0.10.74",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -57,3 +57,8 @@ web:
|
|
|
57
57
|
jurisdiction: "State of California, United States"
|
|
58
58
|
app_store_url: ""
|
|
59
59
|
google_play_url: ""
|
|
60
|
+
|
|
61
|
+
# -----------------------------------------------------------------------------
|
|
62
|
+
# For native iOS apps (Swift/SwiftUI without Flutter), use
|
|
63
|
+
# ios-native-ci.config.yaml.template instead of this file.
|
|
64
|
+
# -----------------------------------------------------------------------------
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# iOS Native TestFlight CI — Setup Guide
|
|
2
|
+
|
|
3
|
+
Drop-in TestFlight automation for native Swift/SwiftUI iOS apps. One workflow, one config file, one credential file. All logic lives in the shared composite action `daemux/daemux-plugins/.github/actions/ios-native-testflight`.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- **Private GitHub repo** (holds the ASC API key `.p8` file).
|
|
8
|
+
- **Apple Developer Program** membership with an App Store Connect user that can create API keys.
|
|
9
|
+
- **ASC API Key** with `App Manager` role, downloaded as `AuthKey_<KEY_ID>.p8` (Apple lets you download this exactly once).
|
|
10
|
+
- Xcode project builds locally on macOS 15 / Xcode 16.
|
|
11
|
+
|
|
12
|
+
## Step 1 — Copy the workflow
|
|
13
|
+
|
|
14
|
+
Copy `.github/workflows/deploy.yml` verbatim from this template. No edits required. It triggers on push to `main` and via `workflow_dispatch`.
|
|
15
|
+
|
|
16
|
+
## Step 2 — Copy `ci.config.yaml`
|
|
17
|
+
|
|
18
|
+
Copy `ios-native-ci.config.yaml.template` to your repo root as `ci.config.yaml`. Fill in:
|
|
19
|
+
|
|
20
|
+
- `app.bundle_id` — REQUIRED (must match your App Store Connect app record).
|
|
21
|
+
- `xcode.scheme` — REQUIRED (the shared scheme the CI should archive).
|
|
22
|
+
|
|
23
|
+
Every other field has a sensible default. `xcode.project` auto-detects if there is exactly one `*.xcodeproj` at the repo root. `app.app_store_apple_id` is auto-discovered via the ASC API from the bundle id.
|
|
24
|
+
|
|
25
|
+
## Step 3 — Drop in the ASC API key
|
|
26
|
+
|
|
27
|
+
Rename the downloaded key to include both the key id and the issuer uuid, then put it under `creds/`:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
creds/AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Example: `creds/AuthKey_5NBDY6YXJ6_Issuer_69a6de77-xxxx-xxxx-xxxx-xxxxxxxxxxxx.p8`.
|
|
34
|
+
|
|
35
|
+
The composite action parses the key id and issuer id from the filename — no GitHub secrets to configure.
|
|
36
|
+
|
|
37
|
+
## Step 4 — Register the app in App Store Connect (one-time)
|
|
38
|
+
|
|
39
|
+
App Store Connect requires a human-in-the-loop 2FA step the first time. Either:
|
|
40
|
+
|
|
41
|
+
- Run `fastlane create_app_ios` locally with `APPLE_ID`, `BUNDLE_ID`, `APP_NAME` exported, or
|
|
42
|
+
- Create the app manually in the App Store Connect web UI (My Apps → + → New App).
|
|
43
|
+
|
|
44
|
+
Subsequent builds need neither — the ASC API key handles everything.
|
|
45
|
+
|
|
46
|
+
## Step 5 — MARKETING_VERSION
|
|
47
|
+
|
|
48
|
+
Leave your Xcode project's `MARKETING_VERSION` at `1.0` initially. CI auto-rolls the marketing version forward (patch bump) whenever the prior version hits `READY_FOR_SALE` in App Store Connect. Until then, the pipeline keeps bumping the build number against the current marketing version.
|
|
49
|
+
|
|
50
|
+
## Step 6 — Push to `main`
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git add .github/workflows/deploy.yml ci.config.yaml creds/AuthKey_*.p8
|
|
54
|
+
git commit -m "ci: add iOS TestFlight pipeline"
|
|
55
|
+
git push origin main
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
GitHub Actions triggers the workflow. On success, the build appears in TestFlight within 5–15 minutes (Apple processing delay).
|
|
59
|
+
|
|
60
|
+
## Troubleshooting
|
|
61
|
+
|
|
62
|
+
| Error | Likely Cause | Fix |
|
|
63
|
+
|-------|--------------|-----|
|
|
64
|
+
| `No ASC key found in creds/` | File missing or misnamed | Filename must match `AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8` exactly |
|
|
65
|
+
| `No ASC app found for bundle <id>` | App not yet registered in ASC | Run Step 4 (create the app record) |
|
|
66
|
+
| `MARKETING_VERSION not set` | Missing in `.pbxproj` | Set `MARKETING_VERSION = 1.0;` in your target's build settings |
|
|
67
|
+
| Apple refuses new version | Prior version not in `READY_FOR_SALE` | Let the existing version finish review, or manually bump `MARKETING_VERSION` in Xcode |
|
|
68
|
+
| `Scheme not found` | Scheme not shared | In Xcode: Product → Scheme → Manage Schemes → tick "Shared", commit `*.xcscheme` |
|
|
69
|
+
|
|
70
|
+
## Credential rotation
|
|
71
|
+
|
|
72
|
+
ASC API keys can be revoked at any time. Rotate via:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
rm creds/AuthKey_OLD*.p8
|
|
76
|
+
cp ~/Downloads/AuthKey_NEW_*.p8 creds/AuthKey_NEW_Issuer_<ISSUER_UUID>.p8
|
|
77
|
+
git add creds/
|
|
78
|
+
git commit -m "ci: rotate ASC API key"
|
|
79
|
+
git push
|
|
80
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: iOS Deploy
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
paths-ignore: ['**/*.md', '.gitignore']
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ios-deploy-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
deploy:
|
|
14
|
+
runs-on: macos-15
|
|
15
|
+
timeout-minutes: 60
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: daemux/daemux-plugins/.github/actions/ios-native-testflight@main
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# iOS TestFlight CI configuration — read by daemux/daemux-plugins/.github/actions/ios-native-testflight
|
|
2
|
+
# Place this at the repo root as ci.config.yaml.
|
|
3
|
+
# Also place your ASC API key at creds/AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8
|
|
4
|
+
|
|
5
|
+
app:
|
|
6
|
+
bundle_id: "com.example.myapp" # REQUIRED
|
|
7
|
+
team_id: "ABCDE12345" # optional (used for manual signing)
|
|
8
|
+
app_store_apple_id: "" # optional — auto-discovered via ASC API by bundle_id
|
|
9
|
+
|
|
10
|
+
xcode:
|
|
11
|
+
project: "" # auto-detected if exactly one *.xcodeproj at root
|
|
12
|
+
workspace: "" # wins over project when set
|
|
13
|
+
scheme: "MyApp" # REQUIRED — no safe auto-detect
|
|
14
|
+
configuration: "Release" # default: Release
|
|
15
|
+
profile_name: "" # default: "<scheme> CI"
|
|
16
|
+
|
|
17
|
+
ios:
|
|
18
|
+
uses_non_exempt_encryption: false # false | true | "" to skip write
|
|
19
|
+
|
|
20
|
+
testflight:
|
|
21
|
+
whats_new: |
|
|
22
|
+
- Improved performance
|
|
23
|
+
- Bug fixes
|
|
24
|
+
- Enhanced security
|
|
25
|
+
locale: "en-US"
|
|
26
|
+
|
|
27
|
+
ci:
|
|
28
|
+
run_tests: false
|
|
29
|
+
test_command: "" # optional
|
|
30
|
+
test_destination: "platform=iOS Simulator,name=iPhone 15"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Set the App Store "What's New" text (appStoreVersionLocalizations.whatsNew) for
|
|
4
|
+
the draft App Store version slot prepared by manage_marketing_version.py.
|
|
5
|
+
|
|
6
|
+
Mirrors gowalk-step's `fastlane run set_changelog` behavior against the ASC
|
|
7
|
+
REST API (no fastlane dependency here):
|
|
8
|
+
|
|
9
|
+
* Uses the pre-resolved APP_STORE_VERSION_ID env var from the
|
|
10
|
+
"Resolve App Store version slot" step -- no polling, no lookup by version.
|
|
11
|
+
* Skips the first release (1.0 / 1.0.0 / 0.0 / 0.0.0) -- there are no prior
|
|
12
|
+
release notes to announce.
|
|
13
|
+
* Skips when the slot is not in a state that permits editing whatsNew.
|
|
14
|
+
* Either PATCHes the existing localization for the requested locale or POSTs
|
|
15
|
+
a new one pointing at the appStoreVersion.
|
|
16
|
+
|
|
17
|
+
Non-fatal by design: any failure is reported as a ::warning:: and the action
|
|
18
|
+
continues. The TestFlight upload itself has already succeeded by this point.
|
|
19
|
+
|
|
20
|
+
Environment:
|
|
21
|
+
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
|
|
22
|
+
MARKETING_VERSION -- e.g. "1.2.3"
|
|
23
|
+
APP_STORE_VERSION_ID -- appStoreVersion id (REUSE or CREATE)
|
|
24
|
+
APP_STORE_WHATS_NEW -- release notes text (empty = skip)
|
|
25
|
+
APP_STORE_LOCALE -- locale code, default "en-US"
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
from asc_common import get_json, make_jwt, request
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# gowalk-step parity: skip whatsNew for the initial release.
|
|
37
|
+
SKIP_VERSIONS = {"1.0", "1.0.0", "0.0", "0.0.0"}
|
|
38
|
+
|
|
39
|
+
# Apple permits editing appStoreVersionLocalizations.whatsNew only while the
|
|
40
|
+
# version is in an editable state. WAITING_FOR_REVIEW / IN_REVIEW /
|
|
41
|
+
# READY_FOR_SALE etc. are not editable -- skip non-fatally.
|
|
42
|
+
WHATSNEW_EDITABLE_STATES = {
|
|
43
|
+
"PREPARE_FOR_SUBMISSION",
|
|
44
|
+
"REJECTED",
|
|
45
|
+
"METADATA_REJECTED",
|
|
46
|
+
"DEVELOPER_REJECTED",
|
|
47
|
+
"INVALID_BINARY",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _require_env(name: str) -> str:
|
|
52
|
+
value = os.environ.get(name, "").strip()
|
|
53
|
+
if not value:
|
|
54
|
+
print(f"::error::{name} env var is required", file=sys.stderr)
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _warn(msg: str) -> None:
|
|
60
|
+
print(f"::warning::{msg}", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _log(msg: str) -> None:
|
|
64
|
+
print(msg, file=sys.stderr)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _patch_localization(token: str, localization_id: str, whats_new: str) -> None:
|
|
68
|
+
request(
|
|
69
|
+
"PATCH",
|
|
70
|
+
f"/appStoreVersionLocalizations/{localization_id}",
|
|
71
|
+
token,
|
|
72
|
+
json_body={
|
|
73
|
+
"data": {
|
|
74
|
+
"type": "appStoreVersionLocalizations",
|
|
75
|
+
"id": localization_id,
|
|
76
|
+
"attributes": {"whatsNew": whats_new},
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _create_localization(
|
|
83
|
+
token: str, version_id: str, locale: str, whats_new: str
|
|
84
|
+
) -> str:
|
|
85
|
+
resp = request(
|
|
86
|
+
"POST",
|
|
87
|
+
"/appStoreVersionLocalizations",
|
|
88
|
+
token,
|
|
89
|
+
json_body={
|
|
90
|
+
"data": {
|
|
91
|
+
"type": "appStoreVersionLocalizations",
|
|
92
|
+
"attributes": {"locale": locale, "whatsNew": whats_new},
|
|
93
|
+
"relationships": {
|
|
94
|
+
"appStoreVersion": {
|
|
95
|
+
"data": {"type": "appStoreVersions", "id": version_id}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
return resp.json()["data"]["id"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
whats_new = os.environ.get("APP_STORE_WHATS_NEW", "")
|
|
106
|
+
if not whats_new.strip():
|
|
107
|
+
_log("APP_STORE_WHATS_NEW empty; skipping.")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
version = _require_env("MARKETING_VERSION")
|
|
111
|
+
version_id = _require_env("APP_STORE_VERSION_ID")
|
|
112
|
+
locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
|
|
113
|
+
|
|
114
|
+
if version in SKIP_VERSIONS:
|
|
115
|
+
_log(f"Skipping whatsNew for first release {version}.")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
token = make_jwt(
|
|
119
|
+
_require_env("ASC_KEY_ID"),
|
|
120
|
+
_require_env("ASC_ISSUER_ID"),
|
|
121
|
+
_require_env("ASC_KEY_PATH"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
ver = get_json(f"/appStoreVersions/{version_id}", token)
|
|
125
|
+
state = (ver.get("data") or {}).get("attributes", {}).get("appStoreState", "")
|
|
126
|
+
if state not in WHATSNEW_EDITABLE_STATES:
|
|
127
|
+
_warn(
|
|
128
|
+
f"appStoreVersion {version} is {state}; whatsNew not editable in "
|
|
129
|
+
f"this state, skipping."
|
|
130
|
+
)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
locs = get_json(
|
|
134
|
+
f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
|
|
135
|
+
)
|
|
136
|
+
existing = next(
|
|
137
|
+
(
|
|
138
|
+
item
|
|
139
|
+
for item in (locs.get("data") or [])
|
|
140
|
+
if (item.get("attributes") or {}).get("locale") == locale
|
|
141
|
+
),
|
|
142
|
+
None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if existing:
|
|
146
|
+
_patch_localization(token, existing["id"], whats_new)
|
|
147
|
+
_log(
|
|
148
|
+
f"PATCHed appStoreVersionLocalization {existing['id']} whatsNew "
|
|
149
|
+
f"for {version} ({locale})"
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
new_id = _create_localization(token, version_id, locale, whats_new)
|
|
153
|
+
_log(
|
|
154
|
+
f"CREATEd appStoreVersionLocalization {new_id} whatsNew for "
|
|
155
|
+
f"{version} ({locale})"
|
|
156
|
+
)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
try:
|
|
162
|
+
sys.exit(main())
|
|
163
|
+
except SystemExit:
|
|
164
|
+
raise
|
|
165
|
+
except Exception as exc: # non-fatal per gowalk-step's set_changelog parity
|
|
166
|
+
_warn(f"whatsNew setter failed (non-fatal): {exc!r}")
|
|
167
|
+
sys.exit(0)
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
name: iOS Deploy
|
|
2
|
-
|
|
3
|
-
# Native-Swift iOS release pipeline. On PRs and non-main branches, runs the
|
|
4
|
-
# simulator test suite (build-only, no signing). On push to main, runs tests,
|
|
5
|
-
# archives the app, and uploads to TestFlight via the App Store Connect API.
|
|
6
|
-
#
|
|
7
|
-
# Required repository secrets (for upload, main branch only):
|
|
8
|
-
# ASC_KEY_ID App Store Connect API key id (e.g. "5NBDY6YXJ6")
|
|
9
|
-
# ASC_ISSUER_ID App Store Connect API issuer id (uuid)
|
|
10
|
-
# ASC_KEY_P8 Full contents of the AuthKey_*.p8 file
|
|
11
|
-
#
|
|
12
|
-
# Edit the `with:` blocks below to match your project.
|
|
13
|
-
|
|
14
|
-
on:
|
|
15
|
-
push:
|
|
16
|
-
branches: [main]
|
|
17
|
-
pull_request:
|
|
18
|
-
branches: [main]
|
|
19
|
-
workflow_dispatch:
|
|
20
|
-
|
|
21
|
-
concurrency:
|
|
22
|
-
group: ios-${{ github.workflow }}-${{ github.ref }}
|
|
23
|
-
cancel-in-progress: false
|
|
24
|
-
|
|
25
|
-
jobs:
|
|
26
|
-
# PRs and non-main pushes: build + test only, no signing or upload.
|
|
27
|
-
build-and-test:
|
|
28
|
-
if: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref != 'refs/heads/main') }}
|
|
29
|
-
runs-on: macos-latest
|
|
30
|
-
timeout-minutes: 45
|
|
31
|
-
steps:
|
|
32
|
-
- uses: actions/checkout@v4
|
|
33
|
-
|
|
34
|
-
- uses: daemux/daemux-plugins/.github/actions/ios-native-testflight@main
|
|
35
|
-
with:
|
|
36
|
-
project: MyApp.xcodeproj
|
|
37
|
-
scheme: MyApp
|
|
38
|
-
bundle-id: com.example.myapp
|
|
39
|
-
team-id: ABCDE12345
|
|
40
|
-
app-store-apple-id: "1234567890"
|
|
41
|
-
run-tests: "true"
|
|
42
|
-
archive: "false"
|
|
43
|
-
upload: "false"
|
|
44
|
-
|
|
45
|
-
# Main branch: test + archive + upload to TestFlight.
|
|
46
|
-
deploy:
|
|
47
|
-
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
|
48
|
-
runs-on: macos-latest
|
|
49
|
-
timeout-minutes: 60
|
|
50
|
-
steps:
|
|
51
|
-
- uses: actions/checkout@v4
|
|
52
|
-
|
|
53
|
-
- uses: daemux/daemux-plugins/.github/actions/ios-native-testflight@main
|
|
54
|
-
with:
|
|
55
|
-
project: MyApp.xcodeproj
|
|
56
|
-
scheme: MyApp
|
|
57
|
-
bundle-id: com.example.myapp
|
|
58
|
-
team-id: ABCDE12345
|
|
59
|
-
app-store-apple-id: "1234567890"
|
|
60
|
-
run-tests: "true"
|
|
61
|
-
archive: "true"
|
|
62
|
-
upload: "true"
|
|
63
|
-
env:
|
|
64
|
-
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
|
65
|
-
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
|
66
|
-
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Set TestFlight "What to Test" (betaBuildLocalization.whatsNew) for the build
|
|
4
|
-
just uploaded by this workflow.
|
|
5
|
-
|
|
6
|
-
Runs after the altool upload step. Polls ASC until the build record appears
|
|
7
|
-
(altool upload returns before Apple has fully ingested the build), then either
|
|
8
|
-
PATCHes an existing betaBuildLocalization for the requested locale or POSTs a
|
|
9
|
-
new one.
|
|
10
|
-
|
|
11
|
-
Environment:
|
|
12
|
-
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
|
|
13
|
-
APP_STORE_APPLE_ID -- numeric ASC app id
|
|
14
|
-
MARKETING_VERSION -- e.g. "1.2.3"
|
|
15
|
-
BUILD_NUMBER -- e.g. "42"
|
|
16
|
-
TESTFLIGHT_WHATS_NEW -- release notes text (empty = skip)
|
|
17
|
-
TESTFLIGHT_LOCALE -- locale code, default "en-US"
|
|
18
|
-
|
|
19
|
-
Non-fatal by design: if the build can't be found within the polling window we
|
|
20
|
-
emit a ::warning:: and exit 0, since the TestFlight upload itself may still
|
|
21
|
-
have succeeded.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import os
|
|
27
|
-
import sys
|
|
28
|
-
import time
|
|
29
|
-
|
|
30
|
-
from asc_common import get_json, make_jwt, request
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
POLL_ATTEMPTS = 20
|
|
34
|
-
POLL_FIRST_DELAY = 10
|
|
35
|
-
POLL_DELAY = 30
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _require_env(name: str) -> str:
|
|
39
|
-
value = os.environ.get(name, "").strip()
|
|
40
|
-
if not value:
|
|
41
|
-
print(f"::error::{name} env var is required", file=sys.stderr)
|
|
42
|
-
raise SystemExit(1)
|
|
43
|
-
return value
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _find_build(token: str, app_id: str, marketing_version: str, build_number: str) -> str | None:
|
|
47
|
-
"""Return the build id matching (marketing_version, build_number), or None."""
|
|
48
|
-
params = {
|
|
49
|
-
"filter[app]": app_id,
|
|
50
|
-
"filter[preReleaseVersion.version]": marketing_version,
|
|
51
|
-
"filter[version]": build_number,
|
|
52
|
-
"limit": "5",
|
|
53
|
-
}
|
|
54
|
-
data = get_json("/builds", token, params=params)
|
|
55
|
-
items = data.get("data") or []
|
|
56
|
-
return items[0].get("id") if items else None
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _poll_for_build(token: str, app_id: str, marketing_version: str, build_number: str) -> str | None:
|
|
60
|
-
"""Poll ASC for the uploaded build. Returns build id or None on timeout."""
|
|
61
|
-
for attempt in range(POLL_ATTEMPTS):
|
|
62
|
-
delay = POLL_FIRST_DELAY if attempt == 0 else POLL_DELAY
|
|
63
|
-
print(
|
|
64
|
-
f"Waiting {delay}s for build {marketing_version} ({build_number}) to appear "
|
|
65
|
-
f"(attempt {attempt + 1}/{POLL_ATTEMPTS})",
|
|
66
|
-
file=sys.stderr,
|
|
67
|
-
)
|
|
68
|
-
time.sleep(delay)
|
|
69
|
-
build_id = _find_build(token, app_id, marketing_version, build_number)
|
|
70
|
-
if build_id:
|
|
71
|
-
print(f"Found build id {build_id}", file=sys.stderr)
|
|
72
|
-
return build_id
|
|
73
|
-
return None
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _existing_localization(token: str, build_id: str, locale: str) -> str | None:
|
|
77
|
-
"""Return the betaBuildLocalization id for `locale` on this build, or None."""
|
|
78
|
-
data = get_json(f"/builds/{build_id}/betaBuildLocalizations", token)
|
|
79
|
-
for item in data.get("data") or []:
|
|
80
|
-
if (item.get("attributes") or {}).get("locale") == locale:
|
|
81
|
-
return item.get("id")
|
|
82
|
-
return None
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _patch_localization(token: str, localization_id: str, whats_new: str) -> None:
|
|
86
|
-
body = {
|
|
87
|
-
"data": {
|
|
88
|
-
"type": "betaBuildLocalizations",
|
|
89
|
-
"id": localization_id,
|
|
90
|
-
"attributes": {"whatsNew": whats_new},
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
request("PATCH", f"/betaBuildLocalizations/{localization_id}", token, json_body=body)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _create_localization(token: str, build_id: str, locale: str, whats_new: str) -> None:
|
|
97
|
-
body = {
|
|
98
|
-
"data": {
|
|
99
|
-
"type": "betaBuildLocalizations",
|
|
100
|
-
"attributes": {"locale": locale, "whatsNew": whats_new},
|
|
101
|
-
"relationships": {
|
|
102
|
-
"build": {"data": {"type": "builds", "id": build_id}},
|
|
103
|
-
},
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
request("POST", "/betaBuildLocalizations", token, json_body=body)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def main() -> int:
|
|
110
|
-
whats_new = os.environ.get("TESTFLIGHT_WHATS_NEW", "")
|
|
111
|
-
if not whats_new.strip():
|
|
112
|
-
print("empty whatsNew; skipping", file=sys.stderr)
|
|
113
|
-
return 0
|
|
114
|
-
|
|
115
|
-
key_id = _require_env("ASC_KEY_ID")
|
|
116
|
-
issuer_id = _require_env("ASC_ISSUER_ID")
|
|
117
|
-
key_path = _require_env("ASC_KEY_PATH")
|
|
118
|
-
app_id = _require_env("APP_STORE_APPLE_ID")
|
|
119
|
-
marketing_version = _require_env("MARKETING_VERSION")
|
|
120
|
-
build_number = _require_env("BUILD_NUMBER")
|
|
121
|
-
locale = os.environ.get("TESTFLIGHT_LOCALE", "").strip() or "en-US"
|
|
122
|
-
|
|
123
|
-
token = make_jwt(key_id, issuer_id, key_path)
|
|
124
|
-
|
|
125
|
-
build_id = _poll_for_build(token, app_id, marketing_version, build_number)
|
|
126
|
-
if not build_id:
|
|
127
|
-
print(
|
|
128
|
-
f"::warning::build {marketing_version} ({build_number}) not found in ASC "
|
|
129
|
-
f"after {POLL_ATTEMPTS} polls; skipping whatsNew update",
|
|
130
|
-
file=sys.stderr,
|
|
131
|
-
)
|
|
132
|
-
return 0
|
|
133
|
-
|
|
134
|
-
existing = _existing_localization(token, build_id, locale)
|
|
135
|
-
if existing:
|
|
136
|
-
_patch_localization(token, existing, whats_new)
|
|
137
|
-
action = "updated"
|
|
138
|
-
else:
|
|
139
|
-
_create_localization(token, build_id, locale, whats_new)
|
|
140
|
-
action = "created"
|
|
141
|
-
print(f"Set betaBuildLocalization whatsNew for build {build_id} locale {locale} ({action})")
|
|
142
|
-
return 0
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if __name__ == "__main__":
|
|
146
|
-
sys.exit(main())
|