Repository navigation
Expand file tree
/
Copy pathrelease.sh
More file actions
executable file
·785 lines (719 loc) · 33.1 KB
/
Copy pathrelease.sh
File metadata and controls
executable file
·785 lines (719 loc) · 33.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
#!/usr/bin/env bash
set -euo pipefail
MODE="${1:-preflight}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${REPOPROMPT_RELEASE_SOURCE_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
CONTROL_PLANE_SCRIPTS_DIR="${REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR:-$ROOT_DIR/Scripts}"
TRUSTED_ROOT="$(cd "$CONTROL_PLANE_SCRIPTS_DIR/.." && pwd)"
APPROVED_SOURCE_ROOT="${REPOPROMPT_APPROVED_SOURCE_ROOT:-$ROOT_DIR}"
CODEX_MANIFEST="$APPROVED_SOURCE_ROOT/Vendor/Codex/manifest.json"
cd "$ROOT_DIR"
source "$CONTROL_PLANE_SCRIPTS_DIR/load_release_metadata.sh"
source "$CONTROL_PLANE_SCRIPTS_DIR/release_sentry_symbols.sh"
load_release_metadata "$ROOT_DIR"
DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}"
APP_BUNDLE="$ROOT_DIR/.build/release/$APP_NAME.app"
DISTRIBUTION_APP_BUNDLE_NAME="$DISPLAY_NAME.app"
ARCHIVE_BASENAME="${APP_NAME}-${MARKETING_VERSION}-${BUILD_NUMBER}"
UPDATE_ZIP="$DIST_DIR/$ARCHIVE_BASENAME.zip"
DMG="$DIST_DIR/$ARCHIVE_BASENAME.dmg"
APPCAST="$DIST_DIR/appcast.xml"
CHECKSUMS="$DIST_DIR/SHA256SUMS"
BUILD_ARTIFACT_MANIFEST="$ROOT_DIR/.build/release/$APP_NAME-artifact-manifest.json"
SENTRY_SYMBOLS_DIR="$ROOT_DIR/.build/sentry-symbols/release"
SENTRY_RELEASE_NAME="$BUNDLE_ID@$MARKETING_VERSION+$BUILD_NUMBER"
SENTRY_API_BASE_URL="${REPOPROMPT_SENTRY_API_BASE_URL:-https://sentry.io/api/0}"
SENTRY_CONNECT_TIMEOUT_SECONDS="${REPOPROMPT_SENTRY_CONNECT_TIMEOUT_SECONDS:-10}"
SENTRY_REQUEST_TIMEOUT_SECONDS="${REPOPROMPT_SENTRY_REQUEST_TIMEOUT_SECONDS:-60}"
SENTRY_OPERATION_ATTEMPTS=3
SENTRY_CURL_CONFIG=""
FINAL_ARTIFACT_MANIFEST="$DIST_DIR/$ARCHIVE_BASENAME-artifact-manifest.json"
STAGE_ARCHIVE="$DIST_DIR/$ARCHIVE_BASENAME-stage.zip"
STAGE_ARCHIVE_CHECKSUM="$STAGE_ARCHIVE.sha256"
RELEASE_TAG="${RELEASE_TAG:-}"
GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-repoprompt/repoprompt-ce}"
PUBLIC_UPDATE_REPOSITORY="${PUBLIC_UPDATE_REPOSITORY:-repoprompt/repoprompt-ce-updates}"
EXPECTED_FEED_URL="https://github.com/repoprompt/repoprompt-ce-updates/releases/latest/download/appcast.xml"
SPARKLE_FRAMEWORK_INFO="$ROOT_DIR/Vendor/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework/Versions/B/Resources/Info.plist"
TMP_DIR=""
RUN_WITHOUT_GITHUB_TOKENS="$CONTROL_PLANE_SCRIPTS_DIR/run_without_github_tokens.sh"
STABLE_ROLLOUT_TOOL="$CONTROL_PLANE_SCRIPTS_DIR/stable_rollout.py"
APPLE_IDENTITY_POLICY="$CONTROL_PLANE_SCRIPTS_DIR/apple_identity_policy.json"
ROLLOUT_DECLARATION="$APPROVED_SOURCE_ROOT/release-rollout.json"
ROLLOUT_MANIFEST="$DIST_DIR/$ARCHIVE_BASENAME-stable-rollout.json"
SIGN_UPDATE="$TRUSTED_ROOT/Vendor/Sparkle/bin/sign_update"
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
# release.sh builds legacy/preparer application artifacts only. The reviewed
# release-rollout.json declaration is the single role authority: transition or
# successor declarations, sibling predecessors, and the successor identity all
# fail here before signing or GitHub mutation.
require_dormant_rollout_declaration() {
require_file "$ROLLOUT_DECLARATION"
require_file "$APPLE_IDENTITY_POLICY"
require_file "$STABLE_ROLLOUT_TOOL"
python3 "$STABLE_ROLLOUT_TOOL" workflow-guard \
--declaration "$ROLLOUT_DECLARATION" \
--policy "$APPLE_IDENTITY_POLICY"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
require_file() {
[[ -f "$1" ]] || fail "Missing required file: $1"
}
require_env() {
[[ -n "${!1:-}" ]] || fail "Missing required environment variable: $1"
}
validate_bounded_positive_integer() {
local name="$1"
local value="$2"
local maximum="$3"
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail "$name must be a positive integer"
(( value <= maximum )) || fail "$name must not exceed $maximum seconds"
}
validate_sentry_network_bounds() {
validate_bounded_positive_integer REPOPROMPT_SENTRY_CONNECT_TIMEOUT_SECONDS "$SENTRY_CONNECT_TIMEOUT_SECONDS" 60
validate_bounded_positive_integer REPOPROMPT_SENTRY_REQUEST_TIMEOUT_SECONDS "$SENTRY_REQUEST_TIMEOUT_SECONDS" 300
(( SENTRY_CONNECT_TIMEOUT_SECONDS <= SENTRY_REQUEST_TIMEOUT_SECONDS )) ||
fail "REPOPROMPT_SENTRY_CONNECT_TIMEOUT_SECONDS must not exceed REPOPROMPT_SENTRY_REQUEST_TIMEOUT_SECONDS"
}
sentry_linking_enabled() {
release_sentry_linking_enabled
}
require_release_tag_matches_metadata() {
[[ "$RELEASE_TAG" == "v$MARKETING_VERSION" ]] ||
fail "Release tag must match release metadata: expected v$MARKETING_VERSION, got ${RELEASE_TAG:-<missing>}"
}
cleanup() {
[[ -z "$TMP_DIR" ]] || rm -rf "$TMP_DIR"
}
trap cleanup EXIT
prepare_dist() {
[[ "$DIST_DIR" != "/" ]] || fail "DIST_DIR must not be /"
rm -rf "$DIST_DIR"
mkdir -p "$DIST_DIR"
}
run_preflight() {
require_command plutil
require_command shasum
require_file "$CONTROL_PLANE_SCRIPTS_DIR/swiftpm_notice_guardrails.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/codex_vendor_guardrails.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/validate_packaged_legal.sh"
require_file "$ROOT_DIR/AppBundle/Info.plist.template"
require_file "$ROOT_DIR/AppBundle/RepoPrompt.entitlements.template"
require_file "$ROOT_DIR/AppBundle/RepoPrompt.local-self-signed.entitlements.template"
require_file "$ROOT_DIR/Vendor/Sparkle/LICENSE"
require_file "$ROOT_DIR/Vendor/Sparkle/PROVENANCE.md"
require_file "$ROOT_DIR/Vendor/Sparkle/SHA256SUMS"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/sign_staged_release.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/embedded_provisioning_profile.py"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/upload_sentry_debug_symbols.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/release_sentry_symbols.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/build_swiftpm_release_products.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/compare_swiftpm_release_resources.py"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/smoke_embedded_mcp_helper.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/smoke_packaged_mcp_roundtrip.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/verify_packaged_mcp_socket_owner.py"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/sync_mcp_cli_version.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/validate_embedded_mcp_helper_layout.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/validate_required_swiftpm_resource_bundles.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/patch_keyboard_shortcuts_resource_lookup.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/patches/keyboardshortcuts-2.3.0-resource-lookup.patch"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/validate_app_architectures.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/write_app_artifact_manifest.py"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/codex_runtime_artifact.py"
require_file "$CODEX_MANIFEST"
require_file "$ROOT_DIR/ThirdPartyLicenses/codex/LICENSE"
require_file "$ROOT_DIR/ThirdPartyLicenses/codex/NOTICE"
require_file "$ROOT_DIR/ThirdPartyLicenses/codex/ZSH-LICENCE"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/extract_staged_release.py"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/validate_staged_release.sh"
require_file "$RUN_WITHOUT_GITHUB_TOKENS"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/verify_remote_release_commit.sh"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/verify_sparkle_vendor.sh"
require_file "$TRUSTED_ROOT/Vendor/Sparkle/INSTALLED_MANIFEST.tsv"
require_file "$SIGN_UPDATE"
require_file "$SPARKLE_FRAMEWORK_INFO"
require_file "$CONTROL_PLANE_SCRIPTS_DIR/build_identity_transition_pkg.sh"
require_dormant_rollout_declaration
local sparkle_version feed_url
sparkle_version="$(plutil -extract CFBundleShortVersionString raw "$SPARKLE_FRAMEWORK_INFO")"
feed_url="$(plutil -extract SUFeedURL raw "$ROOT_DIR/AppBundle/Info.plist.template")"
[[ "$sparkle_version" == "2.9.2" ]] || fail "Expected vendored Sparkle 2.9.2, got $sparkle_version"
[[ "$feed_url" == "$EXPECTED_FEED_URL" ]] || fail "Expected CE Sparkle feed $EXPECTED_FEED_URL, got $feed_url"
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/verify_sparkle_vendor.sh"
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/swiftpm_notice_guardrails.sh"
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/codex_vendor_guardrails.sh"
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/sync_mcp_cli_version.sh" --check
printf 'OK: release preflight passed for %s %s (%s) with Sparkle %s.\n' \
"$DISPLAY_NAME" "$MARKETING_VERSION" "$BUILD_NUMBER" "$sparkle_version"
}
sync_mcp_cli_version() {
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/sync_mcp_cli_version.sh"
}
resolve_without_lockfile_drift() {
require_command cmp
require_command swift
local before_lockfile
before_lockfile="$(mktemp)"
cp "$ROOT_DIR/Package.resolved" "$before_lockfile"
"$RUN_WITHOUT_GITHUB_TOKENS" swift package resolve
cmp "$before_lockfile" "$ROOT_DIR/Package.resolved" ||
fail "swift package resolve changed Package.resolved; commit the intentional lockfile update before packaging"
rm -f "$before_lockfile"
}
validate_packaged_legal() {
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/validate_packaged_legal.sh" "$1"
}
validate_public_app() {
local app_bundle="$1"
local manifest="$2"
local label="$3"
local signed_team_identifier="${4:-}"
"$CONTROL_PLANE_SCRIPTS_DIR/validate_embedded_mcp_helper_layout.sh" "$app_bundle" "$label MCP helper layout"
"$CONTROL_PLANE_SCRIPTS_DIR/validate_app_architectures.sh" "$app_bundle" "arm64,x86_64" "$label architectures"
local codex_verification_args=(
--manifest "$CODEX_MANIFEST" verify-bundle
--arch all
--bundle "$app_bundle/Contents/Resources/BundledRuntimes/Codex"
)
if [[ -n "$signed_team_identifier" ]]; then
codex_verification_args+=(--signed-team-identifier "$signed_team_identifier")
fi
python3 "$CONTROL_PLANE_SCRIPTS_DIR/codex_runtime_artifact.py" "${codex_verification_args[@]}"
"$CONTROL_PLANE_SCRIPTS_DIR/write_app_artifact_manifest.py" verify \
--app "$app_bundle" \
--manifest "$manifest" \
--expected-architectures "arm64,x86_64"
}
validate_distribution_zip() {
local archive="$1"
local manifest="$2"
local label="$3"
local signed_team_identifier="${4:-}"
local extract_dir="$TMP_DIR/${label//[^A-Za-z0-9]/-}-extract"
rm -rf "$extract_dir"
mkdir -p "$extract_dir"
ditto -x -k "$archive" "$extract_dir"
local extracted_app="$extract_dir/$DISTRIBUTION_APP_BUNDLE_NAME"
[[ -d "$extracted_app" ]] || fail "$label ZIP must contain $DISTRIBUTION_APP_BUNDLE_NAME at its root"
validate_public_app "$extracted_app" "$manifest" "$label extracted app" "$signed_team_identifier"
}
write_final_artifact_manifest() {
"$CONTROL_PLANE_SCRIPTS_DIR/write_app_artifact_manifest.py" write \
--app "$APP_BUNDLE" \
--output "$FINAL_ARTIFACT_MANIFEST" \
--expected-architectures "arm64,x86_64"
}
require_sentry_api_configuration() {
sentry_linking_enabled || return 0
[[ -n "${SENTRY_AUTH_TOKEN:-}" || -n "${REPOPROMPT_SENTRY_AUTH_TOKEN_FILE:-${SENTRY_AUTH_TOKEN_FILE:-}}" ]] || fail "Official Sentry-enabled release publishing requires SENTRY_AUTH_TOKEN or REPOPROMPT_SENTRY_AUTH_TOKEN_FILE for Sentry release metadata and debug symbol upload."
require_env REPOPROMPT_SENTRY_ORG
require_env REPOPROMPT_SENTRY_PROJECT
require_command curl
require_command jq
}
require_sentry_publish_configuration() {
require_sentry_api_configuration
require_command sentry-cli
}
prepare_sentry_api_access() {
sentry_linking_enabled || return 0
validate_sentry_network_bounds
[[ -n "$SENTRY_CURL_CONFIG" && -f "$SENTRY_CURL_CONFIG" ]] && return 0
[[ -n "$TMP_DIR" ]] || fail "Sentry API access requires an initialized release workspace"
local token="${SENTRY_AUTH_TOKEN:-}"
if [[ -z "$token" ]]; then
local configured_token_file="${REPOPROMPT_SENTRY_AUTH_TOKEN_FILE:-${SENTRY_AUTH_TOKEN_FILE:-}}"
[[ -n "$configured_token_file" ]] || fail "Missing Sentry auth token file"
[[ -f "$configured_token_file" ]] || fail "Sentry auth token file does not exist: $configured_token_file"
token="$(tr -d '\r\n' < "$configured_token_file")"
fi
[[ -n "$token" ]] || fail "Sentry auth token file was empty"
SENTRY_CURL_CONFIG="$TMP_DIR/sentry-curl.conf"
local normalized_token_file="$TMP_DIR/sentry-auth-token"
(
umask 077
printf '%s' "$token" > "$normalized_token_file"
printf 'header = "Authorization: Bearer %s"\n' "$token" > "$SENTRY_CURL_CONFIG"
)
chmod 600 "$normalized_token_file" "$SENTRY_CURL_CONFIG"
REPOPROMPT_SENTRY_AUTH_TOKEN_FILE="$normalized_token_file"
export REPOPROMPT_SENTRY_AUTH_TOKEN_FILE
unset SENTRY_AUTH_TOKEN
}
sentry_releases_endpoint() {
local encoded_org
encoded_org="$(jq -rn --arg value "$REPOPROMPT_SENTRY_ORG" '$value | @uri')"
printf '%s/organizations/%s/releases/' "${SENTRY_API_BASE_URL%/}" "$encoded_org"
}
sentry_release_endpoint() {
local encoded_release
encoded_release="$(jq -rn --arg value "$SENTRY_RELEASE_NAME" '$value | @uri')"
printf '%s%s/' "$(sentry_releases_endpoint)" "$encoded_release"
}
sentry_release_preflight_endpoint() {
local encoded_project
encoded_project="$(jq -rn --arg value "$REPOPROMPT_SENTRY_PROJECT" '$value | @uri')"
printf '%s?project=%s&per_page=1' "$(sentry_releases_endpoint)" "$encoded_project"
}
sentry_api_request() {
local method="$1"
local endpoint="$2"
local output_file="$3"
local body_file="${4:-}"
local args=(
--silent
--show-error
--connect-timeout "$SENTRY_CONNECT_TIMEOUT_SECONDS"
--max-time "$SENTRY_REQUEST_TIMEOUT_SECONDS"
--output "$output_file"
--write-out '%{http_code}'
--request "$method"
--config "$SENTRY_CURL_CONFIG"
--header 'Accept: application/json'
)
if [[ -n "$body_file" ]]; then
args+=(--header 'Content-Type: application/json' --data-binary "@$body_file")
fi
local status curl_status
if status="$(curl "${args[@]}" "$endpoint")"; then
curl_status=0
else
curl_status=$?
fi
if (( curl_status != 0 )); then
printf 'transport:%s' "$curl_status"
return 0
fi
[[ "$status" =~ ^[0-9]{3}$ ]] || fail "Sentry release API returned an invalid HTTP status"
printf '%s' "$status"
}
sentry_transport_failed() {
[[ "$1" == transport:* ]]
}
sentry_http_response_ambiguous() {
[[ "$1" =~ ^5[0-9][0-9]$ ]]
}
sentry_request_ambiguous() {
sentry_transport_failed "$1" || sentry_http_response_ambiguous "$1"
}
fail_sentry_unknown_transport_state() {
fail "Unable to reconcile Sentry release state after bounded transport or HTTP 5xx responses; no further mutation was attempted"
}
fail_sentry_release_api_status() {
local action="$1"
local status="$2"
case "$status" in
401)
fail "Sentry release API rejected the organization token (HTTP 401); verify that SENTRY_AUTH_TOKEN is current"
;;
403)
fail "Sentry release API rejected the organization token (HTTP 403); verify org:ci access to $REPOPROMPT_SENTRY_ORG/$REPOPROMPT_SENTRY_PROJECT"
;;
*)
fail "Sentry release API could not $action (HTTP $status)"
;;
esac
}
validate_sentry_release_response() {
local response_file="$1"
local label="$2"
jq -e \
--arg version "$SENTRY_RELEASE_NAME" \
--arg project "$REPOPROMPT_SENTRY_PROJECT" \
'.version == $version and
(.projects | type == "array") and
any(.projects[]?; .slug == $project)' \
"$response_file" >/dev/null ||
fail "Sentry release API returned malformed or mismatched JSON for $label"
}
reconcile_sentry_release_identity_after_ambiguous_mutation() {
local response_file="$1"
local label="$2"
local status observation
for ((observation = 1; observation <= SENTRY_OPERATION_ATTEMPTS; observation++)); do
status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$response_file")"
if sentry_request_ambiguous "$status"; then
continue
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail_sentry_release_api_status "reconcile release $SENTRY_RELEASE_NAME after $label" "$status"
validate_sentry_release_response "$response_file" "$label reconciliation"
return 0
done
fail_sentry_unknown_transport_state
}
preflight_sentry_release_access() {
sentry_linking_enabled || return 0
prepare_sentry_api_access
local response_file="$TMP_DIR/sentry-release-preflight.json"
local status
status="$(sentry_api_request GET "$(sentry_release_preflight_endpoint)" "$response_file")"
if sentry_transport_failed "$status"; then
fail "Unable to verify Sentry release access within the configured network deadline"
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail_sentry_release_api_status "verify release access" "$status"
jq -e 'type == "array" and all(.[]; .version | type == "string")' "$response_file" >/dev/null ||
fail "Sentry release API returned malformed JSON during access preflight"
printf 'OK: Sentry org:ci release access verified for %s/%s.\n' \
"$REPOPROMPT_SENTRY_ORG" "$REPOPROMPT_SENTRY_PROJECT"
}
prepare_sentry_release() {
sentry_linking_enabled || return 0
prepare_sentry_api_access
local source_repository="${SOURCE_GITHUB_REPOSITORY:-$GITHUB_REPOSITORY}"
[[ -n "$source_repository" ]] || fail "Missing SOURCE_GITHUB_REPOSITORY for Sentry commit association"
printf 'Preparing Sentry release %s for %s/%s.\n' "$SENTRY_RELEASE_NAME" "$REPOPROMPT_SENTRY_ORG" "$REPOPROMPT_SENTRY_PROJECT"
local release_response="$TMP_DIR/sentry-release.json"
local create_body="$TMP_DIR/sentry-release-create.json"
local status attempt prepared=0
jq -n \
--arg version "$SENTRY_RELEASE_NAME" \
--arg project "$REPOPROMPT_SENTRY_PROJECT" \
--arg repository "$source_repository" \
--arg commit "$RELEASE_COMMIT" \
'{version: $version, projects: [$project], refs: [{repository: $repository, commit: $commit}]}' \
> "$create_body"
chmod 600 "$create_body"
for ((attempt = 1; attempt <= SENTRY_OPERATION_ATTEMPTS; attempt++)); do
status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$release_response")"
if sentry_request_ambiguous "$status"; then
continue
fi
if [[ "$status" == "404" ]]; then
status="$(sentry_api_request POST "$(sentry_releases_endpoint)" "$release_response" "$create_body")"
if sentry_request_ambiguous "$status"; then
status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$release_response")"
if sentry_request_ambiguous "$status"; then
continue
fi
if [[ "$status" == "404" ]]; then
continue
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail_sentry_release_api_status "reconcile release $SENTRY_RELEASE_NAME after create" "$status"
validate_sentry_release_response "$release_response" "release creation reconciliation"
prepared=1
break
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail_sentry_release_api_status "create release $SENTRY_RELEASE_NAME" "$status"
elif [[ ! "$status" =~ ^2[0-9][0-9]$ ]]; then
fail_sentry_release_api_status "look up release $SENTRY_RELEASE_NAME" "$status"
fi
validate_sentry_release_response "$release_response" "release preparation"
prepared=1
break
done
(( prepared == 1 )) || fail_sentry_unknown_transport_state
local refs_body="$TMP_DIR/sentry-release-refs.json"
jq -n \
--arg repository "$source_repository" \
--arg commit "$RELEASE_COMMIT" \
'{refs: [{repository: $repository, commit: $commit}]}' > "$refs_body"
chmod 600 "$refs_body"
for ((attempt = 1; attempt <= SENTRY_OPERATION_ATTEMPTS; attempt++)); do
status="$(sentry_api_request PUT "$(sentry_release_endpoint)" "$release_response" "$refs_body")"
if sentry_request_ambiguous "$status"; then
reconcile_sentry_release_identity_after_ambiguous_mutation "$release_response" "commit association"
continue
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail_sentry_release_api_status "associate release commits" "$status"
validate_sentry_release_response "$release_response" "commit association"
return 0
done
fail "Unable to associate Sentry release commits after bounded retries of the idempotent request"
}
finalize_sentry_release() {
sentry_linking_enabled || return 0
prepare_sentry_api_access
local release_response="$TMP_DIR/sentry-release-finalize.json"
local finalize_body="$TMP_DIR/sentry-release-finalize-body.json"
local status attempt
jq -n --arg date_released "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
'{dateReleased: $date_released}' > "$finalize_body"
chmod 600 "$finalize_body"
for ((attempt = 1; attempt <= SENTRY_OPERATION_ATTEMPTS; attempt++)); do
status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$release_response")"
if sentry_request_ambiguous "$status"; then
continue
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail_sentry_release_api_status "look up release $SENTRY_RELEASE_NAME before finalization" "$status"
validate_sentry_release_response "$release_response" "release finalization"
if jq -e '.dateReleased | type == "string"' "$release_response" >/dev/null; then
printf 'OK: Sentry release %s is already finalized.\n' "$SENTRY_RELEASE_NAME"
return 0
fi
jq -e '.dateReleased == null' "$release_response" >/dev/null ||
fail "Sentry release API returned malformed finalization state"
status="$(sentry_api_request PUT "$(sentry_release_endpoint)" "$release_response" "$finalize_body")"
if sentry_request_ambiguous "$status"; then
status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$release_response")"
if sentry_request_ambiguous "$status"; then
continue
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail_sentry_release_api_status "reconcile release $SENTRY_RELEASE_NAME after finalization" "$status"
validate_sentry_release_response "$release_response" "release finalization reconciliation"
if jq -e '.dateReleased | type == "string"' "$release_response" >/dev/null; then
printf 'OK: Sentry release %s finalization confirmed after an ambiguous response.\n' "$SENTRY_RELEASE_NAME"
return 0
fi
jq -e '.dateReleased == null' "$release_response" >/dev/null ||
fail "Sentry release API returned malformed finalization state during reconciliation"
continue
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail_sentry_release_api_status "finalize release $SENTRY_RELEASE_NAME" "$status"
validate_sentry_release_response "$release_response" "release finalization"
jq -e '.dateReleased | type == "string"' "$release_response" >/dev/null ||
fail "Sentry release API did not confirm finalization"
return 0
done
fail_sentry_unknown_transport_state
}
recover_sentry_finalization() {
[[ "${REPOPROMPT_ENABLE_SENTRY:-}" == "1" ]] ||
fail "finalize-sentry requires REPOPROMPT_ENABLE_SENTRY=1"
require_env RELEASE_TAG
require_release_tag_matches_metadata
require_sentry_api_configuration
TMP_DIR="$(mktemp -d)"
preflight_sentry_release_access
finalize_sentry_release
}
upload_required_sentry_symbols() {
require_sentry_publish_configuration
upload_release_sentry_symbols \
"$SENTRY_SYMBOLS_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/upload_sentry_debug_symbols.sh" \
"$APP_NAME.dSYM" \
"$APP_NAME" \
"repoprompt-mcp.dSYM" \
"repoprompt-mcp"
}
package_release_candidate() {
resolve_without_lockfile_drift
run_preflight
require_command ditto
prepare_dist
"$RUN_WITHOUT_GITHUB_TOKENS" env -u SIGN_IDENTITY \
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR="$CONTROL_PLANE_SCRIPTS_DIR" \
REPOPROMPT_ENABLE_SENTRY=1 \
RELEASE_ALLOW_ADHOC_SIGNING=1 \
"$CONTROL_PLANE_SCRIPTS_DIR/package_app.sh" release
run_preflight
validate_packaged_legal "$APP_BUNDLE"
validate_public_app "$APP_BUNDLE" "$BUILD_ARTIFACT_MANIFEST" "Release candidate"
write_final_artifact_manifest
TMP_DIR="$(mktemp -d)"
ditto "$APP_BUNDLE" "$TMP_DIR/$DISTRIBUTION_APP_BUNDLE_NAME"
ditto -c -k --norsrc --keepParent "$TMP_DIR/$DISTRIBUTION_APP_BUNDLE_NAME" "$UPDATE_ZIP"
validate_distribution_zip "$UPDATE_ZIP" "$FINAL_ARTIFACT_MANIFEST" "Release candidate archive"
(
cd "$DIST_DIR"
shasum -a 256 \
"$(basename "$UPDATE_ZIP")" \
"$(basename "$FINAL_ARTIFACT_MANIFEST")" \
> "$(basename "$CHECKSUMS")"
)
printf 'Created ad-hoc release-candidate artifact: %s\n' "$UPDATE_ZIP"
printf 'This artifact is for packaging validation only. It is not notarized or distributable.\n'
}
verify_publish_inputs() {
sentry_linking_enabled ||
fail "Official release publishing requires REPOPROMPT_ENABLE_SENTRY=1"
require_env RELEASE_TAG
require_env RELEASE_COMMIT
require_env SIGN_IDENTITY
require_env REPOPROMPT_PROVISIONING_PROFILE
require_env SPARKLE_PRIVATE_KEY
require_env NOTARYTOOL_PRIVATE_KEY
require_env NOTARYTOOL_KEY_ID
require_env NOTARYTOOL_ISSUER_ID
require_env GH_TOKEN
require_release_tag_matches_metadata
require_file "$REPOPROMPT_PROVISIONING_PROFILE"
require_file "$NOTARYTOOL_PRIVATE_KEY"
require_sentry_publish_configuration
"$CONTROL_PLANE_SCRIPTS_DIR/verify_remote_release_commit.sh" "$RELEASE_TAG" "$RELEASE_COMMIT"
}
submit_notarization() {
xcrun notarytool submit "$1" \
--key "$NOTARYTOOL_PRIVATE_KEY" \
--key-id "$NOTARYTOOL_KEY_ID" \
--issuer "$NOTARYTOOL_ISSUER_ID" \
--wait \
--timeout "${NOTARYTOOL_TIMEOUT:-30m}"
}
stage_publish_release() {
sentry_linking_enabled ||
fail "Official release staging requires REPOPROMPT_ENABLE_SENTRY=1"
require_env RELEASE_TAG
require_env RELEASE_COMMIT
require_command ditto
"$CONTROL_PLANE_SCRIPTS_DIR/verify_remote_release_commit.sh" "$RELEASE_TAG" "$RELEASE_COMMIT"
require_release_tag_matches_metadata
unset GH_TOKEN GITHUB_TOKEN SOURCE_GH_TOKEN
resolve_without_lockfile_drift
run_preflight
prepare_dist
"$RUN_WITHOUT_GITHUB_TOKENS" env -u SIGN_IDENTITY \
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR="$CONTROL_PLANE_SCRIPTS_DIR" \
REPOPROMPT_ENABLE_SENTRY=1 \
RELEASE_ALLOW_ADHOC_SIGNING=1 \
"$CONTROL_PLANE_SCRIPTS_DIR/package_app.sh" release
run_preflight
validate_packaged_legal "$APP_BUNDLE"
validate_public_app "$APP_BUNDLE" "$BUILD_ARTIFACT_MANIFEST" "Release staging"
require_release_sentry_symbols_when_enabled \
"$SENTRY_SYMBOLS_DIR" \
"$APP_NAME.dSYM" \
"$APP_NAME" \
"repoprompt-mcp.dSYM" \
"repoprompt-mcp"
TMP_DIR="$(mktemp -d)"
local stage_root="$TMP_DIR/release-stage"
mkdir -p "$stage_root/.build/release"
ditto "$APP_BUNDLE" "$stage_root/.build/release/$APP_NAME.app"
cp "$BUILD_ARTIFACT_MANIFEST" "$stage_root/.build/release/$APP_NAME-artifact-manifest.json"
stage_release_sentry_symbols \
"$SENTRY_SYMBOLS_DIR" \
"$stage_root/.build/sentry-symbols/release" \
"$APP_NAME.dSYM" \
"$APP_NAME" \
"repoprompt-mcp.dSYM" \
"repoprompt-mcp"
cp "$ROOT_DIR/version.env" "$ROOT_DIR/LICENSE" "$ROOT_DIR/THIRD_PARTY_NOTICES.md" "$stage_root/"
cp -R "$ROOT_DIR/ThirdPartyLicenses" "$stage_root/"
printf '%s\n' "$RELEASE_COMMIT" > "$stage_root/RELEASE_COMMIT"
ditto -c -k --norsrc "$stage_root" "$STAGE_ARCHIVE"
(
cd "$DIST_DIR"
shasum -a 256 "$(basename "$STAGE_ARCHIVE")" > "$(basename "$STAGE_ARCHIVE_CHECKSUM")"
)
printf 'OK: secret-free release source staged for %s.\n' "$RELEASE_TAG"
printf 'Created staged release artifact: %s\n' "$STAGE_ARCHIVE"
}
publish_staged_release() {
require_command ditto
require_command gh
require_command hdiutil
require_command xcrun
verify_publish_inputs
require_env REPOPROMPT_APPROVED_SOURCE_ROOT
TMP_DIR="$(mktemp -d)"
preflight_sentry_release_access
[[ -d "$APP_BUNDLE" ]] || fail "Missing secret-free staged app bundle: $APP_BUNDLE"
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/validate_staged_release.sh"
verify_release_sentry_symbol_uuids_before_signing \
"$SENTRY_SYMBOLS_DIR" \
"$APP_BUNDLE" \
"$APP_NAME.dSYM" \
"$APP_NAME" \
"repoprompt-mcp.dSYM" \
"repoprompt-mcp"
REPOPROMPT_RELEASE_SOURCE_ROOT="$ROOT_DIR" \
"$CONTROL_PLANE_SCRIPTS_DIR/sign_staged_release.sh"
prepare_dist
validate_packaged_legal "$APP_BUNDLE"
local notary_zip="$TMP_DIR/$ARCHIVE_BASENAME-notarization.zip"
ditto -c -k --norsrc --keepParent "$APP_BUNDLE" "$notary_zip"
submit_notarization "$notary_zip"
xcrun stapler staple "$APP_BUNDLE"
xcrun stapler validate "$APP_BUNDLE"
write_final_artifact_manifest
validate_public_app "$APP_BUNDLE" "$FINAL_ARTIFACT_MANIFEST" "Final Developer ID app" "$SIGNING_TEAM_ID"
prepare_sentry_release
upload_required_sentry_symbols
local distribution_dir="$TMP_DIR/distribution"
mkdir -p "$distribution_dir"
ditto "$APP_BUNDLE" "$distribution_dir/$DISTRIBUTION_APP_BUNDLE_NAME"
ditto -c -k --norsrc --keepParent "$distribution_dir/$DISTRIBUTION_APP_BUNDLE_NAME" "$UPDATE_ZIP"
validate_distribution_zip "$UPDATE_ZIP" "$FINAL_ARTIFACT_MANIFEST" "Final distribution" "$SIGNING_TEAM_ID"
hdiutil create -volname "$DISPLAY_NAME" -srcfolder "$distribution_dir" -ov -format UDZO "$DMG"
submit_notarization "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
local enclosure_signature
enclosure_signature="$(printf '%s' "$SPARKLE_PRIVATE_KEY" |
"$SIGN_UPDATE" --ed-key-file - -p "$UPDATE_ZIP" |
tr -d '\r\n')"
[[ -n "$enclosure_signature" ]] || fail "Unable to produce the Sparkle EdDSA signature for the update archive"
python3 "$STABLE_ROLLOUT_TOOL" generate \
--declaration "$ROLLOUT_DECLARATION" \
--policy "$APPLE_IDENTITY_POLICY" \
--version-env "$ROOT_DIR/version.env" \
--release-tag "$RELEASE_TAG" \
--release-commit "$RELEASE_COMMIT" \
--migration-phase "${REPOPROMPT_IDENTITY_MIGRATION_PHASE:-disabled}" \
--allowed-roles legacy,preparer \
--enclosure "$UPDATE_ZIP" \
--enclosure-signature "$enclosure_signature" \
--app-artifact-manifest "$FINAL_ARTIFACT_MANIFEST" \
--appcast-output "$APPCAST" \
--manifest-output "$ROLLOUT_MANIFEST"
(
cd "$DIST_DIR"
shasum -a 256 \
"$(basename "$UPDATE_ZIP")" \
"$(basename "$DMG")" \
"$(basename "$APPCAST")" \
"$(basename "$FINAL_ARTIFACT_MANIFEST")" \
"$(basename "$ROLLOUT_MANIFEST")" \
> "$(basename "$CHECKSUMS")"
)
"$CONTROL_PLANE_SCRIPTS_DIR/verify_remote_release_commit.sh" "$RELEASE_TAG" "$RELEASE_COMMIT"
local release_args=(
"$RELEASE_TAG"
"$UPDATE_ZIP"
"$DMG"
"$APPCAST"
"$CHECKSUMS"
"$FINAL_ARTIFACT_MANIFEST"
"$ROLLOUT_MANIFEST"
--verify-tag
--title "$DISPLAY_NAME $MARKETING_VERSION"
--generate-notes
--notes "Release-Commit: \`$RELEASE_COMMIT\`"
--repo "$GITHUB_REPOSITORY"
--draft
--target "$RELEASE_COMMIT"
)
local existing_release_state=""
if existing_release_state="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft 2>/dev/null)"; then
fail "GitHub release $RELEASE_TAG already exists (isDraft=$existing_release_state). Refusing to repeat publish-staged; inspect the existing release, then run release.sh finalize-sentry to idempotently recover Sentry finalization."
fi
gh release create "${release_args[@]}"
printf 'Created draft GitHub release assets for %s.\n' "$RELEASE_TAG"
# Finalization marks release metadata, commits, and uploaded debug symbols ready.
# Stable promotion records production availability only after public verification.
finalize_sentry_release
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
case "$MODE" in
sync-cli-version) sync_mcp_cli_version ;;
preflight) run_preflight ;;
artifact) package_release_candidate ;;
stage-publish) stage_publish_release ;;
publish-staged) publish_staged_release ;;
finalize-sentry) recover_sentry_finalization ;;
*) fail "Usage: $0 sync-cli-version|preflight|artifact|stage-publish|publish-staged|finalize-sentry" ;;
esac
fi