@daemux/store-automator 0.10.93 → 0.10.94
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/src/install.mjs +32 -10
- package/templates/github/IOS_NATIVE_CI_SETUP.md +188 -0
- package/templates/github/workflows/deploy.yml +23 -1
- package/templates/scripts/ci/ios-native/autoupdate_check.sh +73 -0
- package/templates/scripts/ci/ios-native/cert_factory.py +190 -0
- package/templates/scripts/ci/ios-native/commit_bot_changes.sh +60 -0
- package/templates/scripts/ci/ios-native/creds_store.py +328 -0
- package/templates/scripts/ci/ios-native/keychain.py +103 -0
- package/templates/scripts/ci/ios-native/prepare_signing.py +155 -271
- package/templates/scripts/ci/ios-native/profile_io.py +64 -0
- package/templates/scripts/ci/ios-native/profile_manager.py +147 -25
|
@@ -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.94"
|
|
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.94",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
package/src/install.mjs
CHANGED
|
@@ -126,9 +126,32 @@ function mapPromptsToCiFields(prompted) {
|
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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,73 @@
|
|
|
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
|
+
git add -A .github/actions/swift-app/ 2>/dev/null || true
|
|
73
|
+
echo "autoupdate: staged refreshed action files (deploy.yml not staged — see comment)"
|
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Commits whatever previous steps staged (cert refresh and/or autoupdate),
|
|
3
|
+
# pushes with up-to-3 attempts (rebase between). Exits 0 with ::warning::
|
|
4
|
+
# on exhausted retries so the wrapping job stays green.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
if git diff --cached --quiet; then
|
|
8
|
+
echo "commit_bot_changes: nothing staged, skipping"
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
staged="$(git diff --cached --name-only)"
|
|
13
|
+
has_cert=0
|
|
14
|
+
has_auto=0
|
|
15
|
+
# autoupdate_check.sh deliberately does NOT stage .github/workflows/deploy.yml
|
|
16
|
+
# (GITHUB_TOKEN cannot push workflow-file changes), so we only need to detect
|
|
17
|
+
# action/ paths to classify the commit as an autoupdate.
|
|
18
|
+
if grep -qE '^creds/' <<<"$staged"; then has_cert=1; fi
|
|
19
|
+
if grep -qE '^\.github/actions/swift-app/' <<<"$staged"; then has_auto=1; fi
|
|
20
|
+
|
|
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
|
|
28
|
+
subject="ci: bot maintenance [skip ci]"
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
git config user.name >/dev/null 2>&1 || git config user.name "github-actions[bot]"
|
|
32
|
+
git config user.email >/dev/null 2>&1 || \
|
|
33
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
34
|
+
|
|
35
|
+
git commit -m "$subject" \
|
|
36
|
+
-m "Co-Authored-By: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
|
37
|
+
|
|
38
|
+
branch="${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}"
|
|
39
|
+
|
|
40
|
+
attempt=1
|
|
41
|
+
max=3
|
|
42
|
+
while (( attempt <= max )); do
|
|
43
|
+
if git push origin "HEAD:$branch"; then
|
|
44
|
+
echo "commit_bot_changes: push succeeded on attempt $attempt"
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
echo "::warning::commit_bot_changes: push attempt $attempt failed"
|
|
48
|
+
if (( attempt < max )); then
|
|
49
|
+
git fetch origin "$branch" || true
|
|
50
|
+
git pull --rebase origin "$branch" || {
|
|
51
|
+
echo "::warning::commit_bot_changes: rebase failed, aborting"
|
|
52
|
+
git rebase --abort >/dev/null 2>&1 || true
|
|
53
|
+
exit 0
|
|
54
|
+
}
|
|
55
|
+
fi
|
|
56
|
+
attempt=$((attempt + 1))
|
|
57
|
+
done
|
|
58
|
+
|
|
59
|
+
echo "::warning::commit_bot_changes: exhausted $max push attempts"
|
|
60
|
+
exit 0
|