From 9a49d6d5a656254d7079d463aa6ead5d62f4a3e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 16:23:25 -0700 Subject: [PATCH] ci(desktop): sign fork PR macOS previews without exposing signing secrets (#11760) Co-authored-by: Claude Fable 5 --- .github/scripts/stage-preview-bundle.py | 76 +++ .github/scripts/stage-preview-bundle.test.py | 97 +++ .github/workflows/ci.yml | 3 + .../desktop-macos-preview-publish.yml | 575 ++++++++++++++++++ .github/workflows/desktop-macos-preview.yml | 381 ++---------- .github/workflows/release-desktop.yml | 81 ++- docs/operations/release.md | 29 + 7 files changed, 899 insertions(+), 343 deletions(-) create mode 100644 .github/scripts/stage-preview-bundle.py create mode 100644 .github/scripts/stage-preview-bundle.test.py create mode 100644 .github/workflows/desktop-macos-preview-publish.yml diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py new file mode 100644 index 0000000000000000000000000000000000000000..0b3a53285916b8f08f4a4f8eb0a037486874e117 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.py @@ -0,0 +1,76 @@ +"""Stage an untrusted preview ZIP without letting it replace packaging code.""" + +import shutil +import stat +import sys +import zipfile +from pathlib import Path + +ROOTS = ("server/dist", "desktop/dist-electron") +REQUIRED_FILES = { + "server/dist/bin.mjs", + "server/dist/client/index.html", + "desktop/dist-electron/main.cjs", +} +# The current bundle is about 32 MiB compressed. Bound extraction on the +# trusted runner even when the PR replaces the uploader entirely. +MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 +MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 +MAX_ENTRIES = 50_000 + + +def stage_bundle(archive: Path, destination: Path): + if archive.stat().st_size > MAX_ARCHIVE_BYTES: + raise ValueError("Preview archive is too large") + with zipfile.ZipFile(archive) as bundle: + entries = bundle.infolist() + if len(entries) > MAX_ENTRIES: + raise ValueError("Preview archive has too many entries") + if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES: + raise ValueError("Expanded preview bundle is too large") + seen = set() + files = set() + for entry in entries: + name = entry.filename.removesuffix("/") + parts = name.split("/") + # Reject ambiguous paths before normalization, including names + # that would alias on the macOS signing runner. + if ( + entry.orig_filename != entry.filename + or any(part in ("", ".", "..") for part in parts) + or any(char in name for char in "\\:") + or not name.isascii() + or any(ord(char) < 32 or ord(char) == 127 for char in name) + ): + raise ValueError(f"Unsafe preview path: {entry.filename!r}") + allowed = any(name.startswith(root + "/") for root in ROOTS) + if entry.is_dir(): + allowed |= any(root == name or root.startswith(name + "/") for root in ROOTS) + if not allowed: + raise ValueError(f"Unexpected preview path: {name!r}") + kind = stat.S_IFMT(entry.external_attr >> 16) + if kind not in (0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG): + raise ValueError(f"Non-regular preview entry: {name!r}") + if name.casefold() in seen: + raise ValueError(f"Duplicate preview path: {name!r}") + seen.add(name.casefold()) + if not entry.is_dir(): + files.add(name) + if not REQUIRED_FILES <= files: + raise ValueError("Preview bundle is missing required entry points") + # Validate all names before writing anything. This is a fresh directory + # outside the checkout; neither pre-existing links nor trusted files + # can be followed or overwritten. ZIP permissions are never restored. + destination.mkdir(parents=True, exist_ok=False) + for entry in entries: + target = destination / entry.filename + if entry.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(entry) as source, target.open("xb") as output: + shutil.copyfileobj(source, output) + + +if __name__ == "__main__": + stage_bundle(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/.github/scripts/stage-preview-bundle.test.py b/.github/scripts/stage-preview-bundle.test.py new file mode 100644 index 0000000000000000000000000000000000000000..5957279c88850cbc3c05493dbbe5f9dd999990b6 --- /dev/null +++ b/.github/scripts/stage-preview-bundle.test.py @@ -0,0 +1,97 @@ +import importlib.util +import stat +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "stage_preview_bundle", Path(__file__).with_name("stage-preview-bundle.py") +) +staging = importlib.util.module_from_spec(spec) +spec.loader.exec_module(staging) + + +class StagePreviewBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.archive = self.root / "bundle.zip" + self.destination = self.root / "staged" + + def bundle(self, extra=(), missing=None): + with zipfile.ZipFile(self.archive, "w") as bundle: + for name in sorted(staging.REQUIRED_FILES - {missing}): + bundle.writestr(name, b"bundle data, never executed") + for name, content in extra: + bundle.writestr(name, content) + + def stage(self): + staging.stage_bundle(self.archive, self.destination) + + def test_preserves_valid_bundle_layout_and_bytes(self): + self.bundle([("server/", b""), ("server/dist/", b""), + ("desktop/dist-electron/chunks/helper.cjs", b"chunk")]) + self.stage() + for name in staging.REQUIRED_FILES: + self.assertEqual((self.destination / name).read_bytes(), b"bundle data, never executed") + self.assertEqual((self.destination / "desktop/dist-electron/chunks/helper.cjs").read_bytes(), b"chunk") + + def test_rejects_builder_overwrite_and_unsafe_paths_before_writing(self): + for name in [ + "desktop/node_modules/electron-builder/cli.js", + "desktop/package.json", + "server/dist/../../desktop/package.json", + "../package.json", + "/server/dist/absolute", + "server/dist/./alias", + "server/dist//alias", + "server/dist/back\\slash", + "server/dist/file:stream", + "server/dist/BIN.MJS", + ]: + with self.subTest(name=name): + self.bundle([(name, b"untrusted")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_rejects_links_and_special_files(self): + for mode in [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR]: + with self.subTest(mode=mode): + entry = zipfile.ZipInfo("server/dist/link") + entry.create_system = 3 + entry.external_attr = (mode | 0o777) << 16 + self.bundle([(entry, b"../../../desktop/node_modules")]) + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_requires_entry_points(self): + self.bundle(missing="desktop/dist-electron/main.cjs") + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_bounds_archive_size_expanded_size_and_entry_count(self): + for limit in ["MAX_ARCHIVE_BYTES", "MAX_EXPANDED_BYTES", "MAX_ENTRIES"]: + with self.subTest(limit=limit), patch.object(staging, limit, 1): + self.bundle() + with self.assertRaises(ValueError): + self.stage() + self.assertFalse(self.destination.exists()) + + def test_refuses_existing_destination(self): + self.bundle() + self.destination.mkdir() + sentinel = self.destination / "trusted" + sentinel.write_text("untouched") + with self.assertRaises(FileExistsError): + self.stage() + self.assertEqual(sentinel.read_text(), "untouched") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a28f205d8634c966e9689fba95be113f003893..611d4cf44f7548a0247caa09394dbf7b242bd944 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test preview artifact validation + run: python3 -B .github/scripts/stage-preview-bundle.test.py + - name: Test nightly release checks run: node --test .github/scripts/check-nightly-release.test.cjs diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml new file mode 100644 index 0000000000000000000000000000000000000000..4d141e327c1f770accbd778dcd50dd1a7d70fd50 --- /dev/null +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -0,0 +1,575 @@ +name: Desktop macOS Preview Publish + +# Trusted half of the macOS preview. Runs from main with secrets and a write +# token, so it must never execute PR code: the PR's JS bundle is only data that +# gets packaged into the app. Everything that runs here (packaging, signing, +# notarization, publishing) is main's code. +# +# Gate, in order: the completed build run belongs to an open PR that still +# carries the preview:mac label and whose head is the built commit, and the PR +# author is trusted by the vouch list. A maintainer applying the label alone is +# not enough, since the bundle gets signed with the Developer ID certificate. +# +# The label is consumed here once the gate passes, so it only ever covers the +# one commit a maintainer applied it to. A later push builds nothing until the +# label is applied again. + +on: + workflow_run: + workflows: [Desktop macOS Preview] + types: [completed] + # The way out: closing the PR deletes its download, and removing the label + # before it is consumed cancels the preview. pull_request_target gives this a + # write token for fork PRs; it never checks out PR code. + pull_request_target: + types: [closed, unlabeled] + +permissions: + contents: read + +jobs: + resolve: + name: Verify preview eligibility + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + # The build workflow completes for every PR push (its label gate is on the + # job), so this runs often and usually finds nothing. Keep it cheap. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + # write only to consume the label; nothing here runs PR code. + pull-requests: write + outputs: + eligible: ${{ steps.gate.outputs.eligible }} + pr_number: ${{ steps.pr.outputs.pr_number }} + head_sha: ${{ steps.pr.outputs.head_sha }} + version: ${{ steps.version.outputs.version }} + clerk_publishable_key: ${{ steps.version.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.version.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ steps.version.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ steps.version.outputs.relay_url }} + steps: + - id: pr + name: Resolve the pull request behind the build + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + // The build workflow also completes (with every job skipped) for + // label events that are not the preview label. Only a run that + // produced a bundle is worth resolving. + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const bundles = artifacts.filter((artifact) => artifact.name === "js-bundle" && !artifact.expired); + if (bundles.length !== 1) { + core.info(`Expected one js-bundle artifact; found ${bundles.length}. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + + // workflow_run.pull_requests is empty for fork PRs, so resolve the + // PR from the built commit instead and require exactly one open PR + // from the same head repository and branch. The build baked its + // PR number into the version, so two candidates would mean the + // asset name could belong to either. + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: run.head_sha, per_page: 100 }, + ); + const matching = associated.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR for ${run.head_sha}; found ${matching.length}. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: matching[0].number, + }); + + if (pull.state !== "open") { + core.info(`PR #${pull.number} is not open. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + if (pull.head.sha !== run.head_sha) { + core.info(`PR #${pull.number} moved to ${pull.head.sha} after ${run.head_sha} was built. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + if (!pull.labels.some((label) => label.name === "preview:mac")) { + core.info(`PR #${pull.number} no longer carries the preview:mac label. Skipping.`); + core.setOutput("eligible", "false"); + return; + } + + core.setOutput("artifact_id", String(bundles[0].id)); + core.setOutput("eligible", "true"); + core.setOutput("pr_number", String(pull.number)); + core.setOutput("head_sha", pull.head.sha); + core.setOutput("author", pull.user.login); + + # Reads VOUCHED.td from the default branch through the API, so a PR + # cannot vouch for itself. + - id: vouch + name: Check PR author trust + if: steps.pr.outputs.eligible == 'true' + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + with: + user: ${{ steps.pr.outputs.author }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The label authorized exactly this build, so take it now, before the + # long signing job. Removing it with GITHUB_TOKEN does not fire the + # unlabeled cleanup below (workflow-token events never start runs), so + # the download this run publishes survives. If a maintainer removed the + # label first, that removal wins: the 404 makes this run ineligible. + - id: consume + name: Consume the preview label + if: steps.pr.outputs.eligible == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + name: "preview:mac", + }); + core.setOutput("consumed", "true"); + } catch (error) { + if (error.status !== 404) throw error; + core.info("The preview:mac label was removed before this build could consume it. Skipping."); + core.setOutput("consumed", "false"); + } + + - id: gate + name: Decide eligibility + shell: bash + env: + PR_ELIGIBLE: ${{ steps.pr.outputs.eligible }} + LABEL_CONSUMED: ${{ steps.consume.outputs.consumed }} + VOUCH_STATUS: ${{ steps.vouch.outputs.status }} + AUTHOR: ${{ steps.pr.outputs.author }} + run: | + set -euo pipefail + if [[ "$PR_ELIGIBLE" != "true" || "$LABEL_CONSUMED" != "true" ]]; then + echo "eligible=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + case "$VOUCH_STATUS" in + bot|collaborator|vouched) + echo "Author $AUTHOR is trusted ($VOUCH_STATUS)." + echo "eligible=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Author $AUTHOR is not vouched ($VOUCH_STATUS). Add them to .github/VOUCHED.td to allow signed previews." + echo "eligible=false" >> "$GITHUB_OUTPUT" + ;; + esac + + # Same inputs as the build workflow, read from the built commit through + # the contents API as data: the desktop manifest's base version plus the + # build run's number reproduces the version baked into the bundle, and + # .env.example holds the public T3 Connect identifiers the bundle was + # compiled with, which the signed app's passkey entitlement must match. + # Both are validated before they reach a file name or an entitlement. + - id: version + name: Resolve preview version and public configuration + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + BUILD_RUN_NUMBER: ${{ github.event.workflow_run.run_number }} + run: | + set -euo pipefail + head_file() { + gh api "repos/${GITHUB_REPOSITORY}/contents/$1?ref=${HEAD_SHA}" --jq '.content' | base64 --decode + } + + base_version="$(head_file apps/desktop/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" + # The committed desktop version is always a plain X.Y.Z; every + # prerelease identifier is added by a release run. Anything else + # would also let a foreign -pr.N. marker into the asset name, which + # is what publish and cleanup key on. + if [[ ! "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unexpected desktop version '$base_version' at $HEAD_SHA; expected X.Y.Z." >&2 + exit 1 + fi + echo "version=${base_version}-pr.${PR_NUMBER}.${BUILD_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + head_file .env.example > "$RUNNER_TEMP/head.env.example" + 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 + output="${key%%:*}" + name="${key##*:}" + value="$(sed -n "s/^${name}=//p" "$RUNNER_TEMP/head.env.example" | head -n 1)" + if [[ ! "$value" =~ ^[A-Za-z0-9._:/-]+$ ]]; then + echo "$name is missing or malformed in .env.example at $HEAD_SHA." >&2 + exit 1 + fi + echo "${output}=${value}" >> "$GITHUB_OUTPUT" + done + + # Only the default-branch revision that owns this workflow supplies the + # validator. Never check out the PR in a workflow_run job. + - name: Checkout trusted artifact validator + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + CHECKOUT_REF: ${{ github.sha }} + GIT_TERMINAL_PROMPT: "0" + # Anonymous fetch avoids checkout's credential cleanup, which fails on + # orphaned gitlinks in .repos even when that directory is excluded. + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set .github/scripts + git checkout --detach FETCH_HEAD + + # Fetch the archive as bytes. Extracting it over the checkout, even with + # download-artifact, could replace code that runs with signing secrets. + - name: Download and validate PR JS bundle + if: steps.gate.outputs.eligible == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/js-bundle.zip" + python3 .github/scripts/stage-preview-bundle.py "$RUNNER_TEMP/js-bundle.zip" "$RUNNER_TEMP/js-bundle" + + # Only validated bundle files cross into the signing job's artifact. + - name: Stage JS bundle for packaging + if: steps.gate.outputs.eligible == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: js-bundle + path: ${{ runner.temp }}/js-bundle + if-no-files-found: error + # Re-running this workflow re-uploads under the same run. + overwrite: true + retention-days: 1 + + build: + name: Package and sign macOS arm64 preview + needs: resolve + if: needs.resolve.outputs.eligible == 'true' + concurrency: + group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-build + cancel-in-progress: true + uses: ./.github/workflows/release-desktop.yml + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + with: + version: ${{ needs.resolve.outputs.version }} + ref: ${{ github.sha }} + release_channel: preview + relay_client_tracing: false + clerk_publishable_key: ${{ needs.resolve.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ needs.resolve.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ needs.resolve.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ needs.resolve.outputs.relay_url }} + label: macOS arm64 preview + runner: blacksmith-12vcpu-macos-26 + platform: mac + target: dmg + arch: arm64 + rust_target: aarch64-apple-darwin + resource_key: darwin-arm64 + cli_archive: false + + # Release assets download without a GitHub account, unlike workflow + # artifacts. All preview DMGs live on one rolling prerelease tagged + # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a + # build never notifies release watchers. + publish: + name: Publish anonymous download + needs: [resolve, build] + if: needs.resolve.outputs.eligible == 'true' && needs.build.result == 'success' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + # Its own group, so a publish never cancels a newer commit's signing job + # (they would share the build group) and is never cancelled mid-upload. + # preview_eligible's head check keeps a superseded publish from landing. + concurrency: + group: desktop-macos-preview-${{ needs.resolve.outputs.pr_number }}-publish + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - name: Download macOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: desktop-mac-arm64 + path: release + + - id: upload + name: Upload DMG to the rolling preview release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # True while the PR is open and still points at the commit this + # build came from. The label was consumed in resolve, so it is not + # part of this check. A push does not cancel an already-running + # signing job, so this is what keeps a superseded commit's DMG off + # the release. + preview_eligible() { + [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,headRefOid \ + --jq '.state + " " + .headRefOid')" == "OPEN $HEAD_SHA" ]] + } + + # The build ran for many minutes. If the PR closed or moved on + # meanwhile, cleanup already ran in its own concurrency group or a + # newer build owns the asset, so publishing now would resurrect a + # deleted download or clobber a newer one. + if ! preview_eligible; then + echo "PR closed or head moved while building. Skipping publish." + exit 0 + fi + + shopt -s nullglob + dmg_files=(release/*.dmg) + if (( ${#dmg_files[@]} != 1 )); then + printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 + exit 1 + fi + dmg_path="${dmg_files[0]}" + + # Requiring this PR's marker keeps a build from clobbering or + # deleting another PR's asset, since those names carry a different + # -pr.N. marker. + if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then + echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 + exit 1 + fi + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # "|| true" tolerates a concurrent publish job creating the + # release between the check and the create. + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$DEFAULT_BRANCH" \ + --prerelease \ + --title "Desktop preview builds" \ + --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." \ + || true + fi + + # Keep one DMG per PR: drop this PR's older builds first. The + # trailing dot keeps -pr.12. from matching -pr.123. builds. + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber + + # Re-check after uploading. A cleanup run that started during the + # upload listed assets before ours existed, so it cannot delete it. + # Whichever writer acts last sees the final PR state; if the preview + # became ineligible, delete what we just uploaded. + if ! preview_eligible; then + gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset was already removed by a concurrent run." + echo "PR closed or head moved during upload. Removed the download." + exit 0 + fi + + echo "dmg_name=$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + + - name: Comment download link + if: steps.upload.outputs.download_url != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + PREVIEW_VERSION: ${{ needs.resolve.outputs.version }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pullRequest.head.sha !== process.env.HEAD_SHA || pullRequest.state !== "open") { + core.info("Skipping the outdated macOS preview comment."); + return; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, + "", + `Version: ${process.env.PREVIEW_VERSION}`, + `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, + "", + "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`.", + "", + "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.", + ].join("\n"); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + + cleanup: + name: Remove preview download + # A published preview no longer carries the label (resolve consumed it), + # so every close must look for assets; the -pr.N. filter below makes + # that a cheap no-op for PRs that never had one. A manual unlabel before + # the build consumed it withdraws the request and drops any older + # download too. + if: >- + github.event_name == 'pull_request_target' && + (github.event.action == 'closed' || + (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) + # Runs on every PR close and usually finds nothing. Keep it cheap. + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # Cleanup runs must complete: a close event right after an unlabel queues + # behind the running cleanup instead of canceling it mid-delete. + concurrency: + group: desktop-macos-preview-${{ github.event.pull_request.number }}-cleanup + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - id: delete + name: Delete this PR's preview assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # A stale cleanup must not delete a download that became valid + # again. If the PR is open and labeled once more, the next publish + # owns this PR's assets and replaces them itself. + if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then + echo "PR is open and labeled again. Skipping cleanup." + echo "removed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "removed=true" >> "$GITHUB_OUTPUT" + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "No preview release exists. Nothing to clean up." + exit 0 + fi + + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + - name: Mark the preview comment as removed + if: steps.delete.outputs.removed == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker), + ); + if (!existing) { + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: [ + marker, + "### macOS preview", + "", + "The preview download was removed because this PR closed or the preview label was removed.", + ].join("\n"), + }); diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 7875aec6f36b01cfdff8d57ce0b8f1eec63ba560..43b6c8220aaf366b8d63a4852993c4d850eb66f8 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -1,74 +1,67 @@ name: Desktop macOS Preview +# Untrusted half of the macOS preview. This runs PR code (including fork PRs) +# with a read-only token and no secrets, and only produces the JS bundle. The +# trusted half, desktop-macos-preview-publish.yml, runs on workflow_run from +# main, verifies the PR author is vouched, then packages, signs, notarizes, and +# publishes the bundle without ever executing it. +# +# The label is a one-shot request for the commit it was applied to, not a +# standing subscription: the trusted half removes it once this run completes, +# and later pushes do not build until a maintainer applies it again. Each +# signed preview is therefore an explicit per-commit decision. +# +# Closing the PR is handled by the publish workflow too, since deleting the +# download needs a write token. + on: pull_request: - types: [labeled, unlabeled, synchronize, reopened, closed] + types: [labeled] permissions: contents: read -# Build events and cleanup events use separate groups: a push must cancel a -# stale in-flight build, but must never cancel a cleanup run mid-delete. The -# publish job re-checks PR state before uploading to cover the reverse race. concurrency: - group: desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }} - # Cleanup runs must complete (a close event right after an unlabel queues - # behind the running cleanup instead of canceling it mid-delete), and events - # that skip the build job, such as adding an unrelated label, must not - # cancel an in-flight build either. - cancel-in-progress: ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }} + group: desktop-macos-preview-${{ github.event.pull_request.number }} + # Adding an unrelated label skips the job and must not cancel a build. + cancel-in-progress: ${{ github.event.label.name == 'preview:mac' }} jobs: - # Builds run PR code, so this job keeps a read-only token. Publishing to the - # release happens in the publish job below, which never checks out PR code. build: - name: Build macOS Apple Silicon preview - if: >- - github.event.action != 'closed' && - github.event.action != 'unlabeled' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(github.event.pull_request.labels.*.name, 'preview:mac') && - (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') - runs-on: blacksmith-12vcpu-macos-26 + name: Build preview JS bundle + if: github.event.label.name == 'preview:mac' + runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 - outputs: - dmg_name: ${{ steps.build.outputs.dmg_name }} - version: ${{ steps.version.outputs.version }} steps: + # This repository is public, so Git needs no credentials. checkout's + # credential cleanup runs submodule foreach even with submodules disabled, + # which fails on the orphaned gitlinks in our vendored .repos tree. - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + CHECKOUT_REF: ${{ github.event.pull_request.head.sha }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: true run-install: false - - name: Install desktop dependencies - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + - name: Install bundle dependencies + run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - - name: Cache resource monitor - id: resource_monitor_cache - uses: actions/cache@v6 - with: - path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor - key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - - - name: Setup Rust - if: steps.resource_monitor_cache.outputs.cache-hit != 'true' - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin - - - id: version - name: Set preview version and public configuration + # The publish workflow derives the same version from this run's number, + # so the version baked into the bundle matches the packaged app. + - name: Set preview version and public configuration shell: bash env: PR_NUMBER: ${{ github.event.pull_request.number }} @@ -76,286 +69,26 @@ jobs: set -euo pipefail base_version="$(node -p "require('./apps/desktop/package.json').version")" - preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" - node scripts/update-release-package-versions.ts "$preview_version" + node scripts/update-release-package-versions.ts "${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + # Public T3 Connect identifiers (Clerk publishable key, relay URL). cp .env.example .env - echo "version=$preview_version" >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/setup-apt-mirrors - - id: build - name: Build unsigned macOS DMG - shell: bash - env: - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} - PREVIEW_VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - vp run dist:desktop:artifact \ - --platform mac \ - --target dmg \ - --arch arm64 \ - --build-version "$PREVIEW_VERSION" \ - --verbose + - name: Build JS bundle + run: vp run build:desktop - shopt -s nullglob - dmg_files=(release/*.dmg) - if (( ${#dmg_files[@]} != 1 )); then - printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 - exit 1 - fi - printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" - - # archive: false uploads the file as its own artifact named after the - # file, so the publish job downloads by *.dmg pattern, not by name. - - name: Upload macOS DMG - uses: actions/upload-artifact@v7 + # Same layout as release.yml's js-bundle so release-desktop.yml can + # package it unchanged. + - name: Upload JS bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - path: release/*.dmg + name: js-bundle + path: | + apps/server/dist + apps/desktop/dist-electron if-no-files-found: error - archive: false - overwrite: true - retention-days: 7 - - # Release assets download without a GitHub account, unlike workflow - # artifacts. All preview DMGs live on one rolling prerelease tagged - # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a - # build never notifies release watchers. This job holds the write token and - # only handles the artifact the build job produced; it never runs PR code. - publish: - name: Publish anonymous download - needs: build - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - name: Download macOS DMG - uses: actions/download-artifact@v8 - with: - pattern: "*.dmg" - merge-multiple: true - path: release - - - id: upload - name: Upload DMG to the rolling preview release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # True while the PR is open and still carries the preview label. - preview_eligible() { - [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]] - } - - # The build ran for many minutes. If the PR closed or lost the label - # meanwhile, cleanup already ran in its own concurrency group, so - # publishing now would resurrect a deleted download. - if ! preview_eligible; then - echo "PR closed or preview label removed while building. Skipping publish." - exit 0 - fi - - dmg_path="$(find release -type f -name '*.dmg' -print -quit)" - if [[ -z "$dmg_path" ]]; then - echo "No DMG found in the downloaded artifact." >&2 - exit 1 - fi - - # The filename comes out of the build, which runs PR code. Requiring - # this PR's marker keeps a build from clobbering or deleting another - # PR's asset, since those names carry a different -pr.N. marker. - if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then - echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 - exit 1 - fi - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - # "|| true" tolerates a concurrent publish job creating the - # release between the check and the create. - gh release create "$tag" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$DEFAULT_BRANCH" \ - --prerelease \ - --title "Desktop preview builds" \ - --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." \ - || true - fi - - # Keep one DMG per PR: drop this PR's older builds first. The - # trailing dot keeps -pr.12. from matching -pr.123. builds. - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber - - # Re-check after uploading. A cleanup run that started during the - # upload listed assets before ours existed, so it cannot delete it. - # Whichever writer acts last sees the final PR state; if the preview - # became ineligible, delete what we just uploaded. - if ! preview_eligible; then - gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset was already removed by a concurrent run." - echo "PR closed or preview label removed during upload. Removed the download." - exit 0 - fi - - echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" - - - name: Comment download link - if: steps.upload.outputs.download_url != '' - uses: actions/github-script@v8 - env: - DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} - DMG_NAME: ${{ needs.build.outputs.dmg_name }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PREVIEW_VERSION: ${{ needs.build.outputs.version }} - with: - script: | - const { data: pullRequest } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - }); - if ( - pullRequest.head.sha !== process.env.HEAD_SHA || - pullRequest.state !== "open" || - !pullRequest.labels.some((label) => label.name === "preview:mac") - ) { - core.info("Skipping the outdated macOS preview comment."); - return; - } - - const marker = ""; - const body = [ - marker, - "### macOS preview", - "", - `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, - "", - `Version: ${process.env.PREVIEW_VERSION}`, - `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, - "", - "Unsigned build. Clear quarantine before opening:", - "```sh", - `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, - "```", - "", - "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.", - ].join("\n"); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body, - }); - } - - # The way out: closing the PR or removing the label deletes its DMG from the - # rolling release and updates the PR comment to say so. - cleanup: - name: Remove preview download - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) || - (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - id: delete - name: Delete this PR's preview assets - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - tag="desktop-preview" - - # A stale cleanup must not delete a download that became valid - # again. If the PR is open and labeled once more, the next publish - # owns this PR's assets and replaces them itself. - if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ - --json state,labels \ - --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then - echo "PR is open and labeled again. Skipping cleanup." - echo "removed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "removed=true" >> "$GITHUB_OUTPUT" - - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "No preview release exists. Nothing to clean up." - exit 0 - fi - - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ - | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ - | while read -r asset; do - gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ - || echo "Asset $asset was already removed by a concurrent run." - done - - - name: Mark the preview comment as removed - if: steps.delete.outputs.removed == 'true' - uses: actions/github-script@v8 - with: - script: | - const marker = ""; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - if (!existing) { - return; - } - - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: [ - marker, - "### macOS preview", - "", - "The preview download was removed because this PR closed or the preview label was removed.", - ].join("\n"), - }); + retention-days: 1 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 5d790e49a061f6d5ae159608fda2d6f783320e50..c374a8692db6c549d5368e1ed048872c9a54bc0a 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -8,6 +8,33 @@ name: Release desktop build on: workflow_call: + secrets: + CSC_LINK: + required: false + CSC_KEY_PASSWORD: + required: false + APPLE_API_KEY: + required: false + APPLE_API_KEY_ID: + required: false + APPLE_API_ISSUER: + required: false + MACOS_PROVISIONING_PROFILE: + required: false + AZURE_TENANT_ID: + required: false + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TRUSTED_SIGNING_ENDPOINT: + required: false + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: + required: false + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: + required: false + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: + required: false inputs: label: required: true @@ -46,6 +73,12 @@ on: release_channel: required: true type: string + # Whether a `relay-client-tracing-config` artifact from the production + # relay state is expected. PR previews carry no tracing config. + relay_client_tracing: + required: false + default: true + type: boolean clerk_publishable_key: required: true type: string @@ -73,17 +106,24 @@ jobs: T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ inputs.relay_url }} steps: + # This repository is public, so Git needs no credentials. checkout's + # credential cleanup runs submodule foreach even with submodules disabled, + # which fails on the orphaned gitlinks in our vendored .repos tree. - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + shell: bash + env: + CHECKOUT_REF: ${{ inputs.ref }} + GIT_TERMINAL_PROMPT: "0" + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git sparse-checkout set --no-cone '/*' '!/.repos/' + git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: ${{ inputs.platform != 'win' }} @@ -97,7 +137,7 @@ jobs: - name: Cache Windows packages if: inputs.platform == 'win' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.package_cache_path.outputs.path }} key: windows-release-packages-v1-${{ inputs.arch }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -106,7 +146,7 @@ jobs: # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata @@ -118,7 +158,7 @@ jobs: - name: Cache resource monitor id: resource_monitor_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} 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: - name: Cache Linux capture helpers if: inputs.platform == 'linux' id: capture_helper_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot @@ -135,17 +175,20 @@ jobs: - name: Setup Rust if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: + toolchain: stable targets: ${{ inputs.rust_target }} - name: Download relay client tracing config - uses: actions/download-artifact@v8 + if: inputs.relay_client_tracing + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing - name: Load relay client tracing config + if: inputs.relay_client_tracing shell: bash run: | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" @@ -160,7 +203,7 @@ jobs: # ancestor of its paths), so extracting into `apps` restores # apps/server/dist and apps/desktop/dist-electron at their build paths. - name: Download JS bundle - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: js-bundle path: apps @@ -169,7 +212,7 @@ jobs: # Windows desktop embeds the same-arch archive the release attaches. - name: Download Linux CLI archive for WSL if: inputs.platform == 'win' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: cli-linux-${{ inputs.arch }} path: wsl-runtime @@ -450,7 +493,7 @@ jobs: - name: Upload CLI archive if: inputs.cli_archive - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* @@ -514,14 +557,14 @@ jobs: cp "$source_path" "$target_dir/$binary_name" - name: Upload build artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* if-no-files-found: error - name: Upload resource monitor - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* diff --git a/docs/operations/release.md b/docs/operations/release.md index a143411c4bf41fc2751e7a7e6176800d7feeacdb..fe8a8e2081ad5a27c7b7407984ef3bf23c88ed24 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -50,6 +50,35 @@ This document covers the unified release workflow for stable and nightly desktop - nightly releases are aliased to the `nightly` hosted app channel - Signing is optional and auto-detected per platform from secrets. +## Pull request macOS previews + +Labeling a PR `preview:mac` publishes a signed, notarized Apple Silicon DMG with T3 Connect enabled +to the rolling `desktop-preview` prerelease, and works for fork PRs. The label is a one-shot request +for the commit it is applied to: the trusted workflow removes it once the build is in hand, and later +pushes do not build until a maintainer applies it again. Every signed preview is therefore a +per-commit maintainer decision, which matters because the result carries the Developer ID signature. +Vouching a contributor lets their labeled commits be signed; it is not a standing grant. The build is +split so the Developer ID certificate never shares a job with PR code: + +- `.github/workflows/desktop-macos-preview.yml` runs on `pull_request` with no secrets and builds + only the JS bundle from the PR (the same `js-bundle` artifact `release.yml` produces). +- `.github/workflows/desktop-macos-preview-publish.yml` runs on `workflow_run` from `main`. It + refuses unless the PR is open, still labeled, its head is the built commit, and the author is a + bot, a collaborator, or listed in `.github/VOUCHED.td` (read from the default branch, so a PR cannot vouch + for itself). It then packages and signs the bundle through `release-desktop.yml` checked out at + `main`, so packaging, native helpers, and the Electron/desktop dependencies come from `main`, not + the PR. Only the version and the public T3 Connect identifiers in `.env.example` are read from the + PR commit, as data, so the signed app's passkey entitlement matches the bundle. A PR that changes + packaging must use the `channel=preview` release train above instead. + +Before handing the bundle to the signing runner, the trusted workflow validates its ZIP entries +and accepts only regular files under `server/dist` and `desktop/dist-electron`, plus the directory +entries that lead to those roots. The artifact cannot +overwrite packaging code or installed dependencies. The bundle is copied into the app, never executed, +on the signing runner. The +`pull_request_target` cleanup job in the publish workflow removes the download when the PR closes, or +when the label is removed by hand before a build consumed it, and never checks out PR code. + ## Required release credentials Stable releases require these GitHub Actions secrets in addition to the platform and deployment -- 2.54.0