Skip to content

Commit 1434797

Browse files
authored
Merge pull request #111 from pylon-code/upstream/2026-08-27-preview-dmg-downloads
feat(ci): download macOS preview DMGs without signing in
2 parents 6ca228f + 9d11232 commit 1434797

1 file changed

Lines changed: 302 additions & 13 deletions

File tree

.github/workflows/desktop-macos-preview.yml

Lines changed: 302 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,38 @@ name: Desktop macOS Preview
22

33
on:
44
pull_request:
5-
types: [labeled, synchronize, reopened]
5+
types: [labeled, unlabeled, synchronize, reopened, closed]
66

77
permissions:
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.
1113
concurrency:
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

1521
jobs:
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
@@ -93,8 +106,9 @@ jobs:
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

Comments
 (0)