authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-14 16:23:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 16:23:25-07:00
log9a49d6d5a656254d7079d463aa6ead5d62f4a3e6
tree9a8981eb022e7b6daa95ba8aec5f7412702876da
parent5bf43c9f3d09faae49fbc99714456d18e3172ff2
signature Signed by PGP key B5690EEEBB952194

ci(desktop): sign fork PR macOS previews without exposing signing secrets (#11760)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

7 files changed, 899 insertions(+), 343 deletions(-)

.github/scripts/stage-preview-bundle.py created+76
......@@ -0,0 +1,76 @@
1"""Stage an untrusted preview ZIP without letting it replace packaging code."""
2
3import shutil
4import stat
5import sys
6import zipfile
7from pathlib import Path
8
9ROOTS = ("server/dist", "desktop/dist-electron")
10REQUIRED_FILES = {
11 "server/dist/bin.mjs",
12 "server/dist/client/index.html",
13 "desktop/dist-electron/main.cjs",
14}
15# The current bundle is about 32 MiB compressed. Bound extraction on the
16# trusted runner even when the PR replaces the uploader entirely.
17MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
18MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024
19MAX_ENTRIES = 50_000
20
21
22def stage_bundle(archive: Path, destination: Path):
23 if archive.stat().st_size > MAX_ARCHIVE_BYTES:
24 raise ValueError("Preview archive is too large")
25 with zipfile.ZipFile(archive) as bundle:
26 entries = bundle.infolist()
27 if len(entries) > MAX_ENTRIES:
28 raise ValueError("Preview archive has too many entries")
29 if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
30 raise ValueError("Expanded preview bundle is too large")
31 seen = set()
32 files = set()
33 for entry in entries:
34 name = entry.filename.removesuffix("/")
35 parts = name.split("/")
36 # Reject ambiguous paths before normalization, including names
37 # that would alias on the macOS signing runner.
38 if (
39 entry.orig_filename != entry.filename
40 or any(part in ("", ".", "..") for part in parts)
41 or any(char in name for char in "\\:")
42 or not name.isascii()
43 or any(ord(char) < 32 or ord(char) == 127 for char in name)
44 ):
45 raise ValueError(f"Unsafe preview path: {entry.filename!r}")
46 allowed = any(name.startswith(root + "/") for root in ROOTS)
47 if entry.is_dir():
48 allowed |= any(root == name or root.startswith(name + "/") for root in ROOTS)
49 if not allowed:
50 raise ValueError(f"Unexpected preview path: {name!r}")
51 kind = stat.S_IFMT(entry.external_attr >> 16)
52 if kind not in (0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG):
53 raise ValueError(f"Non-regular preview entry: {name!r}")
54 if name.casefold() in seen:
55 raise ValueError(f"Duplicate preview path: {name!r}")
56 seen.add(name.casefold())
57 if not entry.is_dir():
58 files.add(name)
59 if not REQUIRED_FILES <= files:
60 raise ValueError("Preview bundle is missing required entry points")
61 # Validate all names before writing anything. This is a fresh directory
62 # outside the checkout; neither pre-existing links nor trusted files
63 # can be followed or overwritten. ZIP permissions are never restored.
64 destination.mkdir(parents=True, exist_ok=False)
65 for entry in entries:
66 target = destination / entry.filename
67 if entry.is_dir():
68 target.mkdir(parents=True, exist_ok=True)
69 else:
70 target.parent.mkdir(parents=True, exist_ok=True)
71 with bundle.open(entry) as source, target.open("xb") as output:
72 shutil.copyfileobj(source, output)
73
74
75if __name__ == "__main__":
76 stage_bundle(Path(sys.argv[1]), Path(sys.argv[2]))
.github/scripts/stage-preview-bundle.test.py created+97
......@@ -0,0 +1,97 @@
1import importlib.util
2import stat
3import tempfile
4import unittest
5import zipfile
6from pathlib import Path
7from unittest.mock import patch
8
9spec = importlib.util.spec_from_file_location(
10 "stage_preview_bundle", Path(__file__).with_name("stage-preview-bundle.py")
11)
12staging = importlib.util.module_from_spec(spec)
13spec.loader.exec_module(staging)
14
15
16class StagePreviewBundleTests(unittest.TestCase):
17 def setUp(self):
18 self.temp = tempfile.TemporaryDirectory()
19 self.addCleanup(self.temp.cleanup)
20 self.root = Path(self.temp.name)
21 self.archive = self.root / "bundle.zip"
22 self.destination = self.root / "staged"
23
24 def bundle(self, extra=(), missing=None):
25 with zipfile.ZipFile(self.archive, "w") as bundle:
26 for name in sorted(staging.REQUIRED_FILES - {missing}):
27 bundle.writestr(name, b"bundle data, never executed")
28 for name, content in extra:
29 bundle.writestr(name, content)
30
31 def stage(self):
32 staging.stage_bundle(self.archive, self.destination)
33
34 def test_preserves_valid_bundle_layout_and_bytes(self):
35 self.bundle([("server/", b""), ("server/dist/", b""),
36 ("desktop/dist-electron/chunks/helper.cjs", b"chunk")])
37 self.stage()
38 for name in staging.REQUIRED_FILES:
39 self.assertEqual((self.destination / name).read_bytes(), b"bundle data, never executed")
40 self.assertEqual((self.destination / "desktop/dist-electron/chunks/helper.cjs").read_bytes(), b"chunk")
41
42 def test_rejects_builder_overwrite_and_unsafe_paths_before_writing(self):
43 for name in [
44 "desktop/node_modules/electron-builder/cli.js",
45 "desktop/package.json",
46 "server/dist/../../desktop/package.json",
47 "../package.json",
48 "/server/dist/absolute",
49 "server/dist/./alias",
50 "server/dist//alias",
51 "server/dist/back\\slash",
52 "server/dist/file:stream",
53 "server/dist/BIN.MJS",
54 ]:
55 with self.subTest(name=name):
56 self.bundle([(name, b"untrusted")])
57 with self.assertRaises(ValueError):
58 self.stage()
59 self.assertFalse(self.destination.exists())
60
61 def test_rejects_links_and_special_files(self):
62 for mode in [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR]:
63 with self.subTest(mode=mode):
64 entry = zipfile.ZipInfo("server/dist/link")
65 entry.create_system = 3
66 entry.external_attr = (mode | 0o777) << 16
67 self.bundle([(entry, b"../../../desktop/node_modules")])
68 with self.assertRaises(ValueError):
69 self.stage()
70 self.assertFalse(self.destination.exists())
71
72 def test_requires_entry_points(self):
73 self.bundle(missing="desktop/dist-electron/main.cjs")
74 with self.assertRaises(ValueError):
75 self.stage()
76 self.assertFalse(self.destination.exists())
77
78 def test_bounds_archive_size_expanded_size_and_entry_count(self):
79 for limit in ["MAX_ARCHIVE_BYTES", "MAX_EXPANDED_BYTES", "MAX_ENTRIES"]:
80 with self.subTest(limit=limit), patch.object(staging, limit, 1):
81 self.bundle()
82 with self.assertRaises(ValueError):
83 self.stage()
84 self.assertFalse(self.destination.exists())
85
86 def test_refuses_existing_destination(self):
87 self.bundle()
88 self.destination.mkdir()
89 sentinel = self.destination / "trusted"
90 sentinel.write_text("untouched")
91 with self.assertRaises(FileExistsError):
92 self.stage()
93 self.assertEqual(sentinel.read_text(), "untouched")
94
95
96if __name__ == "__main__":
97 unittest.main()
.github/workflows/ci.yml+3
......@@ -103,6 +103,9 @@ jobs:
103103 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
104104 sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
105105
106 - name: Test preview artifact validation
107 run: python3 -B .github/scripts/stage-preview-bundle.test.py
108
106109 - name: Test nightly release checks
107110 run: node --test .github/scripts/check-nightly-release.test.cjs
108111
.github/workflows/desktop-macos-preview-publish.yml created+575
......@@ -0,0 +1,575 @@
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
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 uses: ./.github/workflows/release-desktop.yml
288 secrets:
289 CSC_LINK: ${{ secrets.CSC_LINK }}
290 CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
291 APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
292 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
293 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
294 MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}
295 with:
296 version: ${{ needs.resolve.outputs.version }}
297 ref: ${{ github.sha }}
298 release_channel: preview
299 relay_client_tracing: false
300 clerk_publishable_key: ${{ needs.resolve.outputs.clerk_publishable_key }}
301 clerk_jwt_template: ${{ needs.resolve.outputs.clerk_jwt_template }}
302 clerk_cli_oauth_client_id: ${{ needs.resolve.outputs.clerk_cli_oauth_client_id }}
303 relay_url: ${{ needs.resolve.outputs.relay_url }}
304 label: macOS arm64 preview
305 runner: blacksmith-12vcpu-macos-26
306 platform: mac
307 target: dmg
308 arch: arm64
309 rust_target: aarch64-apple-darwin
310 resource_key: darwin-arm64
311 cli_archive: false
312
313 # Release assets download without a GitHub account, unlike workflow
314 # artifacts. All preview DMGs live on one rolling prerelease tagged
315 # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a
316 # build never notifies release watchers.
317 publish:
318 name: Publish anonymous download
319 needs: [resolve, build]
320 if: needs.resolve.outputs.eligible == 'true' && needs.build.result == 'success'
321 runs-on: blacksmith-8vcpu-ubuntu-2404
322 timeout-minutes: 10
323 # Its own group, so a publish never cancels a newer commit's signing job
324 # (they would share the build group) and is never cancelled mid-upload.
325 # preview_eligible's head check keeps a superseded publish from landing.
326 concurrency:
327 group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-publish
328 cancel-in-progress: false
329 permissions:
330 contents: write
331 pull-requests: write
332 steps:
333 - name: Download macOS artifacts
334 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
335 with:
336 name: desktop-mac-arm64
337 path: release
338
339 - id: upload
340 name: Upload DMG to the rolling preview release
341 shell: bash
342 env:
343 GH_TOKEN: ${{ github.token }}
344 PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
345 HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
346 DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
347 run: |
348 set -euo pipefail
349
350 tag="desktop-preview"
351
352 # True while the PR is open and still points at the commit this
353 # build came from. The label was consumed in resolve, so it is not
354 # part of this check. A push does not cancel an already-running
355 # signing job, so this is what keeps a superseded commit's DMG off
356 # the release.
357 preview_eligible() {
358 [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
359 --json state,headRefOid \
360 --jq '.state + " " + .headRefOid')" == "OPEN $HEAD_SHA" ]]
361 }
362
363 # The build ran for many minutes. If the PR closed or moved on
364 # meanwhile, cleanup already ran in its own concurrency group or a
365 # newer build owns the asset, so publishing now would resurrect a
366 # deleted download or clobber a newer one.
367 if ! preview_eligible; then
368 echo "PR closed or head moved while building. Skipping publish."
369 exit 0
370 fi
371
372 shopt -s nullglob
373 dmg_files=(release/*.dmg)
374 if (( ${#dmg_files[@]} != 1 )); then
375 printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2
376 exit 1
377 fi
378 dmg_path="${dmg_files[0]}"
379
380 # Requiring this PR's marker keeps a build from clobbering or
381 # deleting another PR's asset, since those names carry a different
382 # -pr.N. marker.
383 if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then
384 echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2
385 exit 1
386 fi
387
388 if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
389 # "|| true" tolerates a concurrent publish job creating the
390 # release between the check and the create.
391 gh release create "$tag" \
392 --repo "$GITHUB_REPOSITORY" \
393 --target "$DEFAULT_BRANCH" \
394 --prerelease \
395 --title "Desktop preview builds" \
396 --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." \
397 || true
398 fi
399
400 # Keep one DMG per PR: drop this PR's older builds first. The
401 # trailing dot keeps -pr.12. from matching -pr.123. builds.
402 gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
403 | { grep -F -- "-pr.${PR_NUMBER}." || true; } \
404 | while read -r asset; do
405 gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
406 || echo "Asset $asset was already removed by a concurrent run."
407 done
408
409 gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber
410
411 # Re-check after uploading. A cleanup run that started during the
412 # upload listed assets before ours existed, so it cannot delete it.
413 # Whichever writer acts last sees the final PR state; if the preview
414 # became ineligible, delete what we just uploaded.
415 if ! preview_eligible; then
416 gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \
417 || echo "Asset was already removed by a concurrent run."
418 echo "PR closed or head moved during upload. Removed the download."
419 exit 0
420 fi
421
422 echo "dmg_name=$(basename "$dmg_path")" >> "$GITHUB_OUTPUT"
423 echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT"
424
425 - name: Comment download link
426 if: steps.upload.outputs.download_url != ''
427 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
428 env:
429 PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
430 DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }}
431 HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
432 PREVIEW_VERSION: ${{ needs.resolve.outputs.version }}
433 with:
434 script: |
435 const prNumber = Number(process.env.PR_NUMBER);
436 const { data: pullRequest } = await github.rest.pulls.get({
437 owner: context.repo.owner,
438 repo: context.repo.repo,
439 pull_number: prNumber,
440 });
441 if (pullRequest.head.sha !== process.env.HEAD_SHA || pullRequest.state !== "open") {
442 core.info("Skipping the outdated macOS preview comment.");
443 return;
444 }
445
446 const marker = "<!-- desktop-macos-preview -->";
447 const body = [
448 marker,
449 "### macOS preview",
450 "",
451 `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`,
452 "",
453 `Version: ${process.env.PREVIEW_VERSION}`,
454 `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`,
455 "",
456 "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`.",
457 "",
458 "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.",
459 ].join("\n");
460
461 const comments = await github.paginate(github.rest.issues.listComments, {
462 owner: context.repo.owner,
463 repo: context.repo.repo,
464 issue_number: prNumber,
465 per_page: 100,
466 });
467 const existing = comments.find(
468 (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
469 );
470
471 if (existing) {
472 await github.rest.issues.updateComment({
473 owner: context.repo.owner,
474 repo: context.repo.repo,
475 comment_id: existing.id,
476 body,
477 });
478 } else {
479 await github.rest.issues.createComment({
480 owner: context.repo.owner,
481 repo: context.repo.repo,
482 issue_number: prNumber,
483 body,
484 });
485 }
486
487 cleanup:
488 name: Remove preview download
489 # A published preview no longer carries the label (resolve consumed it),
490 # so every close must look for assets; the -pr.N. filter below makes
491 # that a cheap no-op for PRs that never had one. A manual unlabel before
492 # the build consumed it withdraws the request and drops any older
493 # download too.
494 if: >-
495 github.event_name == 'pull_request_target' &&
496 (github.event.action == 'closed' ||
497 (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac'))
498 # Runs on every PR close and usually finds nothing. Keep it cheap.
499 runs-on: ubuntu-24.04
500 timeout-minutes: 10
501 # Cleanup runs must complete: a close event right after an unlabel queues
502 # behind the running cleanup instead of canceling it mid-delete.
503 concurrency:
504 group: desktop-macos-preview-${{ github.event.pull_request.number }}-cleanup
505 cancel-in-progress: false
506 permissions:
507 contents: write
508 pull-requests: write
509 steps:
510 - id: delete
511 name: Delete this PR's preview assets
512 shell: bash
513 env:
514 GH_TOKEN: ${{ github.token }}
515 PR_NUMBER: ${{ github.event.pull_request.number }}
516 run: |
517 set -euo pipefail
518
519 tag="desktop-preview"
520
521 # A stale cleanup must not delete a download that became valid
522 # again. If the PR is open and labeled once more, the next publish
523 # owns this PR's assets and replaces them itself.
524 if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
525 --json state,labels \
526 --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then
527 echo "PR is open and labeled again. Skipping cleanup."
528 echo "removed=false" >> "$GITHUB_OUTPUT"
529 exit 0
530 fi
531
532 echo "removed=true" >> "$GITHUB_OUTPUT"
533
534 if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
535 echo "No preview release exists. Nothing to clean up."
536 exit 0
537 fi
538
539 gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
540 | { grep -F -- "-pr.${PR_NUMBER}." || true; } \
541 | while read -r asset; do
542 gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
543 || echo "Asset $asset was already removed by a concurrent run."
544 done
545
546 - name: Mark the preview comment as removed
547 if: steps.delete.outputs.removed == 'true'
548 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
549 with:
550 script: |
551 const marker = "<!-- desktop-macos-preview -->";
552 const comments = await github.paginate(github.rest.issues.listComments, {
553 owner: context.repo.owner,
554 repo: context.repo.repo,
555 issue_number: context.payload.pull_request.number,
556 per_page: 100,
557 });
558 const existing = comments.find(
559 (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
560 );
561 if (!existing) {
562 return;
563 }
564
565 await github.rest.issues.updateComment({
566 owner: context.repo.owner,
567 repo: context.repo.repo,
568 comment_id: existing.id,
569 body: [
570 marker,
571 "### macOS preview",
572 "",
573 "The preview download was removed because this PR closed or the preview label was removed.",
574 ].join("\n"),
575 });
.github/workflows/desktop-macos-preview.yml+57-324
......@@ -1,74 +1,67 @@
11name: Desktop macOS Preview
22
3# Untrusted half of the macOS preview. This runs PR code (including fork PRs)
4# with a read-only token and no secrets, and only produces the JS bundle. The
5# trusted half, desktop-macos-preview-publish.yml, runs on workflow_run from
6# main, verifies the PR author is vouched, then packages, signs, notarizes, and
7# publishes the bundle without ever executing it.
8#
9# The label is a one-shot request for the commit it was applied to, not a
10# standing subscription: the trusted half removes it once this run completes,
11# and later pushes do not build until a maintainer applies it again. Each
12# signed preview is therefore an explicit per-commit decision.
13#
14# Closing the PR is handled by the publish workflow too, since deleting the
15# download needs a write token.
16
317on:
418 pull_request:
5 types: [labeled, unlabeled, synchronize, reopened, closed]
19 types: [labeled]
620
721permissions:
822 contents: read
923
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.
1324concurrency:
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') }}
25 group: desktop-macos-preview-${{ github.event.pull_request.number }}
26 # Adding an unrelated label skips the job and must not cancel a build.
27 cancel-in-progress: ${{ github.event.label.name == 'preview:mac' }}
2028
2129jobs:
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.
2430 build:
25 name: Build macOS Apple Silicon preview
26 if: >-
27 github.event.action != 'closed' &&
28 github.event.action != 'unlabeled' &&
29 github.event.pull_request.head.repo.full_name == github.repository &&
30 contains(github.event.pull_request.labels.*.name, 'preview:mac') &&
31 (github.event.action != 'labeled' || github.event.label.name == 'preview:mac')
32 runs-on: blacksmith-12vcpu-macos-26
31 name: Build preview JS bundle
32 if: github.event.label.name == 'preview:mac'
33 runs-on: blacksmith-32vcpu-ubuntu-2404
3334 timeout-minutes: 30
34 outputs:
35 dmg_name: ${{ steps.build.outputs.dmg_name }}
36 version: ${{ steps.version.outputs.version }}
3735 steps:
36 # This repository is public, so Git needs no credentials. checkout's
37 # credential cleanup runs submodule foreach even with submodules disabled,
38 # which fails on the orphaned gitlinks in our vendored .repos tree.
3839 - name: Checkout
39 uses: actions/checkout@v6
40 with:
41 ref: ${{ github.event.pull_request.head.sha }}
42 sparse-checkout: |
43 /*
44 !/.repos/
45 sparse-checkout-cone-mode: false
40 shell: bash
41 env:
42 CHECKOUT_REF: ${{ github.event.pull_request.head.sha }}
43 GIT_TERMINAL_PROMPT: "0"
44 run: |
45 set -euo pipefail
46 git init .
47 git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
48 git fetch --no-tags --depth=1 origin "$CHECKOUT_REF"
49 git sparse-checkout set --no-cone '/*' '!/.repos/'
50 git checkout --detach FETCH_HEAD
4651
4752 - name: Setup Vite+
48 uses: voidzero-dev/setup-vp@v1
53 uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
4954 with:
5055 node-version-file: package.json
5156 cache: true
5257 run-install: false
5358
54 - name: Install desktop dependencies
55 run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts...
56
57 - name: Cache resource monitor
58 id: resource_monitor_cache
59 uses: actions/cache@v6
60 with:
61 path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor
62 key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }}
63
64 - name: Setup Rust
65 if: steps.resource_monitor_cache.outputs.cache-hit != 'true'
66 uses: dtolnay/rust-toolchain@stable
67 with:
68 targets: aarch64-apple-darwin
59 - name: Install bundle dependencies
60 run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts...
6961
70 - id: version
71 name: Set preview version and public configuration
62 # The publish workflow derives the same version from this run's number,
63 # so the version baked into the bundle matches the packaged app.
64 - name: Set preview version and public configuration
7265 shell: bash
7366 env:
7467 PR_NUMBER: ${{ github.event.pull_request.number }}
......@@ -76,286 +69,26 @@ jobs:
7669 set -euo pipefail
7770
7871 base_version="$(node -p "require('./apps/desktop/package.json').version")"
79 preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}"
80 node scripts/update-release-package-versions.ts "$preview_version"
72 node scripts/update-release-package-versions.ts "${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}"
73 # Public T3 Connect identifiers (Clerk publishable key, relay URL).
8174 cp .env.example .env
8275
83 echo "version=$preview_version" >> "$GITHUB_OUTPUT"
84
85 - id: build
86 name: Build unsigned macOS DMG
87 shell: bash
88 env:
89 T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }}
90 PREVIEW_VERSION: ${{ steps.version.outputs.version }}
91 run: |
92 set -euo pipefail
76 - uses: ./.github/actions/setup-apt-mirrors
9377
94 vp run dist:desktop:artifact \
95 --platform mac \
96 --target dmg \
97 --arch arm64 \
98 --build-version "$PREVIEW_VERSION" \
99 --verbose
78 - name: Install browser secret helper build libraries
79 run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
10080
101 shopt -s nullglob
102 dmg_files=(release/*.dmg)
103 if (( ${#dmg_files[@]} != 1 )); then
104 printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2
105 exit 1
106 fi
107 printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT"
81 - name: Build JS bundle
82 run: vp run build:desktop
10883
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
112 uses: actions/upload-artifact@v7
84 # Same layout as release.yml's js-bundle so release-desktop.yml can
85 # package it unchanged.
86 - name: Upload JS bundle
87 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
11388 with:
114 path: release/*.dmg
89 name: js-bundle
90 path: |
91 apps/server/dist
92 apps/desktop/dist-electron
11593 if-no-files-found: error
116 archive: false
117 overwrite: true
118 retention-days: 7
119
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: blacksmith-8vcpu-ubuntu-2404
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 # True while the PR is open and still carries the preview label.
154 preview_eligible() {
155 [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
156 --json state,labels \
157 --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]
158 }
159
160 # The build ran for many minutes. If the PR closed or lost the label
161 # meanwhile, cleanup already ran in its own concurrency group, so
162 # publishing now would resurrect a deleted download.
163 if ! preview_eligible; then
164 echo "PR closed or preview label removed while building. Skipping publish."
165 exit 0
166 fi
167
168 dmg_path="$(find release -type f -name '*.dmg' -print -quit)"
169 if [[ -z "$dmg_path" ]]; then
170 echo "No DMG found in the downloaded artifact." >&2
171 exit 1
172 fi
173
174 # The filename comes out of the build, which runs PR code. Requiring
175 # this PR's marker keeps a build from clobbering or deleting another
176 # PR's asset, since those names carry a different -pr.N. marker.
177 if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then
178 echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2
179 exit 1
180 fi
181
182 if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
183 # "|| true" tolerates a concurrent publish job creating the
184 # release between the check and the create.
185 gh release create "$tag" \
186 --repo "$GITHUB_REPOSITORY" \
187 --target "$DEFAULT_BRANCH" \
188 --prerelease \
189 --title "Desktop preview builds" \
190 --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." \
191 || true
192 fi
193
194 # Keep one DMG per PR: drop this PR's older builds first. The
195 # trailing dot keeps -pr.12. from matching -pr.123. builds.
196 gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
197 | { grep -F -- "-pr.${PR_NUMBER}." || true; } \
198 | while read -r asset; do
199 gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
200 || echo "Asset $asset was already removed by a concurrent run."
201 done
202
203 gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber
204
205 # Re-check after uploading. A cleanup run that started during the
206 # upload listed assets before ours existed, so it cannot delete it.
207 # Whichever writer acts last sees the final PR state; if the preview
208 # became ineligible, delete what we just uploaded.
209 if ! preview_eligible; then
210 gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \
211 || echo "Asset was already removed by a concurrent run."
212 echo "PR closed or preview label removed during upload. Removed the download."
213 exit 0
214 fi
215
216 echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT"
217
218 - name: Comment download link
219 if: steps.upload.outputs.download_url != ''
220 uses: actions/github-script@v8
221 env:
222 DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }}
223 DMG_NAME: ${{ needs.build.outputs.dmg_name }}
224 HEAD_SHA: ${{ github.event.pull_request.head.sha }}
225 PREVIEW_VERSION: ${{ needs.build.outputs.version }}
226 with:
227 script: |
228 const { data: pullRequest } = await github.rest.pulls.get({
229 owner: context.repo.owner,
230 repo: context.repo.repo,
231 pull_number: context.payload.pull_request.number,
232 });
233 if (
234 pullRequest.head.sha !== process.env.HEAD_SHA ||
235 pullRequest.state !== "open" ||
236 !pullRequest.labels.some((label) => label.name === "preview:mac")
237 ) {
238 core.info("Skipping the outdated macOS preview comment.");
239 return;
240 }
241
242 const marker = "<!-- desktop-macos-preview -->";
243 const body = [
244 marker,
245 "### macOS preview",
246 "",
247 `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`,
248 "",
249 `Version: ${process.env.PREVIEW_VERSION}`,
250 `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`,
251 "",
252 "Unsigned build. Clear quarantine before opening:",
253 "```sh",
254 `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`,
255 "```",
256 "",
257 "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.",
258 ].join("\n");
259
260 const comments = await github.paginate(github.rest.issues.listComments, {
261 owner: context.repo.owner,
262 repo: context.repo.repo,
263 issue_number: context.payload.pull_request.number,
264 per_page: 100,
265 });
266 const existing = comments.find((comment) => comment.body?.includes(marker));
267
268 if (existing) {
269 await github.rest.issues.updateComment({
270 owner: context.repo.owner,
271 repo: context.repo.repo,
272 comment_id: existing.id,
273 body,
274 });
275 } else {
276 await github.rest.issues.createComment({
277 owner: context.repo.owner,
278 repo: context.repo.repo,
279 issue_number: context.payload.pull_request.number,
280 body,
281 });
282 }
283
284 # The way out: closing the PR or removing the label deletes its DMG from the
285 # rolling release and updates the PR comment to say so.
286 cleanup:
287 name: Remove preview download
288 if: >-
289 github.event.pull_request.head.repo.full_name == github.repository &&
290 ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) ||
291 (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac'))
292 runs-on: blacksmith-8vcpu-ubuntu-2404
293 timeout-minutes: 10
294 permissions:
295 contents: write
296 pull-requests: write
297 steps:
298 - id: delete
299 name: Delete this PR's preview assets
300 shell: bash
301 env:
302 GH_TOKEN: ${{ github.token }}
303 PR_NUMBER: ${{ github.event.pull_request.number }}
304 run: |
305 set -euo pipefail
306
307 tag="desktop-preview"
308
309 # A stale cleanup must not delete a download that became valid
310 # again. If the PR is open and labeled once more, the next publish
311 # owns this PR's assets and replaces them itself.
312 if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
313 --json state,labels \
314 --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then
315 echo "PR is open and labeled again. Skipping cleanup."
316 echo "removed=false" >> "$GITHUB_OUTPUT"
317 exit 0
318 fi
319
320 echo "removed=true" >> "$GITHUB_OUTPUT"
321
322 if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
323 echo "No preview release exists. Nothing to clean up."
324 exit 0
325 fi
326
327 gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \
328 | { grep -F -- "-pr.${PR_NUMBER}." || true; } \
329 | while read -r asset; do
330 gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \
331 || echo "Asset $asset was already removed by a concurrent run."
332 done
333
334 - name: Mark the preview comment as removed
335 if: steps.delete.outputs.removed == 'true'
336 uses: actions/github-script@v8
337 with:
338 script: |
339 const marker = "<!-- desktop-macos-preview -->";
340 const comments = await github.paginate(github.rest.issues.listComments, {
341 owner: context.repo.owner,
342 repo: context.repo.repo,
343 issue_number: context.payload.pull_request.number,
344 per_page: 100,
345 });
346 const existing = comments.find((comment) => comment.body?.includes(marker));
347 if (!existing) {
348 return;
349 }
350
351 await github.rest.issues.updateComment({
352 owner: context.repo.owner,
353 repo: context.repo.repo,
354 comment_id: existing.id,
355 body: [
356 marker,
357 "### macOS preview",
358 "",
359 "The preview download was removed because this PR closed or the preview label was removed.",
360 ].join("\n"),
361 });
94 retention-days: 1
.github/workflows/release-desktop.yml+62-19
......@@ -8,6 +8,33 @@ name: Release desktop build
88
99on:
1010 workflow_call:
11 secrets:
12 CSC_LINK:
13 required: false
14 CSC_KEY_PASSWORD:
15 required: false
16 APPLE_API_KEY:
17 required: false
18 APPLE_API_KEY_ID:
19 required: false
20 APPLE_API_ISSUER:
21 required: false
22 MACOS_PROVISIONING_PROFILE:
23 required: false
24 AZURE_TENANT_ID:
25 required: false
26 AZURE_CLIENT_ID:
27 required: false
28 AZURE_CLIENT_SECRET:
29 required: false
30 AZURE_TRUSTED_SIGNING_ENDPOINT:
31 required: false
32 AZURE_TRUSTED_SIGNING_ACCOUNT_NAME:
33 required: false
34 AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME:
35 required: false
36 AZURE_TRUSTED_SIGNING_PUBLISHER_NAME:
37 required: false
1138 inputs:
1239 label:
1340 required: true
......@@ -46,6 +73,12 @@ on:
4673 release_channel:
4774 required: true
4875 type: string
76 # Whether a `relay-client-tracing-config` artifact from the production
77 # relay state is expected. PR previews carry no tracing config.
78 relay_client_tracing:
79 required: false
80 default: true
81 type: boolean
4982 clerk_publishable_key:
5083 required: true
5184 type: string
......@@ -73,17 +106,24 @@ jobs:
73106 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }}
74107 T3CODE_RELAY_URL: ${{ inputs.relay_url }}
75108 steps:
109 # This repository is public, so Git needs no credentials. checkout's
110 # credential cleanup runs submodule foreach even with submodules disabled,
111 # which fails on the orphaned gitlinks in our vendored .repos tree.
76112 - name: Checkout
77 uses: actions/checkout@v6
78 with:
79 ref: ${{ inputs.ref }}
80 sparse-checkout: |
81 /*
82 !/.repos/
83 sparse-checkout-cone-mode: false
113 shell: bash
114 env:
115 CHECKOUT_REF: ${{ inputs.ref }}
116 GIT_TERMINAL_PROMPT: "0"
117 run: |
118 set -euo pipefail
119 git init .
120 git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
121 git fetch --no-tags --depth=1 origin "$CHECKOUT_REF"
122 git sparse-checkout set --no-cone '/*' '!/.repos/'
123 git checkout --detach FETCH_HEAD
84124
85125 - name: Setup Vite+
86 uses: voidzero-dev/setup-vp@v1
126 uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
87127 with:
88128 node-version-file: package.json
89129 cache: ${{ inputs.platform != 'win' }}
......@@ -97,7 +137,7 @@ jobs:
97137
98138 - name: Cache Windows packages
99139 if: inputs.platform == 'win'
100 uses: actions/cache@v6
140 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
101141 with:
102142 path: ${{ steps.package_cache_path.outputs.path }}
103143 key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }}
......@@ -106,7 +146,7 @@ jobs:
106146 # artifact leaves the cache empty, so installation runs the checks again.
107147 - name: Download dependency verification
108148 continue-on-error: true
109 uses: actions/download-artifact@v8
149 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
110150 with:
111151 name: release-dependency-verification
112152 path: ${{ runner.temp }}/pnpm-metadata
......@@ -118,7 +158,7 @@ jobs:
118158
119159 - name: Cache resource monitor
120160 id: resource_monitor_cache
121 uses: actions/cache@v6
161 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
122162 with:
123163 path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }}
124164 key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }}
......@@ -126,7 +166,7 @@ jobs:
126166 - name: Cache Linux capture helpers
127167 if: inputs.platform == 'linux'
128168 id: capture_helper_cache
129 uses: actions/cache@v6
169 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
130170 with:
131171 path: |
132172 native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot
......@@ -135,17 +175,20 @@ jobs:
135175
136176 - name: Setup Rust
137177 if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true')
138 uses: dtolnay/rust-toolchain@stable
178 uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
139179 with:
180 toolchain: stable
140181 targets: ${{ inputs.rust_target }}
141182
142183 - name: Download relay client tracing config
143 uses: actions/download-artifact@v8
184 if: inputs.relay_client_tracing
185 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
144186 with:
145187 name: relay-client-tracing-config
146188 path: ${{ runner.temp }}/relay-client-tracing
147189
148190 - name: Load relay client tracing config
191 if: inputs.relay_client_tracing
149192 shell: bash
150193 run: |
151194 config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
......@@ -160,7 +203,7 @@ jobs:
160203 # ancestor of its paths), so extracting into `apps` restores
161204 # apps/server/dist and apps/desktop/dist-electron at their build paths.
162205 - name: Download JS bundle
163 uses: actions/download-artifact@v8
206 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
164207 with:
165208 name: js-bundle
166209 path: apps
......@@ -169,7 +212,7 @@ jobs:
169212 # Windows desktop embeds the same-arch archive the release attaches.
170213 - name: Download Linux CLI archive for WSL
171214 if: inputs.platform == 'win'
172 uses: actions/download-artifact@v8
215 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
173216 with:
174217 name: cli-linux-${{ inputs.arch }}
175218 path: wsl-runtime
......@@ -450,7 +493,7 @@ jobs:
450493
451494 - name: Upload CLI archive
452495 if: inputs.cli_archive
453 uses: actions/upload-artifact@v7
496 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
454497 with:
455498 name: cli-${{ inputs.platform }}-${{ inputs.arch }}
456499 path: release-cli/*
......@@ -514,14 +557,14 @@ jobs:
514557 cp "$source_path" "$target_dir/$binary_name"
515558
516559 - name: Upload build artifacts
517 uses: actions/upload-artifact@v7
560 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
518561 with:
519562 name: desktop-${{ inputs.platform }}-${{ inputs.arch }}
520563 path: release-publish/*
521564 if-no-files-found: error
522565
523566 - name: Upload resource monitor
524 uses: actions/upload-artifact@v7
567 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
525568 with:
526569 name: resource-monitor-${{ inputs.resource_key }}
527570 path: resource-monitor-publish/${{ inputs.resource_key }}/*
docs/operations/release.md+29
......@@ -50,6 +50,35 @@ This document covers the unified release workflow for stable and nightly desktop
5050 - nightly releases are aliased to the `nightly` hosted app channel
5151- Signing is optional and auto-detected per platform from secrets.
5252
53## Pull request macOS previews
54
55Labeling a PR `preview:mac` publishes a signed, notarized Apple Silicon DMG with T3 Connect enabled
56to the rolling `desktop-preview` prerelease, and works for fork PRs. The label is a one-shot request
57for the commit it is applied to: the trusted workflow removes it once the build is in hand, and later
58pushes do not build until a maintainer applies it again. Every signed preview is therefore a
59per-commit maintainer decision, which matters because the result carries the Developer ID signature.
60Vouching a contributor lets their labeled commits be signed; it is not a standing grant. The build is
61split so the Developer ID certificate never shares a job with PR code:
62
63- `.github/workflows/desktop-macos-preview.yml` runs on `pull_request` with no secrets and builds
64 only the JS bundle from the PR (the same `js-bundle` artifact `release.yml` produces).
65- `.github/workflows/desktop-macos-preview-publish.yml` runs on `workflow_run` from `main`. It
66 refuses unless the PR is open, still labeled, its head is the built commit, and the author is a
67 bot, a collaborator, or listed in `.github/VOUCHED.td` (read from the default branch, so a PR cannot vouch
68 for itself). It then packages and signs the bundle through `release-desktop.yml` checked out at
69 `main`, so packaging, native helpers, and the Electron/desktop dependencies come from `main`, not
70 the PR. Only the version and the public T3 Connect identifiers in `.env.example` are read from the
71 PR commit, as data, so the signed app's passkey entitlement matches the bundle. A PR that changes
72 packaging must use the `channel=preview` release train above instead.
73
74Before handing the bundle to the signing runner, the trusted workflow validates its ZIP entries
75and accepts only regular files under `server/dist` and `desktop/dist-electron`, plus the directory
76entries that lead to those roots. The artifact cannot
77overwrite packaging code or installed dependencies. The bundle is copied into the app, never executed,
78on the signing runner. The
79`pull_request_target` cleanup job in the publish workflow removes the download when the PR closes, or
80when the label is removed by hand before a build consumed it, and never checks out PR code.
81
5382## Required release credentials
5483
5584Stable releases require these GitHub Actions secrets in addition to the platform and deployment