@daemux/store-automator 0.10.95 → 0.10.96

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.
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ # Note: ~68-line helper exceeds the 50-line function cap. Pre-existing
3
+ # in the source-of-truth; refactor tracked separately to avoid bundling
4
+ # unrelated edits into round-13 trims.
5
+ """
6
+ App Store Connect stale-editable appStoreVersion reuse.
7
+
8
+ Split out of asc_version_create.py to keep each module under the 400-line
9
+ cap. Owns the "PATCH then DELETE+POST fallback" flow that recovers from
10
+ ASC's "You cannot create a new version of the App in the current state."
11
+ 409 (CI run 24640907316).
12
+
13
+ Why: ASC allows only ONE editable appStoreVersion at a time. When a
14
+ stale editable is sitting below our target marketing floor, a straight
15
+ POST of the new version returns 409 ENTITY_ERROR.RELATIONSHIP.INVALID
16
+ with pointer /data/relationships/app. Bumping the version cannot fix
17
+ this -- the editable itself must be renamed (PATCH) or removed (DELETE)
18
+ first.
19
+
20
+ Exit code:
21
+ 5 -- PATCH failed AND DELETE failed (e.g. version locked by review);
22
+ manual intervention required in ASC.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json as _json
28
+ import sys
29
+
30
+ from asc_common import request
31
+
32
+
33
+ # Substrings in error.detail that indicate "a stale editable exists and
34
+ # blocks creating a new version". ASC returns this as 409 with
35
+ # code=ENTITY_ERROR.RELATIONSHIP.INVALID and pointer=/data/relationships/app
36
+ # (CI run 24640907316). Case-insensitive match.
37
+ _EDITABLE_EXISTS_DETAIL_HINTS = (
38
+ "create a new version",
39
+ "cannot create a new version",
40
+ "current state",
41
+ )
42
+
43
+
44
+ def _log_body(body: dict, tag: str) -> None:
45
+ """Pretty-print a request body to stderr before the call."""
46
+ try:
47
+ pretty = _json.dumps(body, indent=2, sort_keys=True)
48
+ except (TypeError, ValueError):
49
+ pretty = repr(body)
50
+ print(f"[{tag}] request body: {pretty}", file=sys.stderr)
51
+
52
+
53
+ def patch_version(app_id: str, existing_id: str, target_version: str, token: str):
54
+ """PATCH /appStoreVersions/{id} to rename versionString. Preserves
55
+ metadata and review attachments. 4xx statuses allowed so the caller
56
+ can fall back to DELETE+POST.
57
+
58
+ ASC PATCH shape per JSON:API: data.type=appStoreVersions, data.id
59
+ must match the URL id, attributes carries the rename."""
60
+ body = {
61
+ "data": {
62
+ "type": "appStoreVersions",
63
+ "id": str(existing_id),
64
+ "attributes": {"versionString": target_version},
65
+ }
66
+ }
67
+ _log_body(body, "patch-version")
68
+ return request(
69
+ "PATCH", f"/appStoreVersions/{existing_id}", token,
70
+ json_body=body, allow_status={400, 403, 404, 409, 422},
71
+ )
72
+
73
+
74
+ def delete_version(app_id: str, existing_id: str, token: str):
75
+ """DELETE /appStoreVersions/{id}. 4xx statuses allowed so the caller
76
+ can surface a clear error when the record is locked by review."""
77
+ return request(
78
+ "DELETE", f"/appStoreVersions/{existing_id}", token,
79
+ allow_status={400, 403, 404, 409, 422},
80
+ )
81
+
82
+
83
+ def _is_2xx(resp) -> bool:
84
+ return 200 <= getattr(resp, "status_code", 0) < 300
85
+
86
+
87
+ def _body_text(resp) -> str:
88
+ t = getattr(resp, "text", None)
89
+ return t if isinstance(t, str) else ""
90
+
91
+
92
+ def reuse_stale_editable(
93
+ *,
94
+ app_id: str,
95
+ existing_id: str,
96
+ target_version: str,
97
+ token: str,
98
+ patch_fn=None,
99
+ delete_fn=None,
100
+ post_fn=None,
101
+ ) -> str:
102
+ """Reuse a stale editable appStoreVersion that blocks creating a new
103
+ version. Tries PATCH (rename) first; on failure falls back to
104
+ DELETE + POST. SystemExit(5) when DELETE also fails.
105
+
106
+ All three ASC calls are injected so tests can drive the sequence
107
+ without hitting the network. ``post_fn`` must accept
108
+ ``(app_id, version, token)`` -- same shape as ``create_version``.
109
+ """
110
+ patch = patch_fn if patch_fn is not None else patch_version
111
+ delete = delete_fn if delete_fn is not None else delete_version
112
+ if post_fn is None:
113
+ # Lazy import to avoid a circular dep with asc_version_create.
114
+ from asc_version_create import create_version as _default_post
115
+ post = _default_post
116
+ else:
117
+ post = post_fn
118
+
119
+ print(
120
+ f"[reuse-stale] PATCH /appStoreVersions/{existing_id} "
121
+ f"versionString -> {target_version}",
122
+ file=sys.stderr,
123
+ )
124
+ resp = patch(app_id, existing_id, target_version, token)
125
+ if _is_2xx(resp):
126
+ print("[reuse-stale] PATCH ok", file=sys.stderr)
127
+ return existing_id
128
+
129
+ print(
130
+ f"[reuse-stale] PATCH failed status={resp.status_code} "
131
+ f"body={_body_text(resp)}",
132
+ file=sys.stderr,
133
+ )
134
+
135
+ print(
136
+ f"[reuse-stale] DELETE /appStoreVersions/{existing_id}",
137
+ file=sys.stderr,
138
+ )
139
+ del_resp = delete(app_id, existing_id, token)
140
+ if not _is_2xx(del_resp):
141
+ print(
142
+ f"::error::stale editable appStoreVersion id={existing_id} "
143
+ f"cannot be renamed or deleted -- manually intervene in ASC. "
144
+ f"DELETE status={del_resp.status_code} body={_body_text(del_resp)}",
145
+ file=sys.stderr,
146
+ )
147
+ raise SystemExit(5)
148
+
149
+ print("[reuse-stale] DELETE ok, retrying POST", file=sys.stderr)
150
+ post_resp = post(app_id, target_version, token)
151
+ if not _is_2xx(post_resp):
152
+ print(
153
+ f"::error::stale editable {existing_id} was deleted but POST "
154
+ f"for {target_version} still failed "
155
+ f"status={post_resp.status_code} body={_body_text(post_resp)}",
156
+ file=sys.stderr,
157
+ )
158
+ raise SystemExit(5)
159
+ return post_resp.json()["data"]["id"]
160
+
161
+
162
+ def is_editable_exists_409(errors: list[dict]) -> bool:
163
+ """True iff the 409 is attributable to a pre-existing editable
164
+ appStoreVersion (ASC allows only one editable at a time). Matches
165
+ the classifier in the CI run 24640907316 spec: code is
166
+ ENTITY_ERROR.RELATIONSHIP.INVALID AND detail contains "create a
167
+ new version" (or similar hint)."""
168
+ for e in errors:
169
+ if not isinstance(e, dict):
170
+ continue
171
+ code = (e.get("code") or "").strip()
172
+ detail = (e.get("detail") or "").strip().lower()
173
+ if code == "ENTITY_ERROR.RELATIONSHIP.INVALID" and any(
174
+ hint in detail for hint in _EDITABLE_EXISTS_DETAIL_HINTS
175
+ ):
176
+ return True
177
+ return False
@@ -76,44 +76,39 @@ git config user.email >/dev/null 2>&1 || \
76
76
  # importing them. Those caches are build artifacts of *this* run, not part
