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