@@ -2,25 +2,38 @@ name: Desktop macOS Preview
22
33on :
44 pull_request :
5- types : [labeled, synchronize, reopened]
5+ types : [labeled, unlabeled, synchronize, reopened, closed ]
66
77permissions :
88 contents : read
9- pull-requests : write
109
10+ # Build events and cleanup events use separate groups: a push must cancel a
11+ # stale in-flight build, but must never cancel a cleanup run mid-delete. The
12+ # publish job re-checks PR state before uploading to cover the reverse race.
1113concurrency :
12- group : desktop-macos-preview-${{ github.event.pull_request.number }}
13- cancel-in-progress : true
14+ group : desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }}
15+ # Cleanup runs must complete (a close event right after an unlabel queues
16+ # behind the running cleanup instead of canceling it mid-delete), and events
17+ # that skip the build job, such as adding an unrelated label, must not
18+ # cancel an in-flight build either.
19+ cancel-in-progress : ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }}
1420
1521jobs :
22+ # Builds run PR code, so this job keeps a read-only token. Publishing to the
23+ # release happens in the publish job below, which never checks out PR code.
1624 build :
1725 name : Build macOS Apple Silicon preview
1826 if : >-
27+ github.event.action != 'closed' &&
28+ github.event.action != 'unlabeled' &&
1929 github.event.pull_request.head.repo.full_name == github.repository &&
2030 contains(github.event.pull_request.labels.*.name, 'preview:mac') &&
2131 (github.event.action != 'labeled' || github.event.label.name == 'preview:mac')
2232 runs-on : macos-26
2333 timeout-minutes : 30
34+ outputs :
35+ dmg_name : ${{ steps.build.outputs.dmg_name }}
36+ version : ${{ steps.version.outputs.version }}
2437 steps :
2538 - name : Checkout
2639 uses : actions/checkout@v6
93106 fi
94107 printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT"
95108
96- - id : upload
97- name : Upload macOS DMG
109+ # archive: false uploads the file as its own artifact named after the
110+ # file, so the publish job downloads by *.dmg pattern, not by name.
111+ - name : Upload macOS DMG
98112 uses : actions/upload-artifact@v7
99113 with :
100114 path : release/*.dmg
@@ -103,21 +117,163 @@ jobs:
103117 overwrite : true
104118 retention-days : 7
105119
120+ # Release assets download without a GitHub account, unlike workflow
121+ # artifacts. All preview DMGs live on one rolling prerelease tagged
122+ # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a
123+ # build never notifies release watchers. This job holds the write token and
124+ # only handles the artifact the build job produced; it never runs PR code.
125+ publish :
126+ name : Publish anonymous download
127+ needs : build
128+ runs-on : ubuntu-latest
129+ timeout-minutes : 10
130+ permissions :
131+ contents : write
132+ pull-requests : write
133+ steps :
134+ - name : Download macOS DMG
135+ uses : actions/download-artifact@v8
136+ with :
137+ pattern : " *.dmg"
138+ merge-multiple : true
139+ path : release
140+
141+ - id : upload
142+ name : Upload DMG to the rolling preview release
143+ shell : bash
144+ env :
145+ GH_TOKEN : ${{ github.token }}
146+ PR_NUMBER : ${{ github.event.pull_request.number }}
147+ DEFAULT_BRANCH : ${{ github.event.repository.default_branch }}
148+ run : |
149+ set -euo pipefail
150+
151+ tag="desktop-preview"
152+
153+ # Answers "is this PR still open and still labeled?" while keeping a
154+ # real answer distinguishable from a failed API call. `set -e` does
155+ # not fire for a command substitution inside `[[ ]]`, so the obvious
156+ # one-liner silently reports a transient 502 as "not eligible" and
157+ # the caller then does the wrong thing behind a green check.
158+ preview_state() {
159+ local attempt out
160+ for attempt in 1 2 3; do
161+ if out="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
162+ --json state,labels \
163+ --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)' 2>/dev/null)"; then
164+ if [[ "$out" == "OPEN true" ]]; then echo eligible; else echo ineligible; fi
165+ return 0
166+ fi
167+ if [[ "$attempt" != 3 ]]; then sleep $((attempt * 5)); fi
168+ done
169+ return 1
170+ }
171+
172+ # The build ran for many minutes. If the PR closed or lost the label
173+ # meanwhile, cleanup already ran in its own concurrency group, so
174+ # publishing now would resurrect a deleted download.
175+ if ! state="$(preview_state)"; then
176+ echo "Could not read PR #${PR_NUMBER} state after three attempts. Refusing to publish on a guess." >&2
177+ exit 1
178+ fi
179+ if [[ "$state" != eligible ]]; then
180+ echo "PR closed or preview label removed while building. Skipping publish."
181+ exit 0
182+ fi
183+
184+ dmg_path="$(find release -type f -name '*.dmg' -print -quit)"
185+ if [[ -z "$dmg_path" ]]; then
186+ echo "No DMG found in the downloaded artifact." >&2
187+ exit 1
188+ fi
189+ dmg_name="$(basename "$dmg_path")"
190+
191+ # The filename comes out of the build, which runs PR code, and from
192+ # here it becomes a release asset name, a public URL, and Markdown in
193+ # a comment authored by the bot. Pin it to a plain filename first: a
194+ # name like `x-pr.42.1)](https://evil.example)(.dmg` satisfies a bare
195+ # substring check and escapes the link in that comment.
196+ if [[ ! "$dmg_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.dmg$ ]]; then
197+ echo "DMG name '$dmg_name' is not a plain [A-Za-z0-9._-] filename. Refusing to publish." >&2
198+ exit 1
199+ fi
200+
201+ # Requiring this PR's marker keeps a build from clobbering or
202+ # deleting another PR's asset, since those names carry a different
203+ # -pr.N. marker.
204+ if [[ "$dmg_name" != *"-pr.${PR_NUMBER}."* ]]; then
205+ echo "DMG name '$dmg_name' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2
206+ exit 1
207+ fi
208+
209+ if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
210+ # "|| true" tolerates a concurrent publish job creating the
211+ # release between the check and the create.
212+ gh release create "$tag" \
213+ --repo "$GITHUB_REPOSITORY" \
214+ --target "$DEFAULT_BRANCH" \
215+ --prerelease \
216+ --title "Desktop preview builds" \
217+ --notes "Rolling unsigned desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \
218+ || true
219+ fi
220+
221+ # Upload before pruning, not after. This job sits in the *build*
222+ # concurrency group, where a fresh push does cancel it, so a
223+ # delete-then-upload order leaves the PR comment pointing at a 404
224+ # for the length of the next build. Asset names carry the run number,
225+ # so the new upload never collides with the one it replaces.
226+ gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber
227+
228+ # Keep one DMG per PR: drop this PR's older builds. The trailing dot
229+ # keeps -pr.12. from matching -pr.123. builds.
230+ gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
231+ | { grep -F -- "-pr.${PR_NUMBER}." || true; } \
232+ | while read -r asset; do
233+ if [[ "$asset" != "$dmg_name" ]]; then
234+ gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
235+ || echo "Asset $asset was already removed by a concurrent run."
236+ fi
237+ done
238+
239+ # Re-check after uploading. A cleanup run that started during the
240+ # upload listed assets before ours existed, so it cannot delete it.
241+ # Whichever writer acts last sees the final PR state; if the preview
242+ # became ineligible, delete what we just uploaded.
243+ if ! state="$(preview_state)"; then
244+ echo "Uploaded $dmg_name but could not re-read PR #${PR_NUMBER} state." >&2
245+ echo "Leaving the asset in place; a close or unlabel will clean it up." >&2
246+ exit 1
247+ fi
248+ if [[ "$state" != eligible ]]; then
249+ gh release delete-asset "$tag" "$dmg_name" --repo "$GITHUB_REPOSITORY" --yes \
250+ || echo "Asset was already removed by a concurrent run."
251+ echo "PR closed or preview label removed during upload. Removed the download."
252+ exit 0
253+ fi
254+
255+ echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/${dmg_name}" >> "$GITHUB_OUTPUT"
256+
106257 - name : Comment download link
258+ if : steps.upload.outputs.download_url != ''
107259 uses : actions/github-script@v8
108260 env :
109- ARTIFACT_URL : ${{ steps.upload.outputs.artifact-url }}
110- DMG_NAME : ${{ steps .build.outputs.dmg_name }}
261+ DOWNLOAD_URL : ${{ steps.upload.outputs.download_url }}
262+ DMG_NAME : ${{ needs .build.outputs.dmg_name }}
111263 HEAD_SHA : ${{ github.event.pull_request.head.sha }}
112- PREVIEW_VERSION : ${{ steps.version .outputs.version }}
264+ PREVIEW_VERSION : ${{ needs.build .outputs.version }}
113265 with :
114266 script : |
115267 const { data: pullRequest } = await github.rest.pulls.get({
116268 owner: context.repo.owner,
117269 repo: context.repo.repo,
118270 pull_number: context.payload.pull_request.number,
119271 });
120- if (pullRequest.head.sha !== process.env.HEAD_SHA) {
272+ if (
273+ pullRequest.head.sha !== process.env.HEAD_SHA ||
274+ pullRequest.state !== "open" ||
275+ !pullRequest.labels.some((label) => label.name === "preview:mac")
276+ ) {
121277 core.info("Skipping the outdated macOS preview comment.");
122278 return;
123279 }
@@ -127,7 +283,7 @@ jobs:
127283 marker,
128284 "### macOS preview",
129285 "",
130- `[Download Apple Silicon DMG](${process.env.ARTIFACT_URL })`,
286+ `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL })`,
131287 "",
132288 `Version: ${process.env.PREVIEW_VERSION}`,
133289 `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`,
@@ -137,10 +293,10 @@ jobs:
137293 `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`,
138294 "```",
139295 "",
140- "The download requires GitHub access and expires after 7 days .",
296+ "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed .",
141297 ].join("\n");
142298
143- const { data: comments } = await github.rest.issues.listComments( {
299+ const comments = await github.paginate(github. rest.issues.listComments, {
144300 owner: context.repo.owner,
145301 repo: context.repo.repo,
146302 issue_number: context.payload.pull_request.number,
@@ -163,3 +319,136 @@ jobs:
163319 body,
164320 });
165321 }
322+
323+ # The way out: closing the PR or removing the label deletes its DMG from the
324+ # rolling release and updates the PR comment to say so.
325+ #
326+ # `closed` deliberately does not also require the label. GitHub cancels a
327+ # *pending* run when a newer one joins its group, so unlabel-then-close can
328+ # cancel the queued unlabel cleanup and leave the close event as the only
329+ # survivor — and by then the label is gone. Gating on it there would strand
330+ # the DMG on a public release with nothing left to remove it. The job is
331+ # idempotent and keyed by -pr.N., so on an unrelated close it finds nothing
332+ # and stops.
333+ cleanup :
334+ name : Remove preview download
335+ if : >-
336+ github.event.pull_request.head.repo.full_name == github.repository &&
337+ (github.event.action == 'closed' ||
338+ (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac'))
339+ runs-on : ubuntu-latest
340+ timeout-minutes : 10
341+ permissions :
342+ contents : write
343+ pull-requests : write
344+ steps :
345+ - id : delete
346+ name : Delete this PR's preview assets
347+ shell : bash
348+ env :
349+ GH_TOKEN : ${{ github.token }}
350+ PR_NUMBER : ${{ github.event.pull_request.number }}
351+ run : |
352+ set -euo pipefail
353+
354+ tag="desktop-preview"
355+
356+ # Answers "is this PR still open and still labeled?" while keeping a
357+ # real answer distinguishable from a failed API call. `set -e` does
358+ # not fire for a command substitution inside `[[ ]]`, so the obvious
359+ # one-liner silently reports a transient 502 as "not eligible" and
360+ # the caller then does the wrong thing behind a green check.
361+ preview_state() {
362+ local attempt out
363+ for attempt in 1 2 3; do
364+ if out="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
365+ --json state,labels \
366+ --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)' 2>/dev/null)"; then
367+ if [[ "$out" == "OPEN true" ]]; then echo eligible; else echo ineligible; fi
368+ return 0
369+ fi
370+ if [[ "$attempt" != 3 ]]; then sleep $((attempt * 5)); fi
371+ done
372+ return 1
373+ }
374+
375+ # A stale cleanup must not delete a download that became valid
376+ # again. If the PR is open and labeled once more, the next publish
377+ # owns this PR's assets and replaces them itself. A failed lookup is
378+ # not an answer: deleting on a guess takes down a live download.
379+ if ! state="$(preview_state)"; then
380+ echo "Could not read PR #${PR_NUMBER} state after three attempts. Refusing to delete on a guess." >&2
381+ exit 1
382+ fi
383+ if [[ "$state" == eligible ]]; then
384+ echo "PR is open and labeled again. Skipping cleanup."
385+ echo "removed=false" >> "$GITHUB_OUTPUT"
386+ exit 0
387+ fi
388+
389+ # Tell "no release yet" apart from "the call failed". Reporting a
390+ # broken API as "nothing to clean up" leaves a public DMG downloadable
391+ # forever behind a green check, which is the exact failure this job
392+ # exists to prevent.
393+ if ! assets="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>view.err)"; then
394+ if grep -qi "not found" view.err; then
395+ echo "No preview release exists. Nothing to clean up."
396+ echo "removed=true" >> "$GITHUB_OUTPUT"
397+ exit 0
398+ fi
399+ echo "Could not list release assets:" >&2
400+ cat view.err >&2
401+ exit 1
402+ fi
403+
404+ # Fed by here-string, not a pipe, so `failed` survives the loop.
405+ failed=0
406+ while read -r asset; do
407+ [[ -n "$asset" ]] || continue
408+ if ! gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes 2>delete.err; then
409+ if grep -qi "not found" delete.err; then
410+ echo "Asset $asset was already removed by a concurrent run."
411+ else
412+ echo "Failed to delete $asset:" >&2
413+ cat delete.err >&2
414+ failed=1
415+ fi
416+ fi
417+ done <<< "$(printf '%s\n' "$assets" | { grep -F -- "-pr.${PR_NUMBER}." || true; })"
418+
419+ if [[ "$failed" != 0 ]]; then
420+ echo "One or more preview assets could not be deleted. Failing loudly so this is retried." >&2
421+ exit 1
422+ fi
423+
424+ # Only now is the removal real, so only now claim it in the comment.
425+ echo "removed=true" >> "$GITHUB_OUTPUT"
426+
427+ - name : Mark the preview comment as removed
428+ if : steps.delete.outputs.removed == 'true'
429+ uses : actions/github-script@v8
430+ with :
431+ script : |
432+ const marker = "<!-- desktop-macos-preview -->";
433+ const comments = await github.paginate(github.rest.issues.listComments, {
434+ owner: context.repo.owner,
435+ repo: context.repo.repo,
436+ issue_number: context.payload.pull_request.number,
437+ per_page: 100,
438+ });
439+ const existing = comments.find((comment) => comment.body?.includes(marker));
440+ if (!existing) {
441+ return;
442+ }
443+
444+ await github.rest.issues.updateComment({
445+ owner: context.repo.owner,
446+ repo: context.repo.repo,
447+ comment_id: existing.id,
448+ body: [
449+ marker,
450+ "### macOS preview",
451+ "",
452+ "The preview download was removed because this PR closed or the preview label was removed.",
453+ ].join("\n"),
454+ });
0 commit comments