77
77
  # of the action distribution -- sweeping them into the autoupdate bot
78
78
  # commit is benign but noisy (every clean run emits a "refresh
79
- # __pycache__" diff). `git ls-files -mo` enumerates modified+other
80
- # (untracked) paths under the directory; we filter out anything matching
81
- # `/__pycache__/` or ending in `.pyc`, then feed the rest to `git add -f`.
79
+ # __pycache__" diff).
82
80
  #
83
- # We deliberately do NOT pass `--exclude-standard`: that flag would make
84
- # ls-files honor the consumer's .gitignore at discovery time. If a
85
- # consumer ignores e.g. `.daemux-version` or any other autoupdate-managed
86
- # path, ls-files would silently drop it and the bot commit would never
87
- # refresh that file -- defeating the autoupdate. The matching `-f` on
88
- # `git add` already overrides .gitignore at staging time, so the only
89
- # files we want to filter are the bytecode caches above, which our
90
- # explicit grep handles deterministically.
81
+ # DESIGN DECISION: use `git add -A` (stages additions, modifications, AND
82
+ # deletions) instead of the previous `git ls-files -mo` flow. `ls-files
83
+ # -mo` enumerates modified + other (untracked) paths but NOT deletions:
84
+ # when an autoupdate cycle removes a script (e.g. `prepare_signing.py`
85
+ # gets renamed to `prepare_signing_v2.py` in a new release), the old
86
+ # file would stay tracked in the consumer repo forever -- the bot
87
+ # commit would add the new file but never remove the orphan. `git add
88
+ # -A` handles all three change kinds in one call. We then unstage any
89
+ # pycache paths that the broad `-A` swept in.
91
90
  #
