| author | |
| committer | |
| log | 9a49d6d5a656254d7079d463aa6ead5d62f4a3e6 |
| tree | 9a8981eb022e7b6daa95ba8aec5f7412702876da |
| parent | 5bf43c9f3d09faae49fbc99714456d18e3172ff2 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ||
| 3 | import shutil | |
| 4 | import stat | |
| 5 | import sys | |
| 6 | import zipfile | |
| 7 | from pathlib import Path | |
| 8 | ||
| 9 | ROOTS = ("server/dist", "desktop/dist-electron") | |
| 10 | REQUIRED_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. | |
| 17 | MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 | |
| 18 | MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 | |
| 19 | MAX_ENTRIES = 50_000 | |
| 20 | ||
| 21 | ||
| 22 | def 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 | ||
| 75 | if __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 @@ |
| 1 | import importlib.util | |
| 2 | import stat | |
| 3 | import tempfile | |
| 4 | import unittest | |
| 5 | import zipfile | |
| 6 | from pathlib import Path | |
| 7 | from unittest.mock import patch | |
| 8 | ||
| 9 | spec = importlib.util.spec_from_file_location( | |
| 10 | "stage_preview_bundle", Path(__file__).with_name("stage-preview-bundle.py") | |
| 11 | ) | |
| 12 | staging = importlib.util.module_from_spec(spec) | |
| 13 | spec.loader.exec_module(staging) | |
| 14 | ||
| 15 | ||
| 16 | class 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 | ||
| 96 | if __name__ == "__main__": | |
| 97 | unittest.main() |
.github/workflows/ci.yml+3| ... | ... | @@ -103,6 +103,9 @@ jobs: |
| 103 | 103 | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources |
| 104 | 104 | sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config |
| 105 | 105 | |
| 106 | - name: Test preview artifact validation | |
| 107 | run: python3 -B .github/scripts/stage-preview-bundle.test.py | |
| 108 | ||
| 106 | 109 | - name: Test nightly release checks |
| 107 | 110 | run: node --test .github/scripts/check-nightly-release.test.cjs |
| 108 | 111 |
.github/workflows/desktop-macos-preview-publish.yml created+575| ... | ... | @@ -0,0 +1,575 @@ |
| 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 | 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 @@ |
| 1 | 1 | name: Desktop macOS Preview |
| 2 | 2 | |
| 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 | ||
| 3 | 17 | on: |
| 4 | 18 | pull_request: |
| 5 | types: [labeled, unlabeled, synchronize, reopened, closed] | |
| 19 | types: [labeled] | |
| 6 | 20 | |
| 7 | 21 | permissions: |
| 8 | 22 | contents: read |
| 9 | 23 | |
| 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. | |
| 13 | 24 | concurrency: |
| 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' }} | |
| 20 | 28 | |
| 21 | 29 | jobs: |
| 22 | # Builds run PR code, so this job keeps a read-only token. Publishing to the | |
| 23 | # release happens in the publish job below, which never checks out PR code. | |
| 24 | 30 | 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 | |
| 33 | 34 | timeout-minutes: 30 |
| 34 | outputs: | |
| 35 | dmg_name: ${{ steps.build.outputs.dmg_name }} | |
| 36 | version: ${{ steps.version.outputs.version }} | |
| 37 | 35 | 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. | |
| 38 | 39 | - 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 | |
| 46 | 51 | |
| 47 | 52 | - name: Setup Vite+ |
| 48 | uses: voidzero-dev/setup-vp@v1 | |
| 53 | uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 | |
| 49 | 54 | with: |
| 50 | 55 | node-version-file: package.json |
| 51 | 56 | cache: true |
| 52 | 57 | run-install: false |
| 53 | 58 | |
| 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... | |
| 69 | 61 | |
| 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 | |
| 72 | 65 | shell: bash |
| 73 | 66 | env: |
| 74 | 67 | PR_NUMBER: ${{ github.event.pull_request.number }} |
| ... | ... | @@ -76,286 +69,26 @@ jobs: |
| 76 | 69 | set -euo pipefail |
| 77 | 70 | |
| 78 | 71 | 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). | |
| 81 | 74 | cp .env.example .env |
| 82 | 75 | |
| 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 | |
| 93 | 77 | |
| 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 | |
| 100 | 80 | |
| 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 | |
| 108 | 83 | |
| 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 | |
| 113 | 88 | with: |
| 114 | path: release/*.dmg | |
| 89 | name: js-bundle | |
| 90 | path: | | |
| 91 | apps/server/dist | |
| 92 | apps/desktop/dist-electron | |
| 115 | 93 | 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 |
| 8 | 8 | |
| 9 | 9 | on: |
| 10 | 10 | 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 | |
| 11 | 38 | inputs: |
| 12 | 39 | label: |
| 13 | 40 | required: true |
| ... | ... | @@ -46,6 +73,12 @@ on: |
| 46 | 73 | release_channel: |
| 47 | 74 | required: true |
| 48 | 75 | 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 | |
| 49 | 82 | clerk_publishable_key: |
| 50 | 83 | required: true |
| 51 | 84 | type: string |
| ... | ... | @@ -73,17 +106,24 @@ jobs: |
| 73 | 106 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} |
| 74 | 107 | T3CODE_RELAY_URL: ${{ inputs.relay_url }} |
| 75 | 108 | 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. | |
| 76 | 112 | - 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 | |
| 84 | 124 | |
| 85 | 125 | - name: Setup Vite+ |
| 86 | uses: voidzero-dev/setup-vp@v1 | |
| 126 | uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 | |
| 87 | 127 | with: |
| 88 | 128 | node-version-file: package.json |
| 89 | 129 | cache: ${{ inputs.platform != 'win' }} |
| ... | ... | @@ -97,7 +137,7 @@ jobs: |
| 97 | 137 | |
| 98 | 138 | - name: Cache Windows packages |
| 99 | 139 | if: inputs.platform == 'win' |
| 100 | uses: actions/cache@v6 | |
| 140 | uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 | |
| 101 | 141 | with: |
| 102 | 142 | path: ${{ steps.package_cache_path.outputs.path }} |
| 103 | 143 | key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }} |
| ... | ... | @@ -106,7 +146,7 @@ jobs: |
| 106 | 146 | # artifact leaves the cache empty, so installation runs the checks again. |
| 107 | 147 | - name: Download dependency verification |
| 108 | 148 | continue-on-error: true |
| 109 | uses: actions/download-artifact@v8 | |
| 149 | uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| 110 | 150 | with: |
| 111 | 151 | name: release-dependency-verification |
| 112 | 152 | path: ${{ runner.temp }}/pnpm-metadata |
| ... | ... | @@ -118,7 +158,7 @@ jobs: |
| 118 | 158 | |
| 119 | 159 | - name: Cache resource monitor |
| 120 | 160 | id: resource_monitor_cache |
| 121 | uses: actions/cache@v6 | |
| 161 | uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 | |
| 122 | 162 | with: |
| 123 | 163 | path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} |
| 124 | 164 | 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: |
| 126 | 166 | - name: Cache Linux capture helpers |
| 127 | 167 | if: inputs.platform == 'linux' |
| 128 | 168 | id: capture_helper_cache |
| 129 | uses: actions/cache@v6 | |
| 169 | uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 | |
| 130 | 170 | with: |
| 131 | 171 | path: | |
| 132 | 172 | native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot |
| ... | ... | @@ -135,17 +175,20 @@ jobs: |
| 135 | 175 | |
| 136 | 176 | - name: Setup Rust |
| 137 | 177 | 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 | |
| 139 | 179 | with: |
| 180 | toolchain: stable | |
| 140 | 181 | targets: ${{ inputs.rust_target }} |
| 141 | 182 | |
| 142 | 183 | - 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 | |
| 144 | 186 | with: |
| 145 | 187 | name: relay-client-tracing-config |
| 146 | 188 | path: ${{ runner.temp }}/relay-client-tracing |
| 147 | 189 | |
| 148 | 190 | - name: Load relay client tracing config |
| 191 | if: inputs.relay_client_tracing | |
| 149 | 192 | shell: bash |
| 150 | 193 | run: | |
| 151 | 194 | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" |
| ... | ... | @@ -160,7 +203,7 @@ jobs: |
| 160 | 203 | # ancestor of its paths), so extracting into `apps` restores |
| 161 | 204 | # apps/server/dist and apps/desktop/dist-electron at their build paths. |
| 162 | 205 | - name: Download JS bundle |
| 163 | uses: actions/download-artifact@v8 | |
| 206 | uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| 164 | 207 | with: |
| 165 | 208 | name: js-bundle |
| 166 | 209 | path: apps |
| ... | ... | @@ -169,7 +212,7 @@ jobs: |
| 169 | 212 | # Windows desktop embeds the same-arch archive the release attaches. |
| 170 | 213 | - name: Download Linux CLI archive for WSL |
| 171 | 214 | if: inputs.platform == 'win' |
| 172 | uses: actions/download-artifact@v8 | |
| 215 | uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| 173 | 216 | with: |
| 174 | 217 | name: cli-linux-${{ inputs.arch }} |
| 175 | 218 | path: wsl-runtime |
| ... | ... | @@ -450,7 +493,7 @@ jobs: |
| 450 | 493 | |
| 451 | 494 | - name: Upload CLI archive |
| 452 | 495 | if: inputs.cli_archive |
| 453 | uses: actions/upload-artifact@v7 | |
| 496 | uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| 454 | 497 | with: |
| 455 | 498 | name: cli-${{ inputs.platform }}-${{ inputs.arch }} |
| 456 | 499 | path: release-cli/* |
| ... | ... | @@ -514,14 +557,14 @@ jobs: |
| 514 | 557 | cp "$source_path" "$target_dir/$binary_name" |
| 515 | 558 | |
| 516 | 559 | - name: Upload build artifacts |
| 517 | uses: actions/upload-artifact@v7 | |
| 560 | uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| 518 | 561 | with: |
| 519 | 562 | name: desktop-${{ inputs.platform }}-${{ inputs.arch }} |
| 520 | 563 | path: release-publish/* |
| 521 | 564 | if-no-files-found: error |
| 522 | 565 | |
| 523 | 566 | - name: Upload resource monitor |
| 524 | uses: actions/upload-artifact@v7 | |
| 567 | uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| 525 | 568 | with: |
| 526 | 569 | name: resource-monitor-${{ inputs.resource_key }} |
| 527 | 570 | 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 |
| 50 | 50 | - nightly releases are aliased to the `nightly` hosted app channel |
| 51 | 51 | - Signing is optional and auto-detected per platform from secrets. |
| 52 | 52 | |
| 53 | ## Pull request macOS previews | |
| 54 | ||
| 55 | Labeling a PR `preview:mac` publishes a signed, notarized Apple Silicon DMG with T3 Connect enabled | |
| 56 | to the rolling `desktop-preview` prerelease, and works for fork PRs. The label is a one-shot request | |
| 57 | for the commit it is applied to: the trusted workflow removes it once the build is in hand, and later | |
| 58 | pushes do not build until a maintainer applies it again. Every signed preview is therefore a | |
| 59 | per-commit maintainer decision, which matters because the result carries the Developer ID signature. | |
| 60 | Vouching a contributor lets their labeled commits be signed; it is not a standing grant. The build is | |
| 61 | split 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 | ||
| 74 | Before handing the bundle to the signing runner, the trusted workflow validates its ZIP entries | |
| 75 | and accepts only regular files under `server/dist` and `desktop/dist-electron`, plus the directory | |
| 76 | entries that lead to those roots. The artifact cannot | |
| 77 | overwrite packaging code or installed dependencies. The bundle is copied into the app, never executed, | |
| 78 | on the signing runner. The | |
| 79 | `pull_request_target` cleanup job in the publish workflow removes the download when the PR closes, or | |
| 80 | when the label is removed by hand before a build consumed it, and never checks out PR code. | |
| 81 | ||
| 53 | 82 | ## Required release credentials |
| 54 | 83 | |
| 55 | 84 | Stable releases require these GitHub Actions secrets in addition to the platform and deployment |