@daemux/store-automator 0.10.93 → 0.10.95

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.93"
8
+ "version": "0.10.95"
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.93",
15
+ "version": "0.10.95",
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.93",
3
+ "version": "0.10.95",
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.93",
3
+ "version": "0.10.95",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
package/src/install.mjs CHANGED
@@ -126,9 +126,32 @@ function mapPromptsToCiFields(prompted) {
126
126
  };
127
127
  }
128
128
 
129
- function printNextSteps(prompted) {
130
- const missing = [];
129
+ // Post-install printer for both modes. The Match-based flow lists
130
+ // missing-credential next steps; the GitHub Actions native iOS flow
131
+ // (no Match) prints the workflow-side requirements (push permissions,
132
+ // paths-ignore, repo visibility) the composite action cannot
133
+ // auto-configure. Co-located in one function so the iOS-native CI
134
+ // install path adds zero new top-level functions to this file. Note:
135
+ // the file currently has 14 top-level functions, above the 10-per-file
136
+ // guideline; that pre-dates this PR (no functions added by the
137
+ // cert-caching feature); refactor tracked separately.
138
+ function printNextSteps(prompted, isGitHubActions = false) {
139
+ console.log('');
140
+
141
+ if (isGitHubActions) {
142
+ console.log('Next steps for the iOS native TestFlight pipeline:');
143
+ console.log(' 1. Place your ASC API key at creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8');
144
+ console.log(' 2. Set workflow permissions: contents: write, models: read');
145
+ console.log(" 3. Add 'creds/**' to paths-ignore in your workflow trigger");
146
+ console.log(' 4. Make the repo PRIVATE (creds/ holds cert.p12 + .p8)');
147
+ console.log(' 5. Commit and push to your default branch — CI handles the rest');
148
+ console.log('');
149
+ console.log('See .github/IOS_NATIVE_CI_SETUP.md (or templates/github/IOS_NATIVE_CI_SETUP.md)');
150
+ console.log('for the full caching + renewal contract.');
151
+ return;
152
+ }
131
153
 
154
+ const missing = [];
132
155
  if (isPlaceholder(prompted.bundleId)) {
133
156
  missing.push('Set bundle ID in ci.config.yaml');
134
157
  }
@@ -142,16 +165,15 @@ function printNextSteps(prompted) {
142
165
  missing.push('Configure Match code signing (match_git_url, deploy key)');
143
166
  }
144
167
 
145
- console.log('');
146
168
  if (missing.length === 0) {
147
169
  console.log('All configuration complete! Start Claude Code.');
148
- } else {
149
- console.log('Next steps:');
150
- for (let i = 0; i < missing.length; i++) {
151
- console.log(` ${i + 1}. ${missing[i]}`);
152
- }
153
- console.log(` ${missing.length + 1}. Start Claude Code`);
170
+ return;
171
+ }
172
+ console.log('Next steps:');
173
+ for (let i = 0; i < missing.length; i++) {
174
+ console.log(` ${i + 1}. ${missing[i]}`);
154
175
  }
176
+ console.log(` ${missing.length + 1}. Start Claude Code`);
155
177
  }
156
178
 
157
179
  async function withReadline(fn) {
@@ -277,6 +299,6 @@ export async function runInstall(scope, isPostinstall = false, cliTokens = {}) {
277
299
  if (isGlobal) {
278
300
  printGlobalNote();
279
301
  } else {
280
- printNextSteps(prompted);
302
+ printNextSteps(prompted, isGitHubActions);
281
303
  }
282
304
  }
@@ -100,3 +100,191 @@ git add creds/
100
100
  git commit -m "ci: rotate ASC API key"
101
101
  git push
102
102
  ```
103
+
104
+ ## Persistent signing identity
105
+
106
+ On the **first** default-branch run, the action provisions an Apple
107
+ Distribution certificate plus one provisioning profile per signable
108
+ target and commits them back into `creds/`:
109
+
110
+ ```
111
+ creds/
112
+ AuthKey_<KEY_ID>_Issuer_<UUID>.p8 # your ASC API key (you supply)
113
+ cert.p12 # CI-managed: distribution cert + private key
114
+ cert.meta.json # CI-managed: cert id, NotAfter, p12 password
115
+ profiles.manifest.json # CI-managed: cert id + per-bundle profile entries
116
+ profiles/<UUID>.mobileprovision # CI-managed: one per signable bundle
117
+ ```
118
+
119
+ Subsequent runs reuse the cached identity. CI re-creates only what changed:
120
+
121
+ | Trigger | Action |
122
+ |---------|--------|
123
+ | Cert expires in <30 days | Regenerate cert + every profile, commit refreshed `creds/` |
124
+ | Cert revoked on App Store Connect | Same as expiry (cache invalidated, fresh cert created) |
125
+ | New signable target added | Generate just that bundle's profile, append to manifest |
126
+ | Existing target removed | GC its `.mobileprovision` from disk + manifest |
127
+ | Cert in 30–60 day window | `::warning::` in CI logs (no regeneration yet) |
128
+
129
+ ### Required workflow configuration
130
+
131
+ The composite action commits back to your default branch. Your workflow
132
+ MUST grant the right permissions and use a deep checkout:
133
+
134
+ ```yaml
135
+ permissions:
136
+ contents: write # required: action commits refreshed creds/
137
+ models: read # required: AI metadata inference
138
+
139
+ jobs:
140
+ deploy:
141
+ steps:
142
+ - uses: actions/checkout@v4
143
+ with:
144
+ fetch-depth: 0 # required: action rebases + pushes
145
+ persist-credentials: true
146
+ ```
147
+
148
+ The `templates/github/workflows/deploy.yml` shipped with this template
149
+ already does this — copy it verbatim.
150
+
151
+ `paths-ignore` for `creds/**` keeps the commit-back from re-triggering
152
+ the workflow (the action's commit message also carries `[skip ci]` as a
153
+ second-line defence).
154
+
155
+ ### `.gitignore` rules
156
+
157
+ If your repo's `.gitignore` blocks `creds/`, the cached identity files
158
+ MUST stay tracked. Either:
159
+
160
+ **Option A (recommended):** drop `creds/` from `.gitignore` entirely.
161
+ The whole directory is meant to be committed in this private repo.
162
+
163
+ **Option B:** ignore the *contents* of `creds/` but un-ignore the
164
+ specific files CI manages. Git cannot re-include a child once its
165
+ parent directory is fully ignored, so the pattern MUST use `creds/*`
166
+ (not `creds/`) and include a directory un-ignore for `profiles/`
167
+ before the file un-ignore inside it:
168
+
169
+ ```
170
+ creds/*
171
+ !creds/cert.p12
172
+ !creds/cert.meta.json
173
+ !creds/profiles.manifest.json
174
+ !creds/profiles/
175
+ !creds/profiles/*
176
+ !creds/AuthKey_*.p8
177
+ ```
178
+
179
+ The action force-adds (`git add -f`) the cache files, but a workflow
180
+ that pushes back to a `.gitignore`-blocked path will still surface a
181
+ confusing diff in PRs. Un-ignoring up-front is cleaner.
182
+
183
+ ### Repo MUST stay private
184
+
185
+ The `cert.p12` is now committed alongside the `.p8` ASC API key. Both
186
+ grant the ability to sign and ship binaries under your team. **Make the
187
+ repo private** (Settings → General → Danger Zone → Change visibility).
188
+ The `p12_password` is intentionally `"ci"` — encryption-at-rest of the
189
+ PKCS12 is decorative when the `.p8` sits next to it in plaintext.
190
+
191
+ ### Renewal cadence
192
+
193
+ * **T-60 days from cert expiry:** CI logs
194
+ `::warning::Cert <id> expires in <N>d; auto-renew fires at 30d remaining`.
195
+ No action needed.
196
+ * **T-30 days from cert expiry:** CI auto-regenerates the cert,
197
+ regenerates all profiles, commits refreshed `creds/`. Subsequent runs
198
+ use the new identity transparently.
199
+ * **Manual revoke** (Apple Developer portal or `App Store Connect` UI):
200
+ the next CI run detects the dead cert via the alive-check API call,
201
+ invalidates the cache, and provisions fresh material.
202
+
203
+ ### Manual reset
204
+
205
+ If you ever need to wipe the cache (debugging, suspected key compromise):
206
+
207
+ ```bash
208
+ rm -f creds/cert.p12 creds/cert.meta.json creds/profiles.manifest.json
209
+ rm -rf creds/profiles/
210
+ git add -A creds/
211
+ git commit -m "ci: reset signing cache"
212
+ git push
213
+ ```
214
+
215
+ The next CI run treats this as a cold-start and generates a fresh cert + profiles.
216
+
217
+ ## Auto-updates
218
+
219
+ The action ships with a per-run autoupdate check. On every default-branch
220
+ push, it queries npm for the latest `@daemux/swift-app-ci` version,
221
+ compares against the locally vendored marker
222
+ (`.github/actions/swift-app/.daemux-version`), and if newer, re-vendors
223
+ via `npx --yes @daemux/swift-app-ci`. The refreshed action files
224
+ (under `.github/actions/swift-app/`) are committed back alongside any
225
+ cert refresh in a single combined commit. `.github/workflows/deploy.yml`
226
+ is NEVER auto-committed — see "deploy.yml is not auto-updated" below.
227
+
228
+ | Aspect | Behaviour |
229
+ |--------|-----------|
230
+ | Trigger | Every push to the default branch (PR / feature branch runs do nothing) |
231
+ | Lag | One run — the *next* push after a new release picks up the update |
232
+ | Suppression | `[skip ci]` in the commit subject + `paths-ignore` for `.github/actions/swift-app/**` |
233
+ | Combined commit | One commit when cert refresh + autoupdate fire in the same run (subjects below) |
234
+ | Failure mode | Non-fatal: a failed `npm view` or `npx` emits `::warning::` and the build continues |
235
+
236
+ Commit subjects (all carry `[skip ci]`):
237
+
238
+ - `ci: refresh signing identity in creds/` — cert/profile refresh only
239
+ - `ci: autoupdate swift-app-ci` — vendored action update only
240
+ - `ci: refresh signing identity + autoupdate swift-app-ci` — both in one run
241
+
242
+ ### `paths-ignore` requirement
243
+
244
+ Your `deploy.yml` MUST include `.github/actions/swift-app/**` under
245
+ `paths-ignore` to prevent the autoupdate commit-back from re-triggering
246
+ the workflow. The shipped template already does this — copy it verbatim:
247
+
248
+ ```yaml
249
+ on:
250
+ push:
251
+ branches: [main]
252
+ paths-ignore:
253
+ - '**/*.md'
254
+ - '.gitignore'
255
+ - 'creds/**'
256
+ - '.github/actions/swift-app/**'
257
+ ```
258
+
259
+ ### deploy.yml is not auto-updated
260
+
261
+ **deploy.yml is NOT auto-updated.** GitHub's `GITHUB_TOKEN` cannot
262
+ push changes to workflow files (`.github/workflows/*.yml`) regardless
263
+ of `contents: write` — this is a built-in safeguard against CI
264
+ self-modification. When a new version of `@daemux/swift-app-ci`
265
+ requires `deploy.yml` schema changes (e.g., new permissions, new
266
+ paths-ignore entries), the action's release notes will call this out
267
+ and you must run `npx --yes @daemux/swift-app-ci` manually once to
268
+ sync your `deploy.yml`. Existing deploy.yml stays untouched on every
269
+ auto-update cycle until you do.
270
+
271
+ ### Opt out
272
+
273
+ Pin the vendored copy by passing `auto-update: 'false'`:
274
+
275
+ ```yaml
276
+ - uses: ./.github/actions/swift-app
277
+ with:
278
+ auto-update: 'false'
279
+ ```
280
+
281
+ ### First-run bootstrap
282
+
283
+ The autoupdate marker is written by `npx @daemux/swift-app-ci` itself.
284
+ A repo that has never run the installer (e.g. an old hand-vendored
285
+ copy) has no marker and the autoupdater will treat its current
286
+ version as `""` — the very first run will then re-vendor against
287
+ the latest published release, after which subsequent runs are
288
+ incremental. If you want to skip even that first auto-bootstrap,
289
+ either pass `auto-update: 'false'` or run `npx --yes
290
+ @daemux/swift-app-ci` once locally to drop the marker yourself.
@@ -2,9 +2,24 @@ name: iOS Deploy
2
2
  on:
3
3
  push:
4
4
  branches: [main]
5
- paths-ignore: ['**/*.md', '.gitignore']
5
+ # creds/ commit-back AND swift-app-ci autoupdate from the action
6
+ # would re-trigger the workflow. The commit messages carry [skip ci]
7
+ # but ignoring the paths is a belt-and-braces guard against trigger
8
+ # storms when [skip ci] is honored inconsistently across providers.
9
+ paths-ignore:
10
+ - '**/*.md'
11
+ - '.gitignore'
12
+ - 'creds/**'
13
+ - '.github/actions/swift-app/**'
6
14
  workflow_dispatch:
7
15
 
16
+ # contents:write lets the action commit the refreshed signing identity
17
+ # back to creds/ on default-branch runs. models:read keeps the AI
18
+ # metadata phases working (GitHub Models inference).
19
+ permissions:
20
+ contents: write
21
+ models: read
22
+
8
23
  concurrency:
9
24
  group: ios-deploy-${{ github.ref }}
10
25
  cancel-in-progress: true
@@ -15,4 +30,11 @@ jobs:
15
30
  timeout-minutes: 60
16
31
  steps:
17
32
  - uses: actions/checkout@v4
33
+ with:
34
+ # full clone so the action can rebase + push refreshed creds/
35
+ # back onto the default branch without losing recent commits.
36
+ fetch-depth: 0
37
+ # required for the GITHUB_TOKEN to remain authenticated for the
38
+ # subsequent `git push` from the action.
39
+ persist-credentials: true
18
40
  - uses: daemux/daemux-plugins/.github/actions/ios-native-testflight@main
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+ # Checks npm for newer @daemux/swift-app-ci, re-vendors via npx if newer.
3
+ # Stages refreshed files for commit. Exits 0 on no-op or non-fatal failure.
4
+ #
5
+ # DESIGN DECISION (intentional, not a bug):
6
+ # This script runs `npx --yes @daemux/swift-app-ci@<latest>` on every default-
7
+ # branch CI run with `permissions: contents: write`. That means the consumer
8
+ # repo trusts whatever code @daemux/swift-app-ci publishes to npm.
9
+ #
10
+ # Trust model — internally consistent with the rest of the action:
11
+ # - Consumers initially install via the same `npx --yes @daemux/swift-app-ci`
12
+ # command. They've already accepted the trust relationship.
13
+ # - The package is published from a private daemux-plugins repo via a
14
+ # dedicated GH Actions workflow with NPM_TOKEN. Compromise of that token
15
+ # would already be catastrophic regardless of this autoupdate path.
16
+ # - We pin to the exact version returned by `npm view` immediately above
17
+ # (`@$latest`) so the small TOCTOU window between view and exec can't
18
+ # swap a different version.
19
+ # - Consumers who want to pin can set `with: { auto-update: 'false' }` in
20
+ # their workflow.
21
+ #
22
+ # Hardening considered, not adopted:
23
+ # - `npm audit signatures` provenance check: requires npm to have published
24
+ # with --provenance, which the daemux publish workflow does not currently
25
+ # emit. Worth revisiting if/when daemux-plugins enables provenance.
26
+ # - Pinning to a fixed hash: defeats the autoupdate purpose.
27
+ # - Out-of-band verification (signed manifest): overkill for the threat
28
+ # model described above.
29
+ set -euo pipefail
30
+
31
+ VERSION_FILE=".github/actions/swift-app/.daemux-version"
32
+ PKG="@daemux/swift-app-ci"
33
+
34
+ current=""
35
+ if [[ -f "$VERSION_FILE" ]]; then
36
+ current="$(tr -d '[:space:]' < "$VERSION_FILE")"
37
+ fi
38
+
39
+ if ! latest="$(npm view "$PKG" version 2>/dev/null)"; then
40
+ echo "::warning::autoupdate: failed to query npm for $PKG"
41
+ exit 0
42
+ fi
43
+ latest="$(printf '%s' "$latest" | tr -d '[:space:]')"
44
+
45
+ if [[ -z "$latest" ]]; then
46
+ echo "::warning::autoupdate: empty version returned for $PKG"
47
+ exit 0
48
+ fi
49
+
50
+ if [[ "$current" == "$latest" ]]; then
51
+ echo "autoupdate: $PKG already at $latest"
52
+ exit 0
53
+ fi
54
+
55
+ echo "autoupdate: $PKG $current -> $latest; re-vendoring"
56
+
57
+ if ! npx --yes "$PKG@$latest"; then
58
+ echo "::warning::autoupdate: npx $PKG@$latest failed"
59
+ exit 0
60
+ fi
61
+
62
+ git config user.name >/dev/null 2>&1 || git config user.name "github-actions[bot]"
63
+ git config user.email >/dev/null 2>&1 || \
64
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
65
+
66
+ # NOTE: We deliberately do NOT stage .github/workflows/deploy.yml here.
67
+ # GITHUB_TOKEN cannot push workflow-file changes regardless of
68
+ # `permissions: contents: write` (GH security policy: prevents CI from
69
+ # modifying its own triggers). Consumer-side deploy.yml updates require
70
+ # manual `npx --yes @daemux/swift-app-ci` by the user. Document this in
71
+ # the action README and changelog when shipping a deploy.yml schema change.
72
+
73
+ # Stage everything under the vendored action dir EXCEPT Python bytecode
74
+ # caches. The action's own scripts run during this CI job and Python
75
+ # generates `__pycache__/*.pyc` files inside scripts/ as a side effect of
76
+ # importing them. Those caches are build artifacts of *this* run, not part
77
+ # of the action distribution -- sweeping them into the autoupdate bot
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`.
82
+ #
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.
91
+ #
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.
100
+ #
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" \
116
+ | tr '\n' '\0' \
117
+ | xargs -0 git add -f -- 2>/dev/null || true
118
+ fi
119
+ echo "autoupdate: staged refreshed action files (deploy.yml not staged — see comment; __pycache__ excluded)"
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cert-creation primitives for the iOS native TestFlight action.
4
+
5
+ Owns the cryptographic legwork (RSA key + CSR), Apple's per-team cert
6
+ cap rotation (revoke OLDEST on 409), and PKCS12 serialisation. Split
7
+ from ``prepare_signing.py`` so the orchestrator there stays focused on
8
+ the load-or-regen control flow and the file count stays under the
9
+ project's per-file limits.
10
+
11
+ Nothing here touches the on-disk cache — callers receive raw bytes and
12
+ hand them to ``creds_store`` for atomic persistence.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import os
19
+ import time
20
+
21
+ from asc_common import get_json, request
22
+ from cryptography import x509
23
+ from cryptography.hazmat.primitives import hashes, serialization
24
+ from cryptography.hazmat.primitives.asymmetric import rsa
25
+ from cryptography.hazmat.primitives.serialization import pkcs12
26
+ from cryptography.x509.oid import NameOID
27
+
28
+ # Apple's cert revoke -> create pipeline is eventually consistent: a
29
+ # DELETE on the per-team cap-blocking cert sometimes still shows up as
30
+ # "active" to a follow-up POST for a second or two, returning a fresh
31
+ # 409 even though we just freed a slot. Sleep then retry-with-backoff
32
+ # rather than failing the whole CI run on a known-transient race.
33
+ CERT_REVOKE_PROPAGATION_DELAY_SEC = float(
34
+ os.getenv("CERT_REVOKE_PROPAGATION_DELAY_SEC", "2.0")
35
+ )
36
+ CERT_POST_RETRIES_AFTER_REVOKE = int(
37
+ os.getenv("CERT_POST_RETRIES_AFTER_REVOKE", "2")
38
+ )
39
+
40
+
41
+ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
42
+ """Generate a fresh 2048-bit RSA key + PEM CSR (base64 body only).
43
+
44
+ Returns the private key (kept in memory; the caller serialises it
45
+ into the PKCS12) and the CSR with PEM headers stripped, ready for
46
+ Apple's ``csrContent`` field.
47
+ """
48
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
49
+ csr = (
50
+ x509.CertificateSigningRequestBuilder()
51
+ .subject_name(
52
+ x509.Name(
53
+ [
54
+ x509.NameAttribute(NameOID.COMMON_NAME, "Daemux CI"),
55
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
56
+ ]
57
+ )
58
+ )
59
+ .sign(private_key, hashes.SHA256())
60
+ )
61
+ csr_pem = csr.public_bytes(serialization.Encoding.PEM)
62
+ csr_payload = b"".join(
63
+ line for line in csr_pem.splitlines() if not line.startswith(b"-----")
64
+ )
65
+ return private_key, csr_payload
66
+
67
+
68
+ def oldest_distribution_cert_id(token: str) -> str | None:
69
+ """Return the DISTRIBUTION cert with the EARLIEST expirationDate.
70
+
71
+ Apple caps each team at 2 distribution certs; when CI hits the cap
72
+ we must revoke one. Picking the OLDEST is the safest choice — the
73
+ NEWEST cert signed the most recent build that may still be in ASC
74
+ processing, and revoking that mid-processing produces ITMS-90035
75
+ ("signed with an ad-hoc certificate, not a distribution
76
+ certificate") on the prior run. Older certs have long since cleared
77
+ processing.
78
+ """
79
+ data = get_json(
80
+ "/certificates",
81
+ token,
82
+ params={
83
+ "limit": "200",
84
+ "sort": "-id",
85
+ "filter[certificateType]": "DISTRIBUTION",
86
+ },
87
+ )
88
+ oldest_id = None
89
+ oldest_exp: str | None = None
90
+ for cert in data.get("data", []):
91
+ attrs = cert.get("attributes") or {}
92
+ exp = attrs.get("expirationDate") or ""
93
+ if not exp:
94
+ continue
95
+ if oldest_exp is None or exp < oldest_exp:
96
+ oldest_exp = exp
97
+ oldest_id = cert["id"]
98
+ return oldest_id
99
+
100
+
101
+ def _post_with_revoke_backoff(token: str, body: dict):
102
+ """POST a cert create after a revoke, tolerating ASC propagation lag.
103
+
104
+ Apple's DELETE -> POST pipeline is eventually consistent: a freshly
105
+ revoked cert may still count against the per-team cap for a brief
106
+ window, producing a spurious 409 on the immediate re-POST. Sleep
107
+ once for ``CERT_REVOKE_PROPAGATION_DELAY_SEC`` then retry up to
108
+ ``CERT_POST_RETRIES_AFTER_REVOKE`` times with exponential backoff.
109
+ The final attempt drops ``allow_status`` so a real 409 surfaces as
110
+ Apple's full body via ``request``'s SystemExit.
111
+ """
112
+ time.sleep(CERT_REVOKE_PROPAGATION_DELAY_SEC)
113
+ delay = CERT_REVOKE_PROPAGATION_DELAY_SEC
114
+ for attempt in range(CERT_POST_RETRIES_AFTER_REVOKE):
115
+ resp = request(
116
+ "POST", "/certificates", token, json_body=body, allow_status={409}
117
+ )
118
+ if resp.status_code != 409:
119
+ return resp
120
+ print(
121
+ f"Post-revoke 409 on attempt {attempt + 1}/"
122
+ f"{CERT_POST_RETRIES_AFTER_REVOKE}; "
123
+ f"sleeping {delay}s before final retry"
124
+ )
125
+ time.sleep(delay)
126
+ delay *= 2
127
+ # Final attempt without allow_status — let Apple's body surface via
128
+ # request()'s SystemExit if the cap is genuinely still hit.
129
+ return request("POST", "/certificates", token, json_body=body)
130
+
131
+
132
+ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
133
+ """POST a CSR to ASC; return ``(cert_id, cert_der)``.
134
+
135
+ On 409 (per-team cap of 2 hit) revoke the OLDEST existing cert
136
+ (see :func:`oldest_distribution_cert_id` for why) and retry with
137
+ backoff to absorb Apple's revoke -> create propagation lag (see
138
+ :func:`_post_with_revoke_backoff`).
139
+ """
140
+ body = {
141
+ "data": {
142
+ "type": "certificates",
143
+ "attributes": {
144
+ "csrContent": csr_b64,
145
+ "certificateType": "DISTRIBUTION",
146
+ },
147
+ }
148
+ }
149
+ resp = request(
150
+ "POST", "/certificates", token, json_body=body, allow_status={409}
151
+ )
152
+ if resp.status_code == 409:
153
+ # Revoke the OLDEST cert, NOT the newest. The newest cert signed
154
+ # the previous build that may still be in ASC processing — revoking
155
+ # it during processing yields ITMS-90035 on the prior build.
156
+ # See oldest_distribution_cert_id() for the full rationale.
157
+ print("Distribution cert cap hit; revoking oldest existing cert")
158
+ target = oldest_distribution_cert_id(token)
159
+ if not target:
160
+ raise SystemExit(
161
+ "409 from cert create but no existing DISTRIBUTION cert "
162
+ "found to revoke"
163
+ )
164
+ request("DELETE", f"/certificates/{target}", token)
165
+ resp = _post_with_revoke_backoff(token, body)
166
+ data = resp.json()["data"]
167
+ cert_id = data["id"]
168
+ cert_der = base64.b64decode(data["attributes"]["certificateContent"])
169
+ print(f"Created DISTRIBUTION cert {cert_id}")
170
+ return cert_id, cert_der
171
+
172
+
173
+ def serialize_p12(
174
+ private_key: rsa.RSAPrivateKey, cert_der: bytes, passwd: str
175
+ ) -> bytes:
176
+ """Return the PKCS12 bytes encrypted with ``passwd``.
177
+
178
+ Caller decides whether to write to disk via the cache layer or as a
179
+ transient artifact in ``$RUNNER_TEMP``.
180
+ """
181
+ cert = x509.load_der_x509_certificate(cert_der)
182
+ return pkcs12.serialize_key_and_certificates(
183
+ name=b"Daemux CI",
184
+ key=private_key,
185
+ cert=cert,
186
+ cas=None,
187
+ encryption_algorithm=serialization.BestAvailableEncryption(
188
+ passwd.encode()
189
+ ),
190
+ )