92
- # Empty-input guard: BSD xargs (default on macos-15 runners) does NOT
93
- # support `-r`/`--no-run-if-empty`, so on a clean tree where the grep
94
- # filter strips everything, piping an empty stream into `xargs git add`
95
- # would invoke `git add -f --` with no positional arguments. That exits
96
- # 129 ("Nothing specified, nothing added"), which `set -euo pipefail`
97
- # treats as fatal even though it's the no-op case we want. Capture to a
98
- # variable first and skip the xargs call when empty -- portable across
99
- # both BSD and GNU xargs.
91
+ # Force flag (`-f`) is implicit in `git add -A` for explicitly listed
92
+ # pathspecs that match .gitignore patterns? No -- `-A` still honors
93
+ # .gitignore for untracked files. To preserve the prior semantics
94
+ # (consumer's .gitignore must not silently drop autoupdate-managed
95
+ # files like `.daemux-version`), we pass `--force` explicitly.
100
96
  #
101
- # We deliberately do NOT pass `--directory`: with that flag, `git ls-files`
102
- # collapses any *new* untracked directory to a single entry (the directory
103
- # path itself), e.g. it would emit `.github/actions/swift-app/scripts/`
104
- # instead of enumerating its files. If such a collapsed parent contains
105
- # `__pycache__/` children, our grep would NOT match `__pycache__/` against
106
- # the parent path -- the directory entry would slip past the filter and
107
- # `git add -f <dir>` would recurse into it, dragging the bytecode caches
108
- # in with everything else. Without `--directory`, ls-files enumerates
109
- # every individual untracked file path so the grep filter sees each
110
- # `__pycache__/...` and `*.pyc` entry by name and strips it
111
- # deterministically.
112
- files="$(git ls-files -mo .github/actions/swift-app/ \
113
- | grep -v -E '(/__pycache__/|\.pyc$)' || true)"
114
- if [[ -n "$files" ]]; then
115
- printf '%s\n' "$files" \
97
+ # BSD xargs (default on macos-15 runners) does NOT support
98
+ # `-r`/`--no-run-if-empty`, so we capture pycache paths into a variable
99
+ # and skip the xargs call when empty -- portable across BSD and GNU.
100
+ git add -A --force -- .github/actions/swift-app/ 2>/dev/null || true
101
+
102
+ # Unstage Python bytecode that the broad `-A` swept in. `git diff
103
+ # --cached --name-only` lists every staged path; we filter to pycache
104
+ # entries and `git reset HEAD --` to drop them from the index. Working
105
+ # tree is left untouched (the .pyc files remain on disk; only their
106
+ # index entries are removed).
107
+ pycache="$(git diff --cached --name-only -- .github/actions/swift-app/ \
108
+ | grep -E '(/__pycache__/|\.pyc$)' || true)"
109
+ if [[ -n "$pycache" ]]; then
110
+ printf '%s\n' "$pycache" \
116
111
  | tr '\n' '\0' \
