-
-
Notifications
You must be signed in to change notification settings - Fork 45
Expand file tree
/
Copy pathdory-release.sh
More file actions
executable file
·244 lines (220 loc) · 9.2 KB
/
Copy pathdory-release.sh
File metadata and controls
executable file
·244 lines (220 loc) · 9.2 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
#!/bin/bash
# The single operator-facing entrypoint for Dory release work. Build, qualification, catalog,
# publication, Pages, and Homebrew implementations remain independently testable behind this
# command; release operators should not dispatch their workflows or call those internals directly.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
cd "$ROOT"
REPOSITORY="Augani/dory"
CANDIDATE_WORKFLOW="release-candidate.yml"
PUBLIC_WORKFLOW="release.yml"
RUN_ID=""
HEAD_SHA=""
PROJECT_BUILD=""
RELEASE_METADATA_TMP=""
cleanup() {
if [ -n "$RELEASE_METADATA_TMP" ] && [ -d "$RELEASE_METADATA_TMP" ] \
&& [ ! -L "$RELEASE_METADATA_TMP" ]; then
rm -rf "$RELEASE_METADATA_TMP"
fi
}
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage:
scripts/dory-release.sh check [VERSION]
scripts/dory-release.sh candidate VERSION [--no-wait]
scripts/dory-release.sh status [RUN_ID]
scripts/dory-release.sh publish VERSION [--no-wait]
Actions:
check Verify the release pipeline contract. With VERSION, also prove that the clean,
exact main checkout has the matching version/build and no existing release.
candidate Build every modular component, Developer ID-sign, notarize, staple, and stage one
private immutable candidate. Waits and downloads it to release-build/candidates/.
status Show one run, or the latest private-candidate and public-release runs.
publish Run the complete qualification-gated GitHub release, Pages, appcast, component
catalog, and Homebrew publication. Waits and independently verifies the result.
Examples:
scripts/dory-release.sh check 0.4.6
scripts/dory-release.sh candidate 0.4.6
scripts/dory-release.sh status
scripts/dory-release.sh publish 0.4.6
Only candidate and publish mutate remote release state. --no-wait returns after dispatch and prints
the exact Actions URL. Public publication remains fail-closed on physical qualification evidence.
EOF
}
die() {
echo "dory release: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
}
validate_version() {
local version="$1"
printf '%s\n' "$version" \
| grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \
|| die "VERSION must be a stable semantic version such as 0.4.6"
}
verify_operator_context() {
local version="$1" project_versions tag_status release_status
validate_version "$version"
for command in curl git gh python3; do require_command "$command"; done
gh auth status >/dev/null 2>&1 || die "authenticate first with: gh auth login"
[ "$(gh repo view --json nameWithOwner --jq .nameWithOwner)" = "$REPOSITORY" ] \
|| die "this checkout is not $REPOSITORY"
python3 .github/scripts/verify-release-workflow-contract.py
git fetch --quiet origin main
[ "$(git branch --show-current)" = main ] || die "release actions must start from main"
[ -z "$(git status --porcelain --untracked-files=normal)" ] \
|| die "the working tree must be clean"
HEAD_SHA="$(git rev-parse HEAD)"
[ "$HEAD_SHA" = "$(git rev-parse origin/main)" ] \
|| die "local main must exactly match origin/main"
project_versions="$(sed -n -E \
's/^[[:space:]]*MARKETING_VERSION = ([^;]+);/\1/p' \
Dory.xcodeproj/project.pbxproj | sort -u)"
[ "$project_versions" = "$version" ] \
|| die "project MARKETING_VERSION is '${project_versions:-missing}', expected $version"
PROJECT_BUILD="$(sed -n -E \
's/^[[:space:]]*CURRENT_PROJECT_VERSION = ([^;]+);/\1/p' \
Dory.xcodeproj/project.pbxproj | sort -u)"
case "$PROJECT_BUILD" in
''|0|0[0-9]*|*[!0-9]*)
die "project CURRENT_PROJECT_VERSION must be one positive integer, found '${PROJECT_BUILD:-missing}'"
;;
esac
RELEASE_METADATA_TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-release-check.XXXXXX")"
GH_TOKEN="$(gh auth token)" python3 .github/scripts/verify-release-identity.py \
--repository "$REPOSITORY" \
--project Dory.xcodeproj/project.pbxproj \
--version "$version" \
--build "$PROJECT_BUILD"
set +e
git ls-remote --exit-code --tags origin "refs/tags/v$version" \
>"$RELEASE_METADATA_TMP/tag-ref.txt" 2>"$RELEASE_METADATA_TMP/tag-ref.err"
tag_status=$?
set -e
case "$tag_status" in
0) die "tag v$version already exists" ;;
2) ;;
*)
local lookup_error
lookup_error="$(<"$RELEASE_METADATA_TMP/tag-ref.err")"
die "could not prove tag v$version is absent: $lookup_error"
;;
esac
release_status="$(curl -sS --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 60 \
-H 'Accept: application/vnd.github+json' \
-H "Authorization: Bearer $(gh auth token)" \
-H 'X-GitHub-Api-Version: 2022-11-28' \
-o "$RELEASE_METADATA_TMP/requested-release.json" -w '%{http_code}' \
"https://api.github.com/repos/$REPOSITORY/releases/tags/v$version")"
cleanup
RELEASE_METADATA_TMP=""
case "$release_status" in
404) ;;
200) die "release v$version already exists" ;;
*) die "could not prove release v$version is absent (GitHub HTTP $release_status)" ;;
esac
}
dispatch_workflow() {
local workflow="$1" version="$2" before_run attempt
before_run="$(gh run list --repo "$REPOSITORY" --workflow "$workflow" \
--event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId // 0')"
gh workflow run "$workflow" --repo "$REPOSITORY" --ref main \
--field "version=$version" \
--field "build=$PROJECT_BUILD"
for attempt in $(seq 1 30); do
RUN_ID="$(gh run list --repo "$REPOSITORY" --workflow "$workflow" \
--event workflow_dispatch --branch main --limit 20 --json databaseId,headSha \
--jq "map(select(.databaseId > $before_run and .headSha == \"$HEAD_SHA\")) | sort_by(.databaseId) | last | .databaseId // empty")"
[ -n "$RUN_ID" ] && break
[ "$attempt" -eq 30 ] || sleep 2
done
[ -n "$RUN_ID" ] || die "could not resolve the newly dispatched $workflow run"
echo "https://github.com/$REPOSITORY/actions/runs/$RUN_ID"
}
stage_candidate() {
local version="$1" wait_mode="$2" run_attempt artifact_name destination
verify_operator_context "$version"
echo "Staging private Dory $version ($PROJECT_BUILD) candidate from $HEAD_SHA..."
dispatch_workflow "$CANDIDATE_WORKFLOW" "$version"
[ "$wait_mode" = wait ] || return 0
gh run watch "$RUN_ID" --repo "$REPOSITORY" --exit-status
run_attempt="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq .run_attempt)"
artifact_name="dory-signed-release-candidate-$HEAD_SHA-$run_attempt"
destination="$ROOT/release-build/candidates/$version-$HEAD_SHA-run-$RUN_ID"
[ ! -e "$destination" ] || die "candidate destination already exists: $destination"
mkdir -p "$destination"
gh run download "$RUN_ID" --repo "$REPOSITORY" \
--name "$artifact_name" --dir "$destination"
echo "Private candidate downloaded to $destination"
echo "It is not public. Install and physically qualify these exact bytes before publish."
}
publish_release() {
local version="$1" wait_mode="$2" primary_sha256
verify_operator_context "$version"
echo "Publishing qualification-gated Dory $version ($PROJECT_BUILD) from $HEAD_SHA..."
dispatch_workflow "$PUBLIC_WORKFLOW" "$version"
[ "$wait_mode" = wait ] || return 0
gh run watch "$RUN_ID" --repo "$REPOSITORY" --exit-status
primary_sha256="$(gh api "repos/$REPOSITORY/releases/tags/v$version" \
--jq ".assets[] | select(.name == \"Dory-$version.zip\") | .digest" \
| sed 's/^sha256://')"
[ -n "$primary_sha256" ] || die "published primary ZIP has no GitHub SHA-256 digest"
GH_TOKEN="$(gh auth token)" python3 .github/scripts/verify-public-release.py \
--repository "$REPOSITORY" \
--version "$version" \
--source-commit "$HEAD_SHA" \
--expected-primary-sha256 "$primary_sha256"
echo "Dory $version is completely published: https://github.com/$REPOSITORY/releases/tag/v$version"
}
show_status() {
local requested_run="${1:-}"
require_command gh
gh auth status >/dev/null 2>&1 || die "authenticate first with: gh auth login"
if [ -n "$requested_run" ]; then
printf '%s\n' "$requested_run" | grep -Eq '^[1-9][0-9]*$' \
|| die "RUN_ID must be a positive integer"
gh run view "$requested_run" --repo "$REPOSITORY"
return
fi
echo "Private signed candidates:"
gh run list --repo "$REPOSITORY" --workflow "$CANDIDATE_WORKFLOW" --limit 5
echo
echo "Public releases:"
gh run list --repo "$REPOSITORY" --workflow "$PUBLIC_WORKFLOW" --limit 5
}
ACTION="${1:-}"
case "$ACTION" in
-h|--help|help) usage ;;
check)
[ "$#" -le 2 ] || { usage >&2; exit 64; }
if [ "$#" -eq 2 ]; then
verify_operator_context "$2"
echo "Dory $2 ($PROJECT_BUILD) release preflight: PASS ($HEAD_SHA)"
else
python3 .github/scripts/verify-release-workflow-contract.py
fi
;;
candidate|publish)
[ "$#" -ge 2 ] && [ "$#" -le 3 ] || { usage >&2; exit 64; }
wait_mode="wait"
if [ "$#" -eq 3 ]; then
[ "$3" = --no-wait ] || die "unknown option for $ACTION: $3"
wait_mode="no-wait"
fi
if [ "$ACTION" = candidate ]; then
stage_candidate "$2" "$wait_mode"
else
publish_release "$2" "$wait_mode"
fi
;;
status)
[ "$#" -le 2 ] || { usage >&2; exit 64; }
show_status "${2:-}"
;;
'') usage >&2; exit 64 ;;
*) die "unknown action: $ACTION (run scripts/dory-release.sh --help)" ;;
esac