-
Notifications
You must be signed in to change notification settings - Fork 2.3k
403 lines (364 loc) · 17.1 KB
/
Copy pathdesktop_auto_release.yml
File metadata and controls
403 lines (364 loc) · 17.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
name: Build Desktop Release Candidate
on:
# The hourly release train: at most one candidate per hour, coalescing every
# merge since the last tag. Schedule and manual dispatch use the same
# throttle; a retry must never create a second candidate inside the hour.
schedule:
- cron: "7 * * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
plan-and-tag:
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: desktop-release-planner-main
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Generate Omi Bot token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.OMI_BOT_APP_ID }}
private-key: ${{ secrets.OMI_BOT_PRIVATE_KEY }}
- name: Plan desktop release
id: plan
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
python3 .github/scripts/plan-desktop-release.py \
--repository "${{ github.repository }}" \
--min-tag-interval-seconds 3600 \
--codemagic-source-gate
- name: Show release plan
run: echo "${{ steps.plan.outputs.reason }}"
# The native tag must be pushed by the Omi Bot installation, rather than
# the workflow's GITHUB_TOKEN. GitHub suppresses recursively generated
# events for GITHUB_TOKEN, which leaves Codemagic no tag webhook to build.
- name: Checkout with Omi Bot token
if: steps.plan.outputs.should_release == 'true'
uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Checkout exact releasable desktop source
if: steps.plan.outputs.should_release == 'true'
uses: actions/checkout@v7
with:
ref: ${{ steps.plan.outputs.source_sha }}
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Compute next version and consolidate changelog
if: steps.plan.outputs.should_release == 'true'
id: version
run: |
# Get latest desktop release tag
LATEST=$(git tag -l 'v*-macos' | sort -V | tail -1)
if [ -z "$LATEST" ]; then
VERSION="0.0.1"
else
# Strip v prefix and +build-macos suffix e.g. v0.11.11+11011-macos -> 0.11.11
VER=$(echo "$LATEST" | sed -E 's/^v([0-9.]+)\+[0-9]+-macos$/\1/')
MAJOR=$(echo "$VER" | cut -d. -f1)
MINOR=$(echo "$VER" | cut -d. -f2)
PATCH=$(echo "$VER" | cut -d. -f3)
PATCH=$((${PATCH:-0} + 1))
VERSION="$MAJOR.$MINOR.$PATCH"
fi
# Build number: 0.11.12 -> 11012 (each component * 1000, summed)
BUILD_NUMBER=$(echo "$VERSION" | tr '.' '\n' | awk '{s=s*1000+$1}END{print s}')
RELEASE_TAG="v${VERSION}+${BUILD_NUMBER}-macos"
echo "Latest tag : ${LATEST:-none}"
echo "New version: $VERSION"
echo "New tag : $RELEASE_TAG"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
# Consolidate unreleased changelog fragments into a versioned release
TODAY=$(date -u +%Y-%m-%d)
python3 .github/scripts/desktop-changelog.py consolidate \
--version "$VERSION" \
--date "$TODAY" \
--write
- name: Commit changelog and prepare sync branch
if: steps.plan.outputs.should_release == 'true'
id: changelog
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
PLANNED_SOURCE_SHA="${{ steps.plan.outputs.source_sha }}"
# A version-only changelog branch can belong to an earlier planner
# source after a retry. Binding the branch to the immutable source
# avoids rewriting that stale branch or confusing its PR with this
# candidate; published tags are never touched by this recovery path.
BRANCH="changelog/v${VERSION}-${PLANNED_SOURCE_SHA:0:12}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Partial candidate tagging recovery: changelog may already be on main while
# the planned-source branch tip is an orphan rewrite. Prefer a
# main-reachable consolidate commit. Always drop consolidate dirt
# before any branch checkout so reuse cannot fail on a dirty tree.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git reset --hard HEAD
git clean -fd -- desktop/macos/CHANGELOG.json desktop/macos/changelog || true
BRANCH_TIP=""
if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null; then
git fetch --no-tags origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
BRANCH_TIP="$(git rev-parse "origin/$BRANCH^{commit}")"
fi
RESOLVE_ARGS=(
python3 .github/scripts/resolve-desktop-changelog-sync.py
--repository-root .
--planned-source-sha "$PLANNED_SOURCE_SHA"
--version "$VERSION"
--main-ref origin/main
--repository "${{ github.repository }}"
--github-output
)
if [ -n "$BRANCH_TIP" ]; then
RESOLVE_ARGS+=(--branch-tip "$BRANCH_TIP")
fi
# Capture resolve into step outputs via GITHUB_OUTPUT.
RESOLVE_OUT="$RUNNER_TEMP/desktop-changelog-resolve-outputs.txt"
: > "$RESOLVE_OUT"
GITHUB_OUTPUT="$RESOLVE_OUT" "${RESOLVE_ARGS[@]}"
RESOLVE_MODE="$(grep '^mode=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
RESOLVE_COMMIT="$(grep '^commit=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
RESOLVE_PR_URL="$(grep '^pr_url=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
RESOLVE_MERGED_MAIN="$(grep '^merged_main_sha=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
RESOLVE_ON_MAIN="$(grep '^already_on_main=' "$RESOLVE_OUT" | tail -1 | cut -d= -f2-)"
if [ "$RESOLVE_MODE" = "stale-orphan" ]; then
# A manually squash-merged or otherwise orphaned sync branch must
# not poison every retry (incident #11936). Keep the stale ref for
# audit and publish a run-qualified recovery branch instead.
BRANCH="${BRANCH}-recovery-${GITHUB_RUN_ID}"
fi
if [ -n "$RESOLVE_COMMIT" ]; then
# Reuse only the exact-parent consolidate selected by the resolver.
test "$(git rev-parse "${RESOLVE_COMMIT}^1")" = "$PLANNED_SOURCE_SHA"
git checkout -B "$BRANCH" "$RESOLVE_COMMIT"
{
echo "changed=true"
echo "commit=$RESOLVE_COMMIT"
echo "reused=true"
echo "already_on_main=$RESOLVE_ON_MAIN"
echo "pr_url=$RESOLVE_PR_URL"
echo "merged_main_sha=$RESOLVE_MERGED_MAIN"
echo "resolve_mode=$RESOLVE_MODE"
echo "branch=$BRANCH"
} >> "$GITHUB_OUTPUT"
exit 0
fi
# Fresh consolidate path: rebuild from the planned source tree.
git checkout --detach "$PLANNED_SOURCE_SHA"
TODAY=$(date -u +%Y-%m-%d)
python3 .github/scripts/desktop-changelog.py consolidate \
--version "$VERSION" \
--date "$TODAY" \
--write
git add desktop/macos/CHANGELOG.json desktop/macos/changelog
if git diff --cached --quiet; then
# Nothing to PR. Direct tag path admits later non-desktop main commits.
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "already_on_main=false" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
exit 0
fi
git commit -m "chore: consolidate changelog for v${VERSION}"
CHANGELOG_COMMIT="$(git rev-parse HEAD)"
git checkout -B "$BRANCH" "$CHANGELOG_COMMIT"
git push origin "$BRANCH"
{
echo "changed=true"
echo "commit=$CHANGELOG_COMMIT"
echo "reused=false"
echo "already_on_main=false"
echo "pr_url="
echo "merged_main_sha="
echo "resolve_mode=created"
echo "branch=$BRANCH"
} >> "$GITHUB_OUTPUT"
- name: Create and regular-merge PR to sync changelog back to main
if: steps.plan.outputs.should_release == 'true' && steps.changelog.outputs.changed == 'true'
id: changelog-pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "Omi Bot token not available; cannot merge changelog PR."
exit 1
fi
VERSION="${{ steps.version.outputs.version }}"
BRANCH="${{ steps.changelog.outputs.branch }}"
CHANGELOG_COMMIT="${{ steps.changelog.outputs.commit }}"
# Changelog already on main from a partial prior candidate tag: do not
# open a second PR. Reuse the merge commit + PR URL from resolve.
if [ "${{ steps.changelog.outputs.already_on_main }}" = "true" ]; then
PR_URL="${{ steps.changelog.outputs.pr_url }}"
MERGED_MAIN_SHA="${{ steps.changelog.outputs.merged_main_sha }}"
if [ -z "$PR_URL" ] || [ -z "$MERGED_MAIN_SHA" ]; then
echo "Changelog commit is on main but merge PR evidence is missing."
exit 1
fi
test "$(git merge-base --is-ancestor "$CHANGELOG_COMMIT" origin/main && echo ok)" = "ok"
PR_NUMBER=$(echo "$PR_URL" | awk -F/ '{print $NF}')
{
echo "number=$PR_NUMBER"
echo "url=$PR_URL"
echo "merged_main_sha=$MERGED_MAIN_SHA"
} >> "$GITHUB_OUTPUT"
exit 0
fi
PR_NUMBER=$(gh pr list \
--head "$BRANCH" \
--base main \
--state open \
--json number \
--jq '.[0].number')
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(gh pr list \
--head "$BRANCH" \
--base main \
--state merged \
--json number \
--jq '.[0].number')
if [ -z "$PR_NUMBER" ]; then
PR_URL=$(gh pr create \
--title "Update desktop changelog for v${VERSION} [skip ci]" \
--body "Auto-generated: consolidates unreleased changelog fragments into v${VERSION} and regenerates CHANGELOG.json." \
--base main \
--head "$BRANCH")
PR_NUMBER=$(echo "$PR_URL" | awk -F/ '{print $NF}')
fi
fi
PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state')
if [ "$PR_STATE" != "MERGED" ]; then
# main branch policy blocks ordinary merges. Use admin override so
# the Omi Bot can sync the changelog without waiting on checks.
# Do not fall back to a non-admin merge: a transient --admin failure
# (e.g. HTTP 502) would otherwise retry without override and fail
# on branch policy (see run 30581710658 / PR #10910).
merge_ok=0
for attempt in 1 2 3; do
if gh pr merge "$PR_NUMBER" --admin --merge; then
merge_ok=1
break
fi
if [ "$attempt" -lt 3 ]; then
echo "Admin merge attempt ${attempt}/3 failed; retrying in 5s..."
sleep 5
fi
done
test "$merge_ok" = "1"
fi
PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state')
test "$PR_STATE" = "MERGED"
MERGED_MAIN_SHA=$(gh pr view "$PR_NUMBER" --json mergeCommit --jq '.mergeCommit.oid')
PR_URL=$(gh pr view "$PR_NUMBER" --json url --jq '.url')
test -n "$MERGED_MAIN_SHA"
{
echo "number=$PR_NUMBER"
echo "url=$PR_URL"
echo "merged_main_sha=$MERGED_MAIN_SHA"
} >> "$GITHUB_OUTPUT"
- name: Bind immutable planner evidence to merged release source
if: steps.plan.outputs.should_release == 'true'
id: identity
run: |
set -euo pipefail
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
PLANNED_SOURCE_SHA="${{ steps.plan.outputs.source_sha }}"
CHANGELOG_CHANGED="${{ steps.changelog.outputs.changed }}"
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
MAIN_SHA="$(git rev-parse 'origin/main^{commit}')"
if [ "$CHANGELOG_CHANGED" = true ]; then
CHANGELOG_COMMIT="${{ steps.changelog.outputs.commit }}"
CHANGELOG_PR="${{ steps.changelog-pr.outputs.url }}"
CHANGELOG_PARENT_SHA="$(git rev-parse "${CHANGELOG_COMMIT}^1")"
CANDIDATE_SHA="$CHANGELOG_COMMIT"
python3 .github/scripts/desktop-release-source-identity.py \
--release-tag "$RELEASE_TAG" \
--planned-source-sha "$PLANNED_SOURCE_SHA" \
--candidate-source-sha "$CANDIDATE_SHA" \
--origin-main-sha "$MAIN_SHA" \
--changelog-parent-sha "$CHANGELOG_PARENT_SHA" \
--changelog-commit "$CHANGELOG_COMMIT" \
--changelog-pr "$CHANGELOG_PR" \
--output "$RUNNER_TEMP/desktop-release-source-identity.json"
else
CANDIDATE_SHA="$PLANNED_SOURCE_SHA"
python3 .github/scripts/desktop-release-source-identity.py \
--release-tag "$RELEASE_TAG" \
--planned-source-sha "$PLANNED_SOURCE_SHA" \
--candidate-source-sha "$CANDIDATE_SHA" \
--origin-main-sha "$MAIN_SHA" \
--output "$RUNNER_TEMP/desktop-release-source-identity.json"
fi
{
echo "candidate_sha=$CANDIDATE_SHA"
echo "evidence_path=$RUNNER_TEMP/desktop-release-source-identity.json"
} >> "$GITHUB_OUTPUT"
- name: Retain immutable planner source evidence
if: steps.plan.outputs.should_release == 'true'
uses: actions/upload-artifact@v7
with:
name: desktop-release-planner-source-identity-${{ steps.version.outputs.release_tag }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.identity.outputs.evidence_path }}
if-no-files-found: error
overwrite: false
- name: Publish immutable tag from merged release source
if: steps.plan.outputs.should_release == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
CANDIDATE_SHA="${{ steps.identity.outputs.candidate_sha }}"
EVIDENCE_PATH="${{ steps.identity.outputs.evidence_path }}"
if [ -z "${GH_TOKEN:-}" ]; then
echo "Omi Bot token not available; cannot publish a candidate tag." >&2
exit 1
fi
# The publisher verifies the exact candidate is still reachable
# from authoritative GitHub main. Later merges belong to the next
# train; an unmerged candidate or existing tag still fails closed.
python3 .github/scripts/publish-desktop-candidate-tag.py \
--repository "${{ github.repository }}" \
--release-tag "$RELEASE_TAG" \
--candidate-sha "$CANDIDATE_SHA" \
--evidence "$EVIDENCE_PATH"
git fetch --no-tags origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$CANDIDATE_SHA"
- name: Dispatch or reuse the exact-tag Codemagic build
if: steps.plan.outputs.should_release == 'true'
env:
CODEMAGIC_API_TOKEN: ${{ secrets.CODEMAGIC_API_TOKEN }}
run: |
# Codemagic's automatic tag trigger is disabled for this workflow.
# One all-state exact-tag query fences retries, then the helper posts
# at most one API build and verifies its immutable source identity.
python3 .github/scripts/check-codemagic-tag-intake.py \
--app-id "66c95e6ec76853c447b8bcbb" \
--workflow-id "omi-desktop-swift-release" \
--release-tag "${{ steps.version.outputs.release_tag }}" \
--source-sha "${{ steps.identity.outputs.candidate_sha }}" \
--timeout-seconds 0 \
--poll-seconds 5 \
--dispatch-fallback-on-absence \
--evidence "$RUNNER_TEMP/codemagic-native-tag-intake.json"
- name: Retain native Codemagic tag intake evidence
if: always() && steps.plan.outputs.should_release == 'true'
uses: actions/upload-artifact@v7
with:
name: codemagic-native-tag-intake-${{ steps.version.outputs.release_tag }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/codemagic-native-tag-intake.json
if-no-files-found: error
overwrite: false