117
- | xargs -0 git add -f -- 2>/dev/null || true
112
+ | xargs -0 git reset HEAD -- 2>/dev/null || true
118
113
  fi
119
114
  echo "autoupdate: staged refreshed action files (deploy.yml not staged — see comment; __pycache__ excluded)"
@@ -12,20 +12,42 @@ fi
12
12
  staged="$(git diff --cached --name-only)"
13
13
  has_cert=0
14
14
  has_auto=0
15
+ has_bump=0
15
16
  # autoupdate_check.sh deliberately does NOT stage .github/workflows/deploy.yml
16
17
  # (GITHUB_TOKEN cannot push workflow-file changes), so we only need to detect
17
18
  # action/ paths to classify the commit as an autoupdate.
18
19
  if grep -qE '^creds/' <<<"$staged"; then has_cert=1; fi
19
20
  if grep -qE '^\.github/actions/swift-app/' <<<"$staged"; then has_auto=1; fi
21
+ # marketing-version-auto-bump (mmv_floor_check) writes the bumped value
22
+ # into the project's source-of-truth (resolved by version_utils):
23
+ # - xcodegen project.yml / project.yaml / Project.yml / Project.yaml
24
+ # - *.xcconfig referenced by the project
25
+ # - project.pbxproj (only when no xcodegen spec is present)
26
+ # - Info.plist's CFBundleShortVersionString
27
+ # Any of those staged paths means a version bump rode along in this
28
+ # commit; surface it in the subject so reviewers see the version
29
+ # delta without diffing.
30
+ if grep -qE '(\.pbxproj|Info\.plist|(^|/)[Pp]roject\.ya?ml|\.xcconfig)$' \
31
+ <<<"$staged"; then
32
+ has_bump=1
33
+ fi
20
34
 
21
- if [[ $has_cert -eq 1 && $has_auto -eq 1 ]]; then
22
- subject="ci: refresh signing identity + autoupdate swift-app-ci [skip ci]"
23
- elif [[ $has_cert -eq 1 ]]; then
24
- subject="ci: refresh signing identity in creds/ [skip ci]"
25
- elif [[ $has_auto -eq 1 ]]; then
26
- subject="ci: autoupdate swift-app-ci [skip ci]"
27
- else
35
+ # Build a subject that lists every concern that actually fired. Avoids a
36
+ # 2^N case explosion by composing components in a stable order.
37
+ parts=()
38
+ if [[ $has_cert -eq 1 ]]; then parts+=("refresh signing identity"); fi
39
+ if [[ $has_auto -eq 1 ]]; then parts+=("autoupdate swift-app-ci"); fi
40
+ if [[ $has_bump -eq 1 ]]; then parts+=("bump MARKETING_VERSION"); fi
41
+ if [[ ${#parts[@]} -eq 0 ]]; then
28
42
  subject="ci: bot maintenance [skip ci]"
43
+ else
44
+ joined=""
45
+ for ((i = 0; i < ${#parts[@]}; i++)); do
46
+ if [[ $i -eq 0 ]]; then joined="${parts[i]}"
47
+ else joined="$joined + ${parts[i]}"
48
+ fi
49
+ done
50
+ subject="ci: $joined [skip ci]"
29
51
  fi
30
52
 
31
53
  git config user.name >/dev/null 2>&1 || git config user.name "github-actions[bot]"