1name: Desktop macOS Preview Publish
2
3# Trusted half of the macOS preview. Runs from main with secrets and a write
4# token, so it must never execute PR code: the PR's JS bundle is only data that
5# gets packaged into the app. Everything that runs here (packaging, signing,
6# notarization, publishing) is main's code.
7#
8# Gate, in order: the completed build run belongs to an open PR that still
9# carries the preview:mac label and whose head is the built commit, and the PR
10# author is trusted by the vouch list. A maintainer applying the label alone is
11# not enough, since the bundle gets signed with the Developer ID certificate.
12#
13# The label is consumed here once the gate passes, so it only ever covers the
14# one commit a maintainer applied it to. A later push builds nothing until the
15# label is applied again.
16
17on:
18 workflow_run:
19 workflows: [Desktop macOS Preview]
20 types: [completed]
21 # The way out: closing the PR deletes its download, and removing the label
22 # before it is consumed cancels the preview. pull_request_target gives this a
23 # write token for fork PRs; it never checks out PR code.
24 pull_request_target:
25 types: [closed, unlabeled]
26
27permissions:
28 contents: read
29
30jobs:
31 resolve:
32 name: Verify preview eligibility
33 if: >-
34 github.event_name == 'workflow_run' &&
35 github.event.workflow_run.event == 'pull_request' &&
36 github.event.workflow_run.conclusion == 'success'
37 # The build workflow completes for every PR push (its label gate is on the
38 # job), so this runs often and usually finds nothing. Keep it cheap.
39 runs-on: ubuntu-24.04
40 timeout-minutes: 10
41 permissions:
42 actions: read
43 contents: read
44 # write only to consume the label; nothing here runs PR code.
45 pull-requests: write
46 outputs:
47 eligible: ${{ steps.gate.outputs.eligible }}
48 pr_number: ${{ steps.pr.outputs.pr_number }}
49 head_sha: ${{ steps.pr.outputs.head_sha }}
50 version: ${{ steps.version.outputs.version }}
51 clerk_publishable_key: ${{ steps.version.outputs.clerk_publishable_key }}
52 clerk_jwt_template: ${{ steps.version.outputs.clerk_jwt_template }}
53 clerk_cli_oauth_client_id: ${{ steps.version.outputs.clerk_cli_oauth_client_id }}
54 relay_url: ${{ steps.version.outputs.relay_url }}
55 steps:
56 - id: pr
57 name: Resolve the pull request behind the build
58 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
59 with:
60 script: |
61 const run = context.payload.workflow_run;
62 const { owner, repo } = context.repo;
63
64 // The build workflow also completes (with every job skipped) for
65 // label events that are not the preview label. Only a run that
66 // produced a bundle is worth resolving.
67 const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
68 owner,
69 repo,
70 run_id: run.id,
71 per_page: 100,
72 });
73 const bundles = artifacts.filter((artifact) => artifact.name === "js-bundle" && !artifact.expired);
74 if (bundles.length !== 1) {
75 core.info(`Expected one js-bundle artifact; found ${bundles.length}. Skipping.`);
76 core.setOutput("eligible", "false");
77 return;
78 }
79
80 // workflow_run.pull_requests and the commit-to-PR lookup are both
81 // empty for fork PRs, so list open PRs by head owner and branch
82 // and require exactly one at the built commit. The build baked its
83 // PR number into the version, so two candidates would mean the
84 // asset name could belong to either.
85 const open = await github.paginate(github.rest.pulls.list, {
86 owner,
87 repo,
88 state: "open",
89 head: `${run.head_repository?.owner?.login}:${run.head_branch}`,
90 per_page: 100,
91 });
92 const matching = open.filter(
93 (candidate) =>
94 candidate.state === "open" &&
95 candidate.head.sha === run.head_sha &&
96 candidate.head.ref === run.head_branch &&
97 candidate.head.repo?.full_name === run.head_repository?.full_name,
98 );
99 if (matching.length !== 1) {
100 core.info(`Expected one open PR for ${run.head_sha}; found ${matching.length}. Skipping.`);
101 core.setOutput("eligible", "false");
102 return;
103 }
104 const { data: pull } = await github.rest.pulls.get({
105 owner,
106 repo,
107 pull_number: matching[0].number,
108 });
109
110 if (pull.state !== "open") {
111 core.info(`PR #${pull.number} is not open. Skipping.`);
112 core.setOutput("eligible", "false");
113 return;
114 }
115 if (pull.head.sha !== run.head_sha) {
116 core.info(`PR #${pull.number} moved to ${pull.head.sha} after ${run.head_sha} was built. Skipping.`);
117 core.setOutput("eligible", "false");
118 return;
119 }
120 if (!pull.labels.some((label) => label.name === "preview:mac")) {
121 core.info(`PR #${pull.number} no longer carries the preview:mac label. Skipping.`);
122 core.setOutput("eligible", "false");
123 return;
124 }
125
126 core.setOutput("artifact_id", String(bundles[0].id));
127 core.setOutput("eligible", "true");
128 core.setOutput("pr_number", String(pull.number));
129 core.setOutput("head_sha", pull.head.sha);
130 core.setOutput("author", pull.user.login);
131
132 # Reads VOUCHED.td from the default branch through the API, so a PR
133 # cannot vouch for itself.
134 - id: vouch
135 name: Check PR author trust
136 if: steps.pr.outputs.eligible == 'true'
137 uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
138 with:
139 user: ${{ steps.pr.outputs.author }}
140 allow-fail: true
141 env:
142 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
143
144 # The label authorized exactly this build, so take it now, before the
145 # long signing job. Removing it with GITHUB_TOKEN does not fire the
146 # unlabeled cleanup below (workflow-token events never start runs), so
147 # the download this run publishes survives. If a maintainer removed the
148 # label first, that removal wins: the 404 makes this run ineligible.
149 - id: consume
150 name: Consume the preview label
151 if: steps.pr.outputs.eligible == 'true'
152 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
153 env:
154 PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
155 with:
156 script: |
157 try {
158 await github.rest.issues.removeLabel({
159 owner: context.repo.owner,
160 repo: context.repo.repo,
161 issue_number: Number(process.env.PR_NUMBER),
162 name: "preview:mac",
163 });
164 core.setOutput("consumed", "true");
165 } catch (error) {
166 if (error.status !== 404) throw error;
167 core.info("The preview:mac label was removed before this build could consume it. Skipping.");
168 core.setOutput("consumed", "false");
169 }
170
171 - id: gate
172 name: Decide eligibility
173 shell: bash
174 env:
175 PR_ELIGIBLE: ${{ steps.pr.outputs.eligible }}
176 LABEL_CONSUMED: ${{ steps.consume.outputs.consumed }}
177 VOUCH_STATUS: ${{ steps.vouch.outputs.status }}
178 AUTHOR: ${{ steps.pr.outputs.author }}
179 run: |
180 set -euo pipefail
181 if [[ "$PR_ELIGIBLE" != "true" || "$LABEL_CONSUMED" != "true" ]]; then
182 echo "eligible=false" >> "$GITHUB_OUTPUT"
183 exit 0
184 fi
185 case "$VOUCH_STATUS" in
186 bot|collaborator|vouched)
187 echo "Author $AUTHOR is trusted ($VOUCH_STATUS)."
188 echo "eligible=true" >> "$GITHUB_OUTPUT"
189 ;;
190 *)
191 echo "Author $AUTHOR is not vouched ($VOUCH_STATUS). Add them to .github/VOUCHED.td to allow signed previews."
192 echo "eligible=false" >> "$GITHUB_OUTPUT"
193 ;;
194 esac
195
196 # Same inputs as the build workflow, read from the built commit through
197 # the contents API as data: the desktop manifest's base version plus the
198 # build run's number reproduces the version baked into the bundle, and
199 # .env.example holds the public T3 Connect identifiers the bundle was
200 # compiled with, which the signed app's passkey entitlement must match.
201 # Both are validated before they reach a file name or an entitlement.
202 - id: version
203 name: Resolve preview version and public configuration
204 if: steps.gate.outputs.eligible == 'true'
205 shell: bash
206 env:
207 GH_TOKEN: ${{ github.token }}
208 PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
209 HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
210 BUILD_RUN_NUMBER: ${{ github.event.workflow_run.run_number }}
211 run: |
212 set -euo pipefail
213 head_file() {
214 gh api "repos/${GITHUB_REPOSITORY}/contents/$1?ref=${HEAD_SHA}" --jq '.content' | base64 --decode
215 }
216
217 base_version="$(head_file apps/desktop/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")"
218 # The committed desktop version is always a plain X.Y.Z; every
219 # prerelease identifier is added by a release run. Anything else
220 # would also let a foreign -pr.N. marker into the asset name, which
221 # is what publish and cleanup key on.
222 if [[ ! "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
223 echo "Unexpected desktop version '$base_version' at $HEAD_SHA; expected X.Y.Z." >&2
224 exit 1
225 fi
226 echo "version=${base_version}-pr.${PR_NUMBER}.${BUILD_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
227
228 head_file .env.example > "$RUNNER_TEMP/head.env.example"
229 for key in clerk_publishable_key:T3CODE_CLERK_PUBLISHABLE_KEY clerk_jwt_template:T3CODE_CLERK_JWT_TEMPLATE clerk_cli_oauth_client_id:T3CODE_CLERK_CLI_OAUTH_CLIENT_ID relay_url:T3CODE_RELAY_URL; do
230 output="${key%%:*}"
231 name="${key##*:}"
232 value="$(sed -n "s/^${name}=//p" "$RUNNER_TEMP/head.env.example" | head -n 1)"
233 if [[ ! "$value" =~ ^[A-Za-z0-9._:/-]+$ ]]; then
234 echo "$name is missing or malformed in .env.example at $HEAD_SHA." >&2
235 exit 1
236 fi
237 echo "${output}=${value}" >> "$GITHUB_OUTPUT"
238 done
239
240 # Only the default-branch revision that owns this workflow supplies the
241 # validator. Never check out the PR in a workflow_run job.
242 - name: Checkout trusted artifact validator
243 if: steps.gate.outputs.eligible == 'true'
244 shell: bash
245 env:
246 CHECKOUT_REF: ${{ github.sha }}
247 GIT_TERMINAL_PROMPT: "0"
248 # Anonymous fetch avoids checkout's credential cleanup, which fails on
249 # orphaned gitlinks in .repos even when that directory is excluded.
250 run: |
251 set -euo pipefail
252 git init .
253 git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
254 git fetch --no-tags --depth=1 origin "$CHECKOUT_REF"
255 git sparse-checkout set .github/scripts
256 git checkout --detach FETCH_HEAD
257
258 # Fetch the archive as bytes. Extracting it over the checkout, even with
259 # download-artifact, could replace code that runs with signing secrets.
260 - name: Download and validate PR JS bundle
261 if: steps.gate.outputs.eligible == 'true'
262 shell: bash
263 env:
264 GH_TOKEN: ${{ github.token }}
265 ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }}
266 run: |
267 set -euo pipefail
268 gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/js-bundle.zip"
269 python3 .github/scripts/stage-preview-bundle.py "$RUNNER_TEMP/js-bundle.zip" "$RUNNER_TEMP/js-bundle"
270
271 # Only validated bundle files cross into the signing job's artifact.
272 - name: Stage JS bundle for packaging
273 if: steps.gate.outputs.eligible == 'true'
274 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
275 with:
276 name: js-bundle
277 path: ${{ runner.temp }}/js-bundle
278 if-no-files-found: error
279 # Re-running this workflow re-uploads under the same run.
280 overwrite: true
281 retention-days: 1
282
283 build:
284 name: Package and sign macOS arm64 preview
285 needs: resolve
286 if: needs.resolve.outputs.eligible == 'true'
287 concurrency:
288 group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-build
289 cancel-in-progress: true
290 # release-desktop.yml asks for actions: read (its Windows builds list artifacts).
291 permissions:
292 actions: read
293 contents: read
294 uses: ./.github/workflows/release-desktop.yml
295 secrets:
296 CSC_LINK: ${{ secrets.CSC_LINK }}
297 CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
298 APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
299 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
300 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
301 MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}
302 with:
303 version: ${{ needs.resolve.outputs.version }}
304 ref: ${{ github.sha }}
305 release_channel: preview
306 relay_client_tracing: false
307 clerk_publishable_key: ${{ needs.resolve.outputs.clerk_publishable_key }}
308 clerk_jwt_template: ${{ needs.resolve.outputs.clerk_jwt_template }}
309 clerk_cli_oauth_client_id: ${{ needs.resolve.outputs.clerk_cli_oauth_client_id }}
310 relay_url: ${{ needs.resolve.outputs.relay_url }}
311 label: macOS arm64 preview
312 runner: blacksmith-12vcpu-macos-26
313 platform: mac
314 target: dmg
315 arch: arm64
316 rust_target: aarch64-apple-darwin
317 resource_key: darwin-arm64
318 cli_archive: false
319
320 # Release assets download without a GitHub account, unlike workflow
321 # artifacts. All preview DMGs live on one rolling prerelease tagged
322 # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a
323 # build never notifies release watchers.
324 publish:
325 name: Publish anonymous download
326 needs: [resolve, build]
327 if: needs.resolve.outputs.eligible == 'true' && needs.build.result == 'success'
328 runs-on: blacksmith-8vcpu-ubuntu-2404
329 timeout-minutes: 10
330 # Its own group, so a publish never cancels a newer commit's signing job
331 # (they would share the build group) and is never cancelled mid-upload.
332 # preview_eligible's head check keeps a superseded publish from landing.
333 concurrency:
334 group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-publish
335 cancel-in-progress: false
336 permissions:
337 contents: write
338 pull-requests: write
339 steps:
340 - name: Download macOS artifacts
341 uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
342 with:
343 name: desktop-mac-arm64
344 path: release
345
346 - id: upload
347 name: Upload DMG to the rolling preview release
348 shell: bash
349 env:
350 GH_TOKEN: ${{ github.token }}
351 PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
352 HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
353 DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
354 run: |
355 set -euo pipefail
356
357 tag="desktop-preview"
358
359 # True while the PR is open and still points at the commit this
360 # build came from. The label was consumed in resolve, so it is not
361 # part of this check. A push does not cancel an already-running
362 # signing job, so this is what keeps a superseded commit's DMG off
363 # the release.
364 preview_eligible() {
365 [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
366 --json state,headRefOid \
367 --jq '.state + " " + .headRefOid')" == "OPEN $HEAD_SHA" ]]
368 }
369
370 # The build ran for many minutes. If the PR closed or moved on
371 # meanwhile, cleanup already ran in its own concurrency group or a
372 # newer build owns the asset, so publishing now would resurrect a
373 # deleted download or clobber a newer one.
374 if ! preview_eligible; then
375 echo "PR closed or head moved while building. Skipping publish."
376 exit 0
377 fi
378
379 shopt -s nullglob
380 dmg_files=(release/*.dmg)
381 if (( ${#dmg_files[@]} != 1 )); then
382 printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2
383 exit 1
384 fi
385 dmg_path="${dmg_files[0]}"
386
387 # Requiring this PR's marker keeps a build from clobbering or
388 # deleting another PR's asset, since those names carry a different
389 # -pr.N. marker.
390 if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then
391 echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2
392 exit 1
393 fi
394
395 if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
396 # "|| true" tolerates a concurrent publish job creating the
397 # release between the check and the create.
398 gh release create "$tag" \
399 --repo "$GITHUB_REPOSITORY" \
400 --target "$DEFAULT_BRANCH" \
401 --prerelease \
402 --title "Desktop preview builds" \
403 --notes "Rolling 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." \
404 || true
405 fi
406
407 # Keep one DMG per PR: drop this PR's older builds first. The
408 # trailing dot keeps -pr.12. from matching -pr.123. builds.
409 gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
410 | { grep -F -- "-pr.${PR_NUMBER}." || true; } \
411 | while read -r asset; do
412 gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
413 || echo "Asset $asset was already removed by a concurrent run."
414 done
415
416 gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber
417
418 # Re-check after uploading. A cleanup run that started during the
419 # upload listed assets before ours existed, so it cannot delete it.
420 # Whichever writer acts last sees the final PR state; if the preview
421 # became ineligible, delete what we just uploaded.
422 if ! preview_eligible; then
423 gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \
424 || echo "Asset was already removed by a concurrent run."
425 echo "PR closed or head moved during upload. Removed the download."
426 exit 0
427 fi
428
429 echo "dmg_name=$(basename "$dmg_path")" >> "$GITHUB_OUTPUT"
430 echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT"
431
432 - name: Comment download link
433 if: steps.upload.outputs.download_url != ''
434 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
435 env:
436 PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
437 DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }}
438 HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
439 PREVIEW_VERSION: ${{ needs.resolve.outputs.version }}
440 with:
441 script: |
442 const prNumber = Number(process.env.PR_NUMBER);
443 const { data: pullRequest } = await github.rest.pulls.get({
444 owner: context.repo.owner,
445 repo: context.repo.repo,
446 pull_number: prNumber,
447 });
448 if (pullRequest.head.sha !== process.env.HEAD_SHA || pullRequest.state !== "open") {
449 core.info("Skipping the outdated macOS preview comment.");
450 return;
451 }
452
453 const marker = "<!-- desktop-macos-preview -->";
454 const body = [
455 marker,
456 "### macOS preview",
457 "",
458 `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`,
459 "",
460 `Version: ${process.env.PREVIEW_VERSION}`,
461 `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`,
462 "",
463 "Signed and notarized, with T3 Connect enabled. The app bundle (server, web client, Electron main) is built from this PR; packaging, native helpers, and desktop dependencies come from `main`.",
464 "",
465 "No GitHub sign-in is needed. The download stays available until this PR closes. The `preview:mac` label was consumed by this build; a maintainer applies it again to build a newer commit.",
466 ].join("\n");
467
468 const comments = await github.paginate(github.rest.issues.listComments, {
469 owner: context.repo.owner,
470 repo: context.repo.repo,
471 issue_number: prNumber,
472 per_page: 100,
473 });
474 const existing = comments.find(
475 (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
476 );
477
478 if (existing) {
479 await github.rest.issues.updateComment({
480 owner: context.repo.owner,
481 repo: context.repo.repo,
482 comment_id: existing.id,
483 body,
484 });
485 } else {
486 await github.rest.issues.createComment({
487 owner: context.repo.owner,
488 repo: context.repo.repo,
489 issue_number: prNumber,
490 body,
491 });
492 }
493
494 cleanup:
495 name: Remove preview download
496 # A published preview no longer carries the label (resolve consumed it),
497 # so every close must look for assets; the -pr.N. filter below makes
498 # that a cheap no-op for PRs that never had one. A manual unlabel before
499 # the build consumed it withdraws the request and drops any older
500 # download too.
501 if: >-
502 github.event_name == 'pull_request_target' &&
503 (github.event.action == 'closed' ||
504 (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac'))
505 # Runs on every PR close and usually finds nothing. Keep it cheap.
506 runs-on: ubuntu-24.04
507 timeout-minutes: 10
508 # Cleanup runs must complete: a close event right after an unlabel queues
509 # behind the running cleanup instead of canceling it mid-delete.
510 concurrency:
511 group: desktop-macos-preview-${{ github.event.pull_request.number }}-cleanup
512 cancel-in-progress: false
513 permissions:
514 contents: write
515 pull-requests: write
516 steps:
517 - id: delete
518 name: Delete this PR's preview assets
519 shell: bash
520 env:
521 GH_TOKEN: ${{ github.token }}
522 PR_NUMBER: ${{ github.event.pull_request.number }}
523 run: |
524 set -euo pipefail
525
526 tag="desktop-preview"
527
528 # A stale cleanup must not delete a download that became valid
529 # again. If the PR is open and labeled once more, the next publish
530 # owns this PR's assets and replaces them itself.
531 if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
532 --json state,labels \
533 --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then
534 echo "PR is open and labeled again. Skipping cleanup."
535 echo "removed=false" >> "$GITHUB_OUTPUT"
536 exit 0
537 fi
538
539 echo "removed=true" >> "$GITHUB_OUTPUT"
540
541 if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
542 echo "No preview release exists. Nothing to clean up."
543 exit 0
544 fi
545
546 gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
547 | { grep -F -- "-pr.${PR_NUMBER}." || true; } \
548 | while read -r asset; do
549 gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
550 || echo "Asset $asset was already removed by a concurrent run."
551 done
552
553 - name: Mark the preview comment as removed
554 if: steps.delete.outputs.removed == 'true'
555 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
556 with:
557 script: |
558 const marker = "<!-- desktop-macos-preview -->";
559 const comments = await github.paginate(github.rest.issues.listComments, {
560 owner: context.repo.owner,
561 repo: context.repo.repo,
562 issue_number: context.payload.pull_request.number,
563 per_page: 100,
564 });
565 const existing = comments.find(
566 (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
567 );
568 if (!existing) {
569 return;
570 }
571
572 await github.rest.issues.updateComment({
573 owner: context.repo.owner,
574 repo: context.repo.repo,
575 comment_id: existing.id,
576 body: [
577 marker,
578 "### macOS preview",
579 "",
580 "The preview download was removed because this PR closed or the preview label was removed.",
581 ].join("\n"),
582 });