@daemux/store-automator 0.10.90 → 0.10.91

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.90"
8
+ "version": "0.10.91"
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.90",
15
+ "version": "0.10.91",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.90",
3
+ "version": "0.10.91",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.90",
3
+ "version": "0.10.91",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -83,7 +83,31 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
83
83
  return private_key, csr_payload
84
84
 
85
85
 
86
- def newest_distribution_cert_id(token: str) -> str | None:
86
+ def oldest_distribution_cert_id(token: str) -> str | None:
87
+ """Return the DISTRIBUTION cert with the EARLIEST expiration date.
88
+
89
+ Apple's per-team distribution-cert cap is 2. When CI hits the cap we
90
+ must revoke one to make room for a fresh cert. We deliberately pick
91
+ the OLDEST cert (earliest expirationDate) because:
92
+
93
+ * Each CI run signs an IPA, uploads it to ASC, and that build then
94
+ spends minutes-to-hours in App Store Connect's processing
95
+ pipeline. Processing re-validates the binary's leaf cert against
96
+ Apple's current cert state. A revoked cert during processing
97
+ produces ITMS-90035 ("invalid signature ... signed with an ad-hoc
98
+ certificate, not a distribution certificate") and rejects the
99
+ build that was already accepted at upload time.
100
+
101
+ * The NEWEST cert is, by construction, the one that signed the most
102
+ recent build — i.e. the build that is right now in ASC processing
103
+ and most exposed to the revocation race. Revoking it (the prior
104
+ behavior) reliably broke the build the previous CI run produced.
105
+
106
+ * The OLDEST cert is the one most likely to be from a build whose
107
+ ASC processing has long since completed (each CI run only adds
108
+ builds; processing finishes in minutes). Revoking it is the
109
+ safest choice.
110
+ """
87
111
  data = get_json(
88
112
  "/certificates",
89
113
  token,
@@ -93,15 +117,17 @@ def newest_distribution_cert_id(token: str) -> str | None:
93
117
  "filter[certificateType]": "DISTRIBUTION",
94
118
  },
95
119
  )
96
- newest_id = None
97
- newest_exp = ""
120
+ oldest_id = None
121
+ oldest_exp: str | None = None
98
122
  for cert in data.get("data", []):
99
123
  attrs = cert.get("attributes") or {}
100
124
  exp = attrs.get("expirationDate") or ""
101
- if exp > newest_exp:
102
- newest_exp = exp
103
- newest_id = cert["id"]
104
- return newest_id
125
+ if not exp:
126
+ continue
127
+ if oldest_exp is None or exp < oldest_exp:
128
+ oldest_exp = exp
129
+ oldest_id = cert["id"]
130
+ return oldest_id
105
131
 
106
132
 
107
133
  def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
@@ -118,8 +144,12 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
118
144
  "POST", "/certificates", token, json_body=body, allow_status={409}
119
145
  )
120
146
  if resp.status_code == 409:
121
- print("Distribution cert cap hit; revoking newest existing cert")
122
- target = newest_distribution_cert_id(token)
147
+ # Revoke the OLDEST cert, NOT the newest. The newest cert signed
148
+ # the previous build that may still be in ASC processing — revoking
149
+ # it during processing yields ITMS-90035 on the prior build.
150
+ # See oldest_distribution_cert_id() for the full rationale.
151
+ print("Distribution cert cap hit; revoking oldest existing cert")
152
+ target = oldest_distribution_cert_id(token)
123
153
  if not target:
124
154
  raise SystemExit(
125
155
  "409 from cert create but no existing DISTRIBUTION cert "
@@ -78,8 +78,31 @@ def _log(msg: str) -> None:
78
78
  print(msg, file=sys.stderr)
79
79
 
80
80
 
81
- def _patch_localization(token: str, localization_id: str, whats_new: str) -> None:
82
- request(
81
+ def _patch_localization(
82
+ token: str, localization_id: str, whats_new: str
83
+ ) -> bool:
84
+ """PATCH a single localization's whatsNew. Returns True on success,
85
+ False when Apple reports the localization is locked (409 STATE_ERROR).
86
+
87
+ Apple returns 409 STATE_ERROR ("Attribute 'whatsNew' cannot be edited
88
+ at this time") on individual localizations even when the parent
89
+ appStoreVersion's appStoreState is in the editable allow-list checked
90
+ upstream. This happens when the per-localization state is locked
91
+ independently (e.g. submitted, in-review at the localization level,
92
+ or transitioning) -- a race the version-level state check at the top
93
+ of main() cannot see.
94
+
95
+ Without this allow-list, asc_common.request() raises SystemExit on
96
+ the 409, which the script's top-level try/except cannot swallow
97
+ (SystemExit is explicitly re-raised). The whole CI run then fails
98
+ at exit code 1 even though the IPA upload already succeeded.
99
+
100
+ Other non-2xx statuses (auth, 5xx, malformed payloads, real ASC
101
+ outages) are NOT in the allow-list and continue to fail loud --
102
+ asc_common.request() retries 5xx automatically and SystemExits on
103
+ everything else.
104
+ """
105
+ resp = request(
83
106
  "PATCH",
84
107
  f"/appStoreVersionLocalizations/{localization_id}",
85
108
  token,
@@ -90,7 +113,17 @@ def _patch_localization(token: str, localization_id: str, whats_new: str) -> Non
90
113
  "attributes": {"whatsNew": whats_new},
91
114
  }
92
115
  },
116
+ allow_status={409},
93
117
  )
118
+ if resp.status_code == 409:
119
+ _warn(
120
+ f"appStoreVersionLocalization {localization_id} returned 409 "
121
+ f"STATE_ERROR (locked); whatsNew not patched for this "
122
+ f"localization. Other localizations and the rest of the run "
123
+ f"continue."
124
+ )
125
+ return False
126
+ return True
94
127
 
95
128
 
96
129
  def _create_localization(
@@ -155,18 +188,26 @@ def _update_all_localizations(
155
188
  return 1
156
189
 
157
190
  count = 0
191
+ skipped = 0
158
192
  for item in entries:
159
193
  loc_id = item.get("id") or ""
160
194
  loc = (item.get("attributes") or {}).get("locale") or "?"
161
195
  if not loc_id:
162
196
  _warn(f"skipping localization without id: {item!r}")
163
197
  continue
164
- _patch_localization(token, loc_id, whats_new)
198
+ if _patch_localization(token, loc_id, whats_new):
199
+ _log(
200
+ f"PATCHed appStoreVersionLocalization {loc_id} whatsNew "
201
+ f"for {version} ({loc})"
202
+ )
203
+ count += 1
204
+ else:
205
+ skipped += 1
206
+ if skipped:
165
207
  _log(
166
- f"PATCHed appStoreVersionLocalization {loc_id} whatsNew "
167
- f"for {version} ({loc})"
208
+ f"whatsNew skipped for {skipped} locked localization(s) "
209
+ f"(see warnings above)"
168
210
  )
169
- count += 1
170
211
  return count
171
212
 
172
213