authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 21:24:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:24:20-07:00
logeb8f6f42a5cad1a5d0bc51eaa16cff13a9c69f45
tree6f21d22a04ac9bb10e95bf17ab6cc7512faee528
parent06de59b3d67e5be427ce4c7c8d41e91265709aec
signature Signed by PGP key B5690EEEBB952194

ci(release): build, sign, and publish self-contained CLI archives (#11317)

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

21 files changed, 1188 insertions(+), 55 deletions(-)

.github/workflows/release.yml+239-29
......@@ -5,6 +5,7 @@ on:
55 tags:
66 - "v*.*.*"
77 - "!v*-nightly.*"
8 - "!v*-preview.*"
89 schedule:
910 # Avoid minute zero, when GitHub scheduled jobs are busiest.
1011 - cron: "8,38 * * * *"
......@@ -18,6 +19,7 @@ on:
1819 options:
1920 - stable
2021 - nightly
22 - preview
2123 version:
2224 description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."
2325 required: false
......@@ -30,7 +32,7 @@ on:
3032# newest-wins single slot, so a queued stable tag can never be silently
3133# dropped. Automatic nightlies recheck the release gap after leaving the queue.
3234concurrency:
33 group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }}
35 group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly' || inputs.channel == 'preview') && 'nightly' || 'stable' }}
3436 cancel-in-progress: false
3537 queue: max
3638
......@@ -72,7 +74,7 @@ jobs:
7274 if (context.eventName === 'schedule') {
7375 core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));
7476 core.setOutput('ref', context.sha);
75 } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') {
77 } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly' && process.env.DISPATCH_CHANNEL !== 'preview') {
7678 const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });
7779 core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);
7880 core.setOutput('ref', sha);
......@@ -144,6 +146,26 @@ jobs:
144146 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
145147 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
146148 echo "make_latest=false" >> "$GITHUB_OUTPUT"
149 elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then
150 # Temporary channel for dogfooding the archive-based CLI runtime.
151 # Same versioning as nightly under its own prerelease identifier.
152 # A preview release is reachable only by downloading it by hand:
153 # it is never published to npm, its desktop builds carry no update
154 # feed, and no updater manifest is attached to the release, so
155 # neither stable nor nightly installs can ever be offered one.
156 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
157
158 node scripts/resolve-nightly-release.ts \
159 --channel preview \
160 --date "$nightly_date" \
161 --run-number "$NIGHTLY_RUN_NUMBER" \
162 --sha "$NIGHTLY_SHA" \
163 --github-output
164
165 echo "release_channel=preview" >> "$GITHUB_OUTPUT"
166 echo "cli_dist_tag=" >> "$GITHUB_OUTPUT"
167 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
168 echo "make_latest=false" >> "$GITHUB_OUTPUT"
147169 else
148170 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
149171 raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"
......@@ -403,6 +425,12 @@ jobs:
403425 fail-fast: false
404426 matrix:
405427 include:
428 # cli_archive: whether the job also builds the self-contained CLI
429 # archive. The executable is built on the runner's own Node, so only
430 # native runners qualify. macOS x64 has no native runner: a
431 # cross-built executable crashed under Rosetta in the smoke test and
432 # cannot be verified on real x64 hardware in CI, so it is skipped
433 # until it can be.
406434 - label: macOS arm64
407435 runner: blacksmith-12vcpu-macos-26
408436 platform: mac
......@@ -410,6 +438,7 @@ jobs:
410438 arch: arm64
411439 rust_target: aarch64-apple-darwin
412440 resource_key: darwin-arm64
441 cli_archive: true
413442 - label: macOS x64
414443 runner: blacksmith-12vcpu-macos-26
415444 platform: mac
......@@ -417,6 +446,7 @@ jobs:
417446 arch: x64
418447 rust_target: x86_64-apple-darwin
419448 resource_key: darwin-x64
449 cli_archive: false
420450 - label: Linux x64
421451 runner: blacksmith-32vcpu-ubuntu-2404
422452 platform: linux
......@@ -424,6 +454,7 @@ jobs:
424454 arch: x64
425455 rust_target: x86_64-unknown-linux-gnu
426456 resource_key: linux-x64
457 cli_archive: true
427458 - label: Windows x64
428459 runner: blacksmith-32vcpu-windows-2025
429460 platform: win
......@@ -431,6 +462,7 @@ jobs:
431462 arch: x64
432463 rust_target: x86_64-pc-windows-msvc
433464 resource_key: win32-x64
465 cli_archive: true
434466 # - label: Windows arm64
435467 # runner: windows-11-arm
436468 # platform: win
......@@ -710,6 +742,105 @@ jobs:
710742
711743 vp run dist:desktop:artifact "${args[@]}"
712744
745 # The single-executable is built with a Node that supports --build-sea
746 # (25.7+); the repo itself stays on the engines.node version. It always
747 # injects into the runner's own Node: tsdown's cross-target download path
748 # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a
749 # remote host, and a cross-built macOS binary cannot be smoke-tested.
750 - name: Build CLI single-executable
751 if: matrix.cli_archive
752 shell: bash
753 env:
754 # The exact version, not a major: vp downloads it from nodejs.org/dist on
755 # the runner, and only exact versions have a dist directory. Keep in
756 # step with SEA_NODE_VERSION in apps/server/vite.config.ts.
757 VP_NODE_VERSION: "26.8.2"
758 run: node apps/server/scripts/cli.ts build-exe --verbose
759
760 - name: Import macOS signing certificate for the CLI archive
761 if: matrix.cli_archive && matrix.platform == 'mac'
762 shell: bash
763 env:
764 CSC_LINK: ${{ secrets.CSC_LINK }}
765 CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
766 run: |
767 set -euo pipefail
768 if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then
769 echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc."
770 exit 0
771 fi
772 keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db"
773 keychain_password="$(openssl rand -hex 16)"
774 cert_path="$RUNNER_TEMP/t3-cli-signing.p12"
775 printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path"
776 security create-keychain -p "$keychain_password" "$keychain"
777 security set-keychain-settings -lut 21600 "$keychain"
778 security unlock-keychain -p "$keychain_password" "$keychain"
779 security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign
780 security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null
781 security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
782 identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)"
783 if [[ -z "$identity" ]]; then
784 echo "No Developer ID Application identity found in CSC_LINK." >&2
785 exit 1
786 fi
787 echo "::add-mask::$keychain_password"
788 echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV"
789 echo "macOS CLI signing enabled."
790
791 - name: Stage resource monitor for the CLI archive
792 if: matrix.cli_archive
793 shell: bash
794 run: |
795 set -euo pipefail
796 binary_name="t3-resource-monitor"
797 if [[ "${{ matrix.platform }}" == "win" ]]; then
798 binary_name="${binary_name}.exe"
799 fi
800 target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ matrix.resource_key }}"
801 mkdir -p "$target_dir"
802 cp "native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" "$target_dir/$binary_name"
803
804 - name: Build CLI archive
805 if: matrix.cli_archive
806 shell: bash
807 env:
808 APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
809 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
810 APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
811 AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
812 AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
813 AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
814 AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
815 AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
816 AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
817 run: |
818 set -euo pipefail
819 if [[ "${{ matrix.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then
820 key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8"
821 printf '%s' "$APPLE_API_KEY" > "$key_path"
822 export APPLE_API_KEY="$key_path"
823 fi
824 node scripts/build-cli-archive.ts \
825 --platform "${{ matrix.platform }}" \
826 --arch "${{ matrix.arch }}" \
827 --version "${{ needs.preflight.outputs.version }}" \
828 --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \
829 --output-dir release-cli
830
831 - name: Smoke-test CLI archive
832 if: matrix.cli_archive
833 shell: bash
834 run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}"
835
836 - name: Upload CLI archive
837 if: matrix.cli_archive
838 uses: actions/upload-artifact@v7
839 with:
840 name: cli-${{ matrix.platform }}-${{ matrix.arch }}
841 path: release-cli/*
842 if-no-files-found: error
843
713844 - name: Collect release assets
714845 shell: bash
715846 run: |
......@@ -717,13 +848,19 @@ jobs:
717848 mkdir -p release-publish
718849
719850 shopt -s nullglob
720 for pattern in \
721 "release/*.dmg" \
722 "release/*.zip" \
723 "release/*.AppImage" \
724 "release/*.exe" \
725 "release/*.blockmap" \
726 "release/*.yml"; do
851 patterns=(
852 "release/*.dmg"
853 "release/*.zip"
854 "release/*.AppImage"
855 "release/*.exe"
856 )
857 # Preview builds have no publish config, so electron-builder writes
858 # no feed manifest for them, but it still emits blockmaps beside the
859 # installers. Neither belongs on a release no updater may follow.
860 if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then
861 patterns+=("release/*.blockmap" "release/*.yml")
862 fi
863 for pattern in "${patterns[@]}"; do
727864 for file in $pattern; do
728865 cp "$file" release-publish/
729866 done
......@@ -775,10 +912,12 @@ jobs:
775912 path: resource-monitor-publish/${{ matrix.resource_key }}/*
776913 if-no-files-found: error
777914
915 # Preview releases never reach npm: the archive on the GitHub Release is the
916 # only way to obtain one, so no dist-tag can ever resolve to a preview build.
778917 publish_cli:
779918 name: Publish CLI to npm
780919 needs: [preflight, relay_public_config, quality, build]
781 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }}
920 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
782921 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
783922 timeout-minutes: 10
784923 permissions:
......@@ -856,7 +995,7 @@ jobs:
856995 release:
857996 name: Publish GitHub Release
858997 needs: [preflight, build, publish_cli]
859 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }}
998 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && (needs.publish_cli.result == 'success' || (needs.preflight.outputs.release_channel == 'preview' && needs.publish_cli.result == 'skipped')) }}
860999 runs-on: blacksmith-8vcpu-ubuntu-2404
8611000 timeout-minutes: 30
8621001 permissions:
......@@ -887,7 +1026,48 @@ jobs:
8871026 merge-multiple: true
8881027 path: release-assets
8891028
1029 - name: Download all CLI archives
1030 uses: actions/download-artifact@v8
1031 with:
1032 pattern: cli-*
1033 merge-multiple: true
1034 path: release-assets
1035
1036 # Installers verify archives against this file, so it is written from the
1037 # signed bytes that get uploaded, never from an earlier stage.
1038 - name: Write CLI archive checksums
1039 shell: bash
1040 run: |
1041 set -euo pipefail
1042 cd release-assets
1043 shopt -s nullglob
1044 archives=(t3-*.tar.gz t3-*.zip)
1045 if [[ ${#archives[@]} -eq 0 ]]; then
1046 echo "No CLI archives were produced." >&2
1047 exit 1
1048 fi
1049 sha256sum "${archives[@]}" > SHA256SUMS
1050 cat SHA256SUMS
1051
1052 # The desktop build omits the publish config for preview versions, so
1053 # electron-builder emits no updater manifests for them. Refuse to publish
1054 # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview
1055 # release is what would let a stable or nightly install update onto it.
1056 - name: Refuse updater metadata on preview releases
1057 if: needs.preflight.outputs.release_channel == 'preview'
1058 shell: bash
1059 run: |
1060 set -euo pipefail
1061 shopt -s nullglob extglob
1062 # builder-debug.yml is electron-builder's config dump, not a feed.
1063 updater_files=(release-assets/!(builder-debug).yml release-assets/*.blockmap)
1064 if [[ ${#updater_files[@]} -ne 0 ]]; then
1065 printf 'Preview releases must not carry updater metadata, found: %s\n' "${updater_files[*]}" >&2
1066 exit 1
1067 fi
1068
8901069 - name: Merge macOS updater manifests
1070 if: needs.preflight.outputs.release_channel != 'preview'
8911071 run: |
8921072 shopt -s nullglob
8931073 for x64_manifest in release-assets/*-mac-x64.yml; do
......@@ -898,6 +1078,45 @@ jobs:
8981078 fi
8991079 done
9001080
1081 # Updater manifests and blockmaps are what electron-updater consumes.
1082 # They are only listed for channels an updater is meant to follow.
1083 - id: release_files
1084 name: Resolve release asset list
1085 shell: bash
1086 run: |
1087 {
1088 echo 'files<<EOF'
1089 echo 'release-assets/*.dmg'
1090 echo 'release-assets/*.zip'
1091 echo 'release-assets/*.AppImage'
1092 echo 'release-assets/*.exe'
1093 if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then
1094 echo 'release-assets/*.blockmap'
1095 echo 'release-assets/*.yml'
1096 fi
1097 echo 'release-assets/*.tar.gz'
1098 echo 'release-assets/SHA256SUMS'
1099 echo 'EOF'
1100 } >> "$GITHUB_OUTPUT"
1101
1102 # A preview release gets a warning instead of generated notes. Generated
1103 # notes would list every commit since the previous preview, which is
1104 # unmerged branch history no one should read as a changelog, and would
1105 # make the release look like any other build to someone browsing the
1106 # releases page.
1107 - name: Write preview release notes
1108 if: needs.preflight.outputs.release_channel == 'preview'
1109 shell: bash
1110 run: |
1111 cat > release-notes.md <<'EOF'
1112 > [!WARNING]
1113 > **This is a preview build. Do not install it unless you know exactly why you are here.**
1114 >
1115 > Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, are never offered as updates, and are not supported. If you want T3 Code, install the [latest release](https://github.com/pingdotgg/t3code/releases/latest) or a nightly instead.
1116
1117 Built from `${{ needs.preflight.outputs.ref }}`.
1118 EOF
1119
9011120 - name: Publish release
9021121 if: needs.preflight.outputs.previous_tag != ''
9031122 uses: softprops/action-gh-release@v3
......@@ -905,17 +1124,12 @@ jobs:
9051124 tag_name: ${{ needs.preflight.outputs.tag }}
9061125 target_commitish: ${{ needs.preflight.outputs.ref }}
9071126 name: ${{ needs.preflight.outputs.release_name }}
908 generate_release_notes: true
1127 generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }}
1128 body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }}
9091129 previous_tag: ${{ needs.preflight.outputs.previous_tag }}
9101130 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
9111131 make_latest: ${{ needs.preflight.outputs.make_latest }}
912 files: |
913 release-assets/*.dmg
914 release-assets/*.zip
915 release-assets/*.AppImage
916 release-assets/*.exe
917 release-assets/*.blockmap
918 release-assets/*.yml
1132 files: ${{ steps.release_files.outputs.files }}
9191133 fail_on_unmatched_files: true
9201134 token: ${{ github.token }}
9211135
......@@ -926,23 +1140,18 @@ jobs:
9261140 tag_name: ${{ needs.preflight.outputs.tag }}
9271141 target_commitish: ${{ needs.preflight.outputs.ref }}
9281142 name: ${{ needs.preflight.outputs.release_name }}
929 generate_release_notes: true
1143 generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }}
1144 body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }}
9301145 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
9311146 make_latest: ${{ needs.preflight.outputs.make_latest }}
932 files: |
933 release-assets/*.dmg
934 release-assets/*.zip
935 release-assets/*.AppImage
936 release-assets/*.exe
937 release-assets/*.blockmap
938 release-assets/*.yml
1147 files: ${{ steps.release_files.outputs.files }}
9391148 fail_on_unmatched_files: true
9401149 token: ${{ github.token }}
9411150
9421151 publish_aur:
9431152 name: Publish AUR package
9441153 needs: [preflight, release]
945 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }}
1154 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
9461155 uses: ./.github/workflows/publish-aur.yml
9471156 with:
9481157 release_tag: ${{ needs.preflight.outputs.tag }}
......@@ -952,7 +1161,7 @@ jobs:
9521161 deploy_web:
9531162 name: Deploy hosted web app
9541163 needs: [preflight, relay_public_config, release]
955 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }}
1164 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
9561165 runs-on: blacksmith-8vcpu-ubuntu-2404
9571166 timeout-minutes: 10
9581167 env:
......@@ -1206,6 +1415,7 @@ jobs:
12061415 if: |
12071416 always() && !cancelled() &&
12081417 needs.preflight.result == 'success' &&
1418 needs.preflight.outputs.release_channel != 'preview' &&
12091419 needs.relay_public_config.result == 'success' &&
12101420 needs.release.result == 'success' &&
12111421 needs.deploy_web.result == 'success' &&
apps/desktop/src/updates/updateChannels.test.ts created+16
......@@ -0,0 +1,16 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isNightlyDesktopVersion, resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
4
5describe("updateChannels", () => {
6 it("keeps preview builds branded as nightly but on the latest update channel", () => {
7 expect(isNightlyDesktopVersion("0.0.41-preview.20260911.7")).toBe(true);
8 expect(resolveDefaultDesktopUpdateChannel("0.0.41-preview.20260911.7")).toBe("latest");
9 expect(resolveDefaultDesktopUpdateChannel("0.0.41-nightly.20260911.7")).toBe("nightly");
10 });
11
12 it("only matches the first prerelease identifier", () => {
13 expect(isNightlyDesktopVersion("1.2.3-foo-preview.20260911.1")).toBe(false);
14 expect(isNightlyDesktopVersion("1.2.3")).toBe(false);
15 });
16});
apps/desktop/src/updates/updateChannels.ts+9-3
......@@ -1,11 +1,17 @@
11import type { DesktopUpdateChannel } from "@t3tools/contracts";
22
3const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
3const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/;
4// Preview builds are a temporary dogfooding train cut from nightly. They share
5// nightly's branding but are packaged without an update feed (see
6// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the
7// channel a preview install reports is cosmetic: it never checks for updates
8// and no updater feed ever lists a preview release.
9const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/;
410
511export function isNightlyDesktopVersion(version: string): boolean {
6 return NIGHTLY_VERSION_PATTERN.test(version);
12 return PRERELEASE_VERSION_PATTERN.test(version);
713}
814
915export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
10 return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";
16 return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest";
1117}
apps/server/resources/cli-entitlements.plist created+18
......@@ -0,0 +1,18 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <!-- Same set Node.js applies to its own release binaries: V8 needs JIT
6 and executable memory, and the hardened runtime blocks both. -->
7 <key>com.apple.security.cs.allow-jit</key>
8 <true/>
9 <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
10 <true/>
11 <key>com.apple.security.cs.disable-executable-page-protection</key>
12 <true/>
13 <key>com.apple.security.cs.allow-dyld-environment-variables</key>
14 <true/>
15 <key>com.apple.security.cs.disable-library-validation</key>
16 <true/>
17</dict>
18</plist>
apps/server/scripts/cli.ts+14-1
......@@ -185,6 +185,12 @@ const buildExeCmd = Command.make(
185185 "build-exe",
186186 {
187187 verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)),
188 target: Flag.string("target").pipe(
189 Flag.withDescription(
190 "Cross-build for <platform>-<arch> in nodejs.org naming (for example darwin-x64); defaults to the host.",
191 ),
192 Flag.optional,
193 ),
188194 },
189195 (config) =>
190196 Effect.gen(function* () {
......@@ -198,7 +204,14 @@ const buildExeCmd = Command.make(
198204 yield* runCommand(
199205 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
200206 cwd: serverDir,
201 env: { ...process.env, T3CODE_PACK_EXE: "1" },
207 env: {
208 ...process.env,
209 T3CODE_PACK_EXE: "1",
210 ...Option.match(config.target, {
211 onNone: () => ({}),
212 onSome: (target) => ({ T3CODE_PACK_EXE_TARGET: target }),
213 }),
214 },
202215 stdout: config.verbose ? "inherit" : "ignore",
203216 stderr: "inherit",
204217 shell: spawnCommand.shell,
apps/server/src/cli/invocation.test.ts+3-1
......@@ -43,9 +43,11 @@ it("treats stable installs as direct invocations", () => {
4343 }
4444});
4545
46it("re-suggests the nightly channel only for nightly builds", () => {
46it("re-suggests the prerelease channel only for prerelease builds", () => {
4747 for (const [version, expected] of [
4848 ["0.0.31-nightly.20260729", "npx t3@nightly serve"],
49 ["0.0.31-preview.20260729.1", "npx t3@preview serve"],
50 ["0.0.31-foo-preview.20260729.1", "npx t3 serve"],
4951 ["0.0.31", "npx t3 serve"],
5052 ] as const) {
5153 assert.equal(
apps/server/src/cli/invocation.ts+2-1
......@@ -43,7 +43,8 @@ function detectCliRunner(entryPath: string): CliRunner | null {
4343 * anything else suggests the bare package.
4444 */
4545function suggestedPackageSpec(version: string): string {
46 return version.includes("-nightly.") ? "t3@nightly" : "t3";
46 const channel = /^[^-+]+-(nightly|preview)\./.exec(version)?.[1];
47 return channel === undefined ? "t3" : `t3@${channel}`;
4748}
4849
4950/**
apps/server/src/cli/triage.ts+2-2
......@@ -189,8 +189,8 @@ export const triageCommand = Command.make("triage", {
189189 buildTriageContext({
190190 generatedAt: DateTime.formatIso(now),
191191 version,
192 releaseTag: version.includes("-nightly.")
193 ? `v${version} (nightly build; if this tag does not exist, clone main)`
192 releaseTag: /^[^-+]+-(?:nightly|preview)\./.test(version)
193 ? `v${version} (prerelease build; if this tag does not exist, clone main)`
194194 : `v${version}`,
195195 os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`,
196196 nodeVersion: process.version,
apps/server/vite.config.ts+35-1
......@@ -21,13 +21,46 @@ import {
2121export { shouldBundleCliDependency };
2222
2323const repoEnv = loadRepoEnv();
24const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest";
24const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version)
25 ? "nightly"
26 : "latest";
2527
2628// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe
2729// step refuses multi-chunk output and counts the sourcemap as a chunk, and the
2830// executable needs a host Node that supports `--build-sea` (25.7+), so this is
2931// a separate mode rather than a second entry in the default build.
3032const packExecutable = process.env.T3CODE_PACK_EXE === "1";
33// `<platform>-<arch>` in nodejs.org naming (darwin-x64, linux-arm64, win-x64).
34// When set, tsdown injects the bundle into a downloaded Node of that target
35// instead of the host Node, which is how the arm64 macOS runner produces the
36// x64 archive. Cross-building is safe because the code cache is off.
37//
38// The Node inside the executable is pinned here rather than taken from the
39// build host, so every archive of a release embeds the same runtime no matter
40// which Node happens to run the build.
41const SEA_NODE_VERSION = "26.8.2";
42const SEA_TARGETS = {
43 "darwin-arm64": { platform: "darwin", arch: "arm64" },
44 "darwin-x64": { platform: "darwin", arch: "x64" },
45 "linux-arm64": { platform: "linux", arch: "arm64" },
46 "linux-x64": { platform: "linux", arch: "x64" },
47 "win-arm64": { platform: "win", arch: "arm64" },
48 "win-x64": { platform: "win", arch: "x64" },
49} as const;
50const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim();
51if (packExecutableTarget && !Object.hasOwn(SEA_TARGETS, packExecutableTarget)) {
52 throw new Error(
53 `T3CODE_PACK_EXE_TARGET must be one of ${Object.keys(SEA_TARGETS).join(", ")}, got "${packExecutableTarget}".`,
54 );
55}
56const packExecutableTargets = packExecutableTarget
57 ? [
58 {
59 ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS],
60 nodeVersion: SEA_NODE_VERSION,
61 },
62 ]
63 : undefined;
3164
3265export default mergeConfig(
3366 baseConfig,
......@@ -53,6 +86,7 @@ export default mergeConfig(
5386 exe: {
5487 fileName: "t3",
5588 outDir: "dist-exe",
89 ...(packExecutableTargets ? { targets: packExecutableTargets } : {}),
5690 // Node's SEA docs: `import()` does not work when useCodeCache is
5791 // true, and the server reaches several modules that way. The
5892 // cache is also platform-bound, so leaving it off keeps the
apps/web/src/branding.logic.ts+1-1
......@@ -1,4 +1,4 @@
1const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
1const NIGHTLY_SERVER_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/;
22
33export function formatAppDisplayName(input: {
44 readonly baseName: string;
docs/operations/release.md+7
......@@ -12,6 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop
1212 - push tag matching `v*.*.*` for a stable release of an explicit commit
1313 - scheduled nightly check every 30 minutes
1414 - manual `workflow_dispatch` with `channel=nightly`
15 - manual `workflow_dispatch` with `channel=preview`, a temporary train for dogfooding the archive-based CLI runtime. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes only a GitHub prerelease. A preview is reachable solely by downloading it from that release: it is not published to npm, its desktop builds are packaged without an update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so stable and nightly installs can never be offered one. The hosted web app, AUR, and Discord announcements are skipped. Remove the channel once archives are the default on nightly and stable.
1516- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.
1617 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep
1718 landing while you verify and never leak into the stable build.
......@@ -33,9 +34,15 @@ This document covers the unified release workflow for stable and nightly desktop
3334 - Nightly runs are always GitHub prereleases and never marked latest.
3435 - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag.
3536- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.
37- Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI.
38 - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the download every runtime installer will verify against `SHA256SUMS`.
39 - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`.
40 - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries.
41 - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded.
3642- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file:
3743 - stable releases publish npm dist-tag `latest`
3844 - nightly releases publish npm dist-tag `nightly`
45 - preview releases are not published to npm
3946- Deploys the hosted web app to Vercel only after a release is published:
4047 - stable releases are aliased to the `latest` hosted app channel
4148 - nightly releases are aliased to the `nightly` hosted app channel
scripts/build-cli-archive.ts created+553
......@@ -0,0 +1,553 @@
1#!/usr/bin/env node
2/**
3 * Packages the server single-executable into a self-contained per-platform
4 * archive: the `t3` binary, the web client, the resource monitor, and a
5 * production install of the native packages the bundle keeps external. The
6 * archive is the unit every runtime installer downloads, so nothing in it may
7 * require Node, npm, or a compiler on the machine that unpacks it.
8 *
9 * Layout inside the archive (a single top-level directory named after the
10 * archive stem):
11 *
12 * t3 | t3.exe the single-executable
13 * client/ web app served by the server
14 * resource-monitor/ per-platform Rust helper, same paths as the npm package
15 * node_modules/ runtime externals (node-pty, msgpackr-extract, fff)
16 */
17import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
18import * as NodeServices from "@effect/platform-node/NodeServices";
19import * as Config from "effect/Config";
20import * as Effect from "effect/Effect";
21import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";
23import * as Logger from "effect/Logger";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import * as Schema from "effect/Schema";
27import { Command, Flag } from "effect/unstable/cli";
28import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
29
30import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
31import { fromYaml } from "@t3tools/shared/schemaYaml";
32import { resolveSpawnCommand } from "@t3tools/shared/shell";
33import rootPackageJson from "../package.json" with { type: "json" };
34import serverPackageJson from "../apps/server/package.json" with { type: "json" };
35
36import {
37 createStagePatchedDependencies,
38 createStageWorkspaceConfig,
39 resolveFffNativeDependencies,
40 STAGE_INSTALL_ARGS,
41} from "./build-desktop-artifact.ts";
42import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts";
43import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
44
45const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);
46const BuildArch = Schema.Literals(["arm64", "x64"]);
47type BuildPlatform = typeof BuildPlatform.Type;
48type BuildArch = typeof BuildArch.Type;
49
50const WorkspaceConfig = Schema.Struct({
51 catalog: Schema.optional(Schema.Record(Schema.String, Schema.String)),
52 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
53 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
54 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
55});
56const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
57const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
58const StageWorkspaceConfig = Schema.Struct({
59 supportedArchitectures: Schema.Struct({
60 os: Schema.Array(Schema.String),
61 cpu: Schema.Array(Schema.String),
62 libc: Schema.optional(Schema.Array(Schema.String)),
63 }),
64 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
65 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
66 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
67 nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
68});
69const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
70
71const RepoRoot = Effect.service(Path.Path).pipe(
72 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
73);
74
75export class CliArchiveCommandFailedError extends Schema.TaggedError<CliArchiveCommandFailedError>()(
76 "CliArchiveCommandFailedError",
77 { command: Schema.String, exitCode: Schema.Int },
78) {
79 override get message(): string {
80 return `${this.command} exited with code ${this.exitCode}.`;
81 }
82}
83
84export class CliArchiveInputMissingError extends Schema.TaggedError<CliArchiveInputMissingError>()(
85 "CliArchiveInputMissingError",
86 { inputPath: Schema.String, hint: Schema.String },
87) {
88 override get message(): string {
89 return `Missing ${this.inputPath}. ${this.hint}`;
90 }
91}
92
93/** Platform/arch pair as it appears in archive names and `process.platform`/`process.arch`. */
94export function cliArchivePlatformKey(platform: BuildPlatform, arch: BuildArch): string {
95 const nodePlatform = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
96 return `${nodePlatform}-${arch}`;
97}
98
99export function cliArchiveStem(version: string, platform: BuildPlatform, arch: BuildArch): string {
100 return `t3-${version}-${cliArchivePlatformKey(platform, arch)}`;
101}
102
103export function cliArchiveFileName(version: string, platform: BuildPlatform, arch: BuildArch) {
104 // gzip rather than xz: GNU tar needs an external xz binary for -J, which
105 // minimal hosts lack, while every tar (and Node's zlib) handles gzip alone.
106 return `${cliArchiveStem(version, platform, arch)}.${platform === "win" ? "zip" : "tar.gz"}`;
107}
108
109/** The bsdtar Windows ships in System32; resolves regardless of which tar is first on PATH. */
110export function windowsSystemTar(): string {
111 const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
112 return `${systemRoot}\\System32\\tar.exe`;
113}
114
115const runCommand = Effect.fn("runCommand")(function* (
116 command: ChildProcess.Command,
117 label: string,
118) {
119 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
120 // Output is inherited so the build log shows what each tool did; a failing
121 // signing or packaging step is otherwise a bare exit code.
122 const child = yield* spawner.spawn(
123 ChildProcess.isStandardCommand(command)
124 ? ChildProcess.make(command.command, command.args, {
125 ...command.options,
126 stdout: "inherit",
127 stderr: "inherit",
128 })
129 : command,
130 );
131 const exitCode = Number(yield* child.exitCode);
132 if (exitCode !== 0) {
133 return yield* new CliArchiveCommandFailedError({ command: label, exitCode });
134 }
135});
136
137const requireInput = Effect.fn("requireInput")(function* (inputPath: string, hint: string) {
138 const fs = yield* FileSystem.FileSystem;
139 if (!(yield* fs.exists(inputPath))) {
140 return yield* new CliArchiveInputMissingError({ inputPath, hint });
141 }
142});
143
144/**
145 * Installs the runtime-external packages into `stageDir/node_modules` with a
146 * hoisted, symlink-free layout. The tree is archived and unpacked on machines
147 * without pnpm, so the store layout cannot be relied on to survive the trip.
148 */
149const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (input: {
150 readonly repoRoot: string;
151 readonly stageDir: string;
152 readonly platform: BuildPlatform;
153 readonly arch: BuildArch;
154 readonly version: string;
155}) {
156 const fs = yield* FileSystem.FileSystem;
157 const path = yield* Path.Path;
158 const workspace = yield* decodeWorkspaceConfig(
159 yield* fs.readFileString(path.join(input.repoRoot, "pnpm-workspace.yaml")),
160 );
161 const catalog = workspace.catalog ?? {};
162 const serverDependencies = resolveCatalogDependencies(
163 serverPackageJson.dependencies,
164 catalog,
165 "apps/server",
166 );
167 const fffNodeVersion = serverDependencies["@ff-labs/fff-node"];
168 if (fffNodeVersion === undefined) {
169 return yield* new CliArchiveInputMissingError({
170 inputPath: "apps/server/package.json#dependencies['@ff-labs/fff-node']",
171 hint: "The archive stages fff's platform binary from this version.",
172 });
173 }
174 const dependencies = {
175 ...selectCliRuntimeExternalDependencies(serverDependencies),
176 ...resolveFffNativeDependencies(input.platform, input.arch, fffNodeVersion),
177 };
178 const patchedDependencies = createStagePatchedDependencies(
179 workspace.patchedDependencies ?? {},
180 dependencies,
181 );
182
183 yield* fs.writeFileString(
184 path.join(input.stageDir, "package.json"),
185 `${yield* encodeJsonString({
186 name: "t3-runtime",
187 version: input.version,
188 private: true,
189 packageManager: rootPackageJson.packageManager,
190 dependencies,
191 })}\n`,
192 );
193 yield* fs.writeFileString(
194 path.join(input.stageDir, "pnpm-workspace.yaml"),
195 yield* encodeStageWorkspaceConfig({
196 ...createStageWorkspaceConfig({
197 platform: input.platform,
198 arch: input.arch,
199 ...(workspace.allowBuilds ? { allowBuilds: workspace.allowBuilds } : {}),
200 patchedDependencies,
201 overrides: resolveCatalogDependencies(workspace.overrides ?? {}, catalog, "apps/server"),
202 }),
203 nodeLinker: "hoisted",
204 }),
205 );
206 if (Object.keys(patchedDependencies).length > 0) {
207 yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(input.stageDir, "patches"));
208 }
209
210 const install = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]);
211 yield* runCommand(
212 ChildProcess.make(install.command, install.args, {
213 cwd: input.stageDir,
214 shell: install.shell,
215 stdout: "inherit",
216 stderr: "inherit",
217 }),
218 "vp install --prod (cli archive runtime externals)",
219 );
220
221 // pnpm's bookkeeping and the manifest only matter to pnpm; the runtime
222 // resolves packages by directory. node-pty ships every platform's prebuilds
223 // in one package (58 MB); only the archive's own platform loads.
224 const platformKey = cliArchivePlatformKey(input.platform, input.arch);
225 const prebuildsDir = path.join(input.stageDir, "node_modules/node-pty/prebuilds");
226 const foreignPrebuilds = (yield* fs
227 .readDirectory(prebuildsDir)
228 .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))).filter(
229 (entry) => entry !== platformKey,
230 );
231 for (const entry of [
232 "package.json",
233 "pnpm-workspace.yaml",
234 "pnpm-lock.yaml",
235 "patches",
236 "node_modules/.pnpm",
237 "node_modules/.modules.yaml",
238 "node_modules/.pnpm-workspace-state-v1.json",
239 "node_modules/.bin",
240 ...foreignPrebuilds.map((entry) => `node_modules/node-pty/prebuilds/${entry}`),
241 ]) {
242 yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true });
243 }
244});
245
246/** Copies the web client without its sourcemaps, which nothing serves. */
247const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, target: string) {
248 const fs = yield* FileSystem.FileSystem;
249 const path = yield* Path.Path;
250 yield* fs.copy(source, target);
251 const maps = (yield* fs.readDirectory(target, { recursive: true })).filter((entry) =>
252 entry.endsWith(".map"),
253 );
254 for (const entry of maps) {
255 yield* fs.remove(path.join(target, entry), { force: true });
256 }
257});
258
259const MacSigningConfig = Config.all({
260 identity: Config.string("T3CODE_CLI_MAC_SIGN_IDENTITY").pipe(Config.option),
261 appleApiKey: Config.string("APPLE_API_KEY").pipe(Config.option),
262 appleApiKeyId: Config.string("APPLE_API_KEY_ID").pipe(Config.option),
263 appleApiIssuer: Config.string("APPLE_API_ISSUER").pipe(Config.option),
264});
265
266/**
267 * Apple Silicon refuses to run unsigned Mach-O binaries at all, so the
268 * executable is always signed: ad hoc when no identity is configured, or with
269 * the Developer ID plus notarization when it is. The hardened runtime that
270 * notarization requires only loads signed libraries, so every native addon in
271 * the archive is signed with the same identity.
272 */
273const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (input: {
274 readonly repoRoot: string;
275 readonly contentDir: string;
276 readonly executablePath: string;
277}) {
278 const fs = yield* FileSystem.FileSystem;
279 const path = yield* Path.Path;
280 const signing = yield* MacSigningConfig;
281 const identity = Option.getOrUndefined(signing.identity)?.trim() || "-";
282
283 const entitlements = path.join(input.repoRoot, "apps/server/resources/cli-entitlements.plist");
284 const libraries = (yield* fs.readDirectory(input.contentDir, { recursive: true }))
285 .filter(
286 (entry) =>
287 entry.endsWith(".node") ||
288 entry.endsWith(".dylib") ||
289 entry.endsWith("spawn-helper") ||
290 entry.endsWith("t3-resource-monitor"),
291 )
292 .map((entry) => path.join(input.contentDir, entry));
293
294 for (const target of [...libraries, input.executablePath]) {
295 yield* runCommand(
296 ChildProcess.make("codesign", [
297 "--force",
298 "--sign",
299 identity,
300 ...(identity === "-" ? [] : ["--options", "runtime", "--timestamp"]),
301 ...(target === input.executablePath ? ["--entitlements", entitlements] : []),
302 target,
303 ]),
304 `codesign ${path.relative(input.contentDir, target)}`,
305 );
306 }
307 if (identity === "-") {
308 yield* Effect.log("[cli-archive] Signed ad hoc (no T3CODE_CLI_MAC_SIGN_IDENTITY).");
309 return;
310 }
311
312 const apiKey = Option.getOrUndefined(signing.appleApiKey);
313 const apiKeyId = Option.getOrUndefined(signing.appleApiKeyId);
314 const apiIssuer = Option.getOrUndefined(signing.appleApiIssuer);
315 if (!apiKey || !apiKeyId || !apiIssuer) {
316 yield* Effect.logWarning(
317 "[cli-archive] Developer ID signed but not notarized (missing APPLE_API_KEY*).",
318 );
319 return;
320 }
321 // notarytool only accepts archives, and a bare executable cannot be stapled,
322 // so notarize a zip of the binary and rely on the online ticket lookup.
323 const notarizeZip = path.join(path.dirname(input.executablePath), ".notarize-t3.zip");
324 yield* runCommand(
325 ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.executablePath, notarizeZip]),
326 "ditto (notarization zip)",
327 );
328 yield* runCommand(
329 ChildProcess.make("xcrun", [
330 "notarytool",
331 "submit",
332 notarizeZip,
333 "--key",
334 apiKey,
335 "--key-id",
336 apiKeyId,
337 "--issuer",
338 apiIssuer,
339 "--wait",
340 ]),
341 "notarytool submit",
342 ).pipe(Effect.ensuring(fs.remove(notarizeZip, { force: true }).pipe(Effect.ignore)));
343 yield* Effect.log("[cli-archive] Notarized t3.");
344});
345
346const WindowsSigningConfig = Config.all({
347 endpoint: Config.string("AZURE_TRUSTED_SIGNING_ENDPOINT").pipe(Config.option),
348 accountName: Config.string("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME").pipe(Config.option),
349 certificateProfileName: Config.string("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME").pipe(
350 Config.option,
351 ),
352});
353
354/**
355 * Node's --build-sea injects the blob into a copy of node.exe by rebuilding
356 * its resource section but, unlike its Mach-O path, leaves node's original
357 * Authenticode data-directory entry in the PE header. The file grows, so the
358 * entry now points into the middle of the new section at bytes that are not a
359 * certificate table. signtool refuses to sign such an image (0x800700C1, "not
360 * a valid Win32 application") and cannot `remove /s` it either, since the SIP
361 * fails to parse the garbage. Clearing the entry is exactly what a signature
362 * strip does, without needing a parser that trusts the broken table.
363 */
364const stripStaleAuthenticodeEntry = Effect.fn("stripStaleAuthenticodeEntry")(function* (
365 executablePath: string,
366) {
367 const fs = yield* FileSystem.FileSystem;
368 const bytes = yield* fs.readFile(executablePath);
369 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
370 const peOffset = view.getUint32(0x3c, true);
371 if (view.getUint32(peOffset, true) !== 0x00004550) {
372 return yield* new CliArchiveInputMissingError({
373 inputPath: executablePath,
374 hint: "Expected a PE executable to strip the stale signature entry from.",
375 });
376 }
377 const optionalHeader = peOffset + 24;
378 const magic = view.getUint16(optionalHeader, true);
379 // Data directories start at +112 (PE32+) or +96 (PE32); the certificate
380 // table is directory index 4, eight bytes (file offset, size).
381 const securityEntry = optionalHeader + (magic === 0x20b ? 112 : 96) + 4 * 8;
382 const offset = view.getUint32(securityEntry, true);
383 const size = view.getUint32(securityEntry + 4, true);
384 if (offset === 0 && size === 0) return;
385 if (offset + size === bytes.byteLength) {
386 // A certificate table that still ends at EOF is intact; leave it for
387 // signtool to replace rather than second-guessing it here.
388 return;
389 }
390 view.setUint32(securityEntry, 0, true);
391 view.setUint32(securityEntry + 4, 0, true);
392 yield* fs.writeFile(executablePath, bytes);
393 yield* Effect.log(
394 `[cli-archive] Cleared the stale Authenticode entry (offset ${String(offset)}, size ${String(size)}) left by --build-sea.`,
395 );
396});
397
398/** Signs t3.exe through the same Azure Trusted Signing setup the installer uses. */
399const signWindowsExecutable = Effect.fn("signWindowsExecutable")(function* (
400 executablePath: string,
401) {
402 const signing = yield* WindowsSigningConfig;
403 const endpoint = Option.getOrUndefined(signing.endpoint);
404 const accountName = Option.getOrUndefined(signing.accountName);
405 const profile = Option.getOrUndefined(signing.certificateProfileName);
406 if (!endpoint || !accountName || !profile) {
407 yield* Effect.log("[cli-archive] Windows signing disabled (missing Azure Trusted Signing).");
408 return;
409 }
410 yield* stripStaleAuthenticodeEntry(executablePath);
411 // Mirrors electron-builder's invocation for the installer: every value
412 // single-quoted, the file path in Windows form. `$ErrorActionPreference`
413 // makes a signing failure inside the cmdlet surface as a non-zero exit.
414 const quote = (value: string) => `'${value.replaceAll("'", "''")}'`;
415 const script = [
416 "$ErrorActionPreference = 'Stop';",
417 "Invoke-TrustedSigning",
418 `-Endpoint ${quote(endpoint)}`,
419 `-CodeSigningAccountName ${quote(accountName)}`,
420 `-CertificateProfileName ${quote(profile)}`,
421 "-FileDigest 'SHA256'",
422 "-TimestampRfc3161 'http://timestamp.acs.microsoft.com'",
423 "-TimestampDigest 'SHA256'",
424 `-Files ${quote(executablePath)}`,
425 ].join(" ");
426 yield* runCommand(
427 ChildProcess.make("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script]),
428 "Invoke-TrustedSigning t3.exe",
429 );
430 yield* Effect.log("[cli-archive] Signed t3.exe (Azure Trusted Signing).");
431});
432
433const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: {
434 readonly platform: BuildPlatform;
435 readonly arch: BuildArch;
436 readonly version: string;
437 readonly outputDir: string;
438 readonly resourceMonitorDir: Option.Option<string>;
439}) {
440 const fs = yield* FileSystem.FileSystem;
441 const path = yield* Path.Path;
442 const repoRoot = yield* RepoRoot;
443 const serverDir = path.join(repoRoot, "apps/server");
444 const executableName = input.platform === "win" ? "t3.exe" : "t3";
445 // tsdown suffixes cross-built executables with their target (t3-darwin-x64);
446 // a host build is plain t3. Prefer the exact target when both exist.
447 const targetKey = `${input.platform === "mac" ? "darwin" : input.platform}-${input.arch}`;
448 const targetExecutable = path.join(
449 serverDir,
450 "dist-exe",
451 `t3-${targetKey}${input.platform === "win" ? ".exe" : ""}`,
452 );
453 // The unsuffixed host build is only a valid stand-in when it was built for
454 // this platform and architecture; otherwise a missing target must fail.
455 const hostPlatform = yield* HostProcessPlatform;
456 const hostKey = `${hostPlatform === "win32" ? "win" : hostPlatform}-${yield* HostProcessArchitecture}`;
457 const builtExecutable = (yield* fs.exists(targetExecutable))
458 ? targetExecutable
459 : targetKey === hostKey
460 ? path.join(serverDir, "dist-exe", executableName)
461 : targetExecutable;
462 const webClient = path.join(serverDir, "dist/client");
463 const resourceMonitorDir = Option.getOrElse(input.resourceMonitorDir, () =>
464 path.join(serverDir, "dist/resource-monitor"),
465 );
466
467 yield* requireInput(
468 builtExecutable,
469 `Run \`node apps/server/scripts/cli.ts build-exe --target ${targetKey}\` first.`,
470 );
471 yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first.");
472 yield* requireInput(
473 resourceMonitorDir,
474 "Build the resource monitor or pass --resource-monitor-dir.",
475 );
476
477 const stem = cliArchiveStem(input.version, input.platform, input.arch);
478 const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-archive-" });
479 const contentDir = path.join(stageRoot, stem);
480 yield* fs.makeDirectory(contentDir, { recursive: true });
481
482 yield* Effect.log(`[cli-archive] Staging ${stem}...`);
483 yield* fs.copyFile(builtExecutable, path.join(contentDir, executableName));
484 yield* stageWebClient(webClient, path.join(contentDir, "client"));
485 yield* fs.copy(resourceMonitorDir, path.join(contentDir, "resource-monitor"));
486 yield* stageRuntimeExternals({
487 repoRoot,
488 stageDir: contentDir,
489 platform: input.platform,
490 arch: input.arch,
491 version: input.version,
492 });
493
494 const executablePath = path.join(contentDir, executableName);
495 if (input.platform === "mac") {
496 yield* signMacArchiveContents({ repoRoot, contentDir, executablePath });
497 } else if (input.platform === "win") {
498 yield* signWindowsExecutable(executablePath);
499 }
500 if (input.platform !== "win") {
501 yield* fs.chmod(executablePath, 0o755);
502 }
503
504 yield* fs.makeDirectory(input.outputDir, { recursive: true });
505 const archivePath = path.join(
506 input.outputDir,
507 cliArchiveFileName(input.version, input.platform, input.arch),
508 );
509 yield* fs.remove(archivePath, { force: true });
510 if (input.platform === "win") {
511 // Windows ships bsdtar, which writes zip natively. Name it by path: under
512 // the Git Bash shell CI uses, a bare `tar` is GNU tar, which neither
513 // writes zip nor accepts a drive-letter path.
514 yield* runCommand(
515 ChildProcess.make(windowsSystemTar(), ["-a", "-c", "-f", archivePath, "-C", stageRoot, stem]),
516 "tar (zip)",
517 );
518 } else {
519 yield* runCommand(
520 ChildProcess.make("tar", ["-czf", archivePath, "-C", stageRoot, stem]),
521 "tar (gzip)",
522 );
523 }
524 const stat = yield* fs.stat(archivePath);
525 yield* Effect.log(`[cli-archive] Wrote ${archivePath} (${String(stat.size)} bytes).`);
526 return archivePath;
527});
528
529const command = Command.make(
530 "build-cli-archive",
531 {
532 platform: Flag.choice("platform", BuildPlatform.literals),
533 arch: Flag.choice("arch", BuildArch.literals),
534 version: Flag.string("version").pipe(
535 Flag.withDescription("Release version for the archive name."),
536 ),
537 outputDir: Flag.string("output-dir").pipe(Flag.withDefault("release-cli")),
538 resourceMonitorDir: Flag.string("resource-monitor-dir").pipe(
539 Flag.withDescription(
540 "Directory laid out like dist/resource-monitor (defaults to apps/server/dist/resource-monitor).",
541 ),
542 Flag.optional,
543 ),
544 },
545 (input) => buildCliArchive(input).pipe(Effect.scoped),
546).pipe(Command.withDescription("Package the t3 single-executable into a per-platform archive."));
547
548if (import.meta.main) {
549 Command.run(command, { version: "0.0.0" }).pipe(
550 Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)),
551 NodeRuntime.runMain,
552 );
553}
scripts/build-desktop-artifact.test.ts+11
......@@ -350,7 +350,18 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
350350 undefined,
351351 );
352352
353 const previewChannel = yield* createBuildConfig(
354 "mac",
355 "dmg",
356 "0.0.41-preview.20260912.1589",
357 false,
358 false,
359 undefined,
360 undefined,
361 );
362
353363 assert.notProperty(preview, "publish");
364 assert.notProperty(previewChannel, "publish");
354365 assert.deepStrictEqual(release.publish, [
355366 {
356367 provider: "github",
scripts/build-desktop-artifact.ts+9-2
......@@ -2616,8 +2616,15 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl
26162616 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";
26172617}
26182618
2619function isDesktopPreviewVersion(version: string): boolean {
2620 return /-pr\./.test(version);
2619// Pull request builds (`-pr.<n>.`) and the temporary preview train
2620// (`-preview.<date>.<run>`) are downloaded by hand and never through an
2621// updater. Building them without a publish config means electron-builder
2622// emits no `latest*.yml`/`nightly*.yml` manifests or blockmaps for them and
2623// the app ships without `app-update.yml`, so neither a stable nor a nightly
2624// install can be pointed at one of these releases, and the build itself
2625// reports that no update feed is configured instead of polling.
2626export function isDesktopPreviewVersion(version: string): boolean {
2627 return /-pr\./.test(version) || /-preview\.\d{8}\.\d+$/.test(version);
26212628}
26222629
26232630export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand {
scripts/lib/brand-assets.test.ts+1
......@@ -81,6 +81,7 @@ describe("brand-assets", () => {
8181 it("maps package versions to web asset brands", () => {
8282 expect(resolveWebAssetBrandForPackageVersion("0.0.29")).toBe("production");
8383 expect(resolveWebAssetBrandForPackageVersion("0.0.29-nightly.20260723.882")).toBe("nightly");
84 expect(resolveWebAssetBrandForPackageVersion("0.0.29-preview.20260723.882")).toBe("nightly");
8485 });
8586
8687 it("keeps development, nightly, and production icon families separate", () => {
scripts/lib/brand-assets.ts+1-1
......@@ -42,7 +42,7 @@ export function resolveWebAssetBrandForChannel(channel: WebAssetChannel): WebAss
4242}
4343
4444export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand {
45 return version.includes("-nightly.") ? "nightly" : "production";
45 return /^[^-+]+-(?:nightly|preview)\./.test(version) ? "nightly" : "production";
4646}
4747
4848export interface IconOverride {
scripts/resolve-nightly-release.test.ts+13
......@@ -52,6 +52,19 @@ it("derives nightly metadata including the short commit sha in the release name"
5252 );
5353});
5454
55it("derives preview metadata under its own prerelease identifier", () => {
56 assert.deepStrictEqual(
57 resolveNightlyReleaseMetadata("9.9.10", "20260413", 321, "abcdef1234567890", "preview"),
58 {
59 baseVersion: "9.9.10",
60 version: "9.9.10-preview.20260413.321",
61 tag: "v9.9.10-preview.20260413.321",
62 name: "T3 Code Preview (maintainer test build, do not install) 9.9.10-preview.20260413.321 (abcdef123456)",
63 shortSha: "abcdef123456",
64 },
65 );
66});
67
5568it.effect("preserves the GITHUB_OUTPUT configuration cause", () => {
5669 const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890");
5770 const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" });
scripts/resolve-nightly-release.ts+23-4
......@@ -96,19 +96,32 @@ export const resolveNightlyTargetVersion = (version: string) => {
9696 return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`);
9797};
9898
99/** Prerelease trains that share nightly's date-and-run versioning. */
100export const PrereleaseChannel = Schema.Literals(["nightly", "preview"]);
101export type PrereleaseChannel = typeof PrereleaseChannel.Type;
102
103// The preview label is deliberately loud: the releases page is the one place
104// a preview build can be found, and its name is the first thing a visitor
105// reads before the warning in the body.
106const CHANNEL_RELEASE_LABELS: Record<PrereleaseChannel, string> = {
107 nightly: "Nightly",
108 preview: "Preview (maintainer test build, do not install)",
109};
110
99111export const resolveNightlyReleaseMetadata = (
100112 baseVersion: string,
101113 date: string,
102114 runNumber: number,
103115 sha: string,
116 channel: PrereleaseChannel = "nightly",
104117) => {
105118 const shortSha = sha.slice(0, 12);
106 const version = `${baseVersion}-nightly.${date}.${runNumber}`;
119 const version = `${baseVersion}-${channel}.${date}.${runNumber}`;
107120 return {
108121 baseVersion,
109122 version,
110123 tag: `v${version}`,
111 name: `T3 Code Nightly ${version} (${shortSha})`,
124 name: `T3 Code ${CHANNEL_RELEASE_LABELS[channel]} ${version} (${shortSha})`,
112125 shortSha,
113126 };
114127};
......@@ -198,6 +211,10 @@ const command = Command.make(
198211 Flag.withSchema(ShaSchema),
199212 Flag.withDescription("Commit sha for the nightly build."),
200213 ),
214 channel: Flag.choice("channel", PrereleaseChannel.literals).pipe(
215 Flag.withDescription("Prerelease channel whose identifier the version carries."),
216 Flag.withDefault("nightly" as const),
217 ),
201218 githubOutput: Flag.boolean("github-output").pipe(
202219 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
203220 Flag.withDefault(false),
......@@ -207,9 +224,11 @@ const command = Command.make(
207224 Flag.optional,
208225 ),
209226 },
210 ({ date, runNumber, sha, githubOutput, root }) =>
227 ({ date, runNumber, sha, channel, githubOutput, root }) =>
211228 readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(
212 Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)),
229 Effect.map((baseVersion) =>
230 resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha, channel),
231 ),
213232 Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)),
214233 ),
215234).pipe(Command.withDescription("Resolve nightly release version metadata."));
scripts/resolve-previous-release-tag.test.ts+17
......@@ -65,6 +65,23 @@ it.effect("accepts legacy nightly tags when selecting the previous nightly", ()
6565 }),
6666);
6767
68it.effect("keeps preview tags in their own series", () =>
69 Effect.gen(function* () {
70 const previous = yield* resolvePreviousReleaseTag("preview", "v1.2.0-preview.20260620.2", [
71 "v1.2.0-nightly.20260620.3",
72 "v1.2.0-preview.20260620.1",
73 "v1.1.9",
74 ]);
75 assert.equal(previous, "v1.2.0-preview.20260620.1");
76
77 const stable = yield* resolvePreviousReleaseTag("stable", "v1.2.0", [
78 "v1.1.9",
79 "v1.2.0-preview.20260620.1",
80 ]);
81 assert.equal(stable, "v1.1.9");
82 }),
83);
84
6885it.effect("reports the invalid tag with its release channel", () =>
6986 Effect.gen(function* () {
7087 const error = yield* resolvePreviousReleaseTag("nightly", "v1.2.0", []).pipe(Effect.flip);
scripts/resolve-previous-release-tag.ts+16-9
......@@ -11,7 +11,7 @@ import * as String from "effect/String";
1111import { Command, Flag } from "effect/unstable/cli";
1212import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1313
14const ReleaseChannel = Schema.Literals(["stable", "nightly"]);
14const ReleaseChannel = Schema.Literals(["stable", "nightly", "preview"]);
1515type ReleaseChannel = typeof ReleaseChannel.Type;
1616
1717export class InvalidReleaseTagError extends Schema.TaggedError<InvalidReleaseTagError>()(
......@@ -151,10 +151,12 @@ const parseStableTag = (tag: string): StableVersion | undefined => {
151151 if (!major || !minor || !patch) return undefined;
152152
153153 const prereleaseIdentifiers = prerelease ? prerelease.split(".") : [];
154 // Nightly tags also start with `v` and carry a `nightly.*` prerelease
155 // identifier. They must not be considered stable candidates when resolving
156 // the previous stable tag.
157 if (prereleaseIdentifiers[0] === "nightly") return undefined;
154 // Nightly and preview tags also start with `v` and carry their channel as
155 // the prerelease identifier. They must not be considered stable candidates
156 // when resolving the previous stable tag.
157 if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "preview") {
158 return undefined;
159 }
158160
159161 return {
160162 major: Number(major),
......@@ -172,10 +174,15 @@ const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): nu
172174 return left.runNumber - right.runNumber;
173175};
174176
175const parseNightlyTag = (tag: string): NightlyVersion | undefined => {
177const parseNightlyTag = (
178 tag: string,
179 channel: "nightly" | "preview" = "nightly",
180): NightlyVersion | undefined => {
176181 // Accept both the current `v<semver>` format and the legacy `nightly-v<semver>`
177182 // format so release note diffs keep working across the tag-format transition.
178 const match = /^(?:nightly-)?v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag);
183 const match = new RegExp(
184 `^(?:nightly-)?v(\\d+)\\.(\\d+)\\.(\\d+)-${channel}\\.(\\d{8})\\.(\\d+)$`,
185 ).exec(tag);
179186 if (!match) return undefined;
180187
181188 const [, major, minor, patch, date, runNumber] = match;
......@@ -213,13 +220,13 @@ export const resolvePreviousReleaseTag = (
213220 return candidates[0]?.tag;
214221 }
215222
216 const current = parseNightlyTag(currentTag);
223 const current = parseNightlyTag(currentTag, channel);
217224 if (!current) {
218225 return yield* new InvalidReleaseTagError({ channel, currentTag });
219226 }
220227
221228 const candidates = tags
222 .map((tag) => ({ tag, parsed: parseNightlyTag(tag) }))
229 .map((tag) => ({ tag, parsed: parseNightlyTag(tag, channel) }))
223230 .filter(
224231 (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined,
225232 )
scripts/smoke-cli-archive.ts created+198
......@@ -0,0 +1,198 @@
1#!/usr/bin/env node
2/**
3 * Unpacks a CLI archive into a scratch directory and runs the executable the
4 * way an installer would: no repo, no node_modules, no Node on PATH. Catches
5 * the failures that only show inside the single-executable, such as an
6 * external package reached through `import` or a native addon the hardened
7 * runtime refuses to load.
8 */
9import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Logger from "effect/Logger";
17import * as Path from "effect/Path";
18import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";
20import { Command, Flag } from "effect/unstable/cli";
21import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
22
23import * as NetService from "@t3tools/shared/Net";
24import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
25import { windowsSystemTar } from "./build-cli-archive.ts";
26import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
27
28export class CliArchiveSmokeError extends Schema.TaggedError<CliArchiveSmokeError>()(
29 "CliArchiveSmokeError",
30 { step: Schema.String, detail: Schema.String },
31) {
32 override get message(): string {
33 return `CLI archive smoke test failed while ${this.step}: ${this.detail}`;
34 }
35}
36
37const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
38 stream.pipe(
39 Stream.decodeText(),
40 Stream.runFold(
41 () => "",
42 (acc, chunk) => acc + chunk,
43 ),
44 );
45
46const runExecutable = Effect.fn("runExecutable")(function* (
47 executable: string,
48 args: ReadonlyArray<string>,
49 cwd: string,
50) {
51 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
52 const child = yield* spawner.spawn(
53 ChildProcess.make(executable, args, {
54 cwd,
55 // Empty PATH: the archive must not reach a system node, and the
56 // launcher context must not leak in from a developer shell.
57 env: { PATH: "", HOME: cwd, USERPROFILE: cwd, TMPDIR: cwd, TEMP: cwd },
58 extendEnv: false,
59 }),
60 );
61 const [stdout, stderr, exitCode] = yield* Effect.all(
62 [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))],
63 { concurrency: "unbounded" },
64 );
65 return { stdout, stderr, exitCode };
66});
67
68const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: {
69 readonly archive: string;
70 readonly expectVersion: string;
71}) {
72 const fs = yield* FileSystem.FileSystem;
73 const path = yield* Path.Path;
74 const platform = yield* HostProcessPlatform;
75 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
76 const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-smoke-" });
77
78 // On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU
79 // tar; use the bsdtar Windows ships, which reads both formats.
80 const tar = platform === "win32" ? windowsSystemTar() : "tar";
81 const extract = yield* spawner
82 .spawn(ChildProcess.make(tar, ["-xf", input.archive, "-C", scratch]))
83 .pipe(Effect.flatMap((child) => child.exitCode));
84 if (Number(extract) !== 0) {
85 return yield* new CliArchiveSmokeError({
86 step: "extracting the archive",
87 detail: `tar exited with ${String(extract)}`,
88 });
89 }
90 const [root] = yield* fs.readDirectory(scratch);
91 if (root === undefined) {
92 return yield* new CliArchiveSmokeError({
93 step: "extracting the archive",
94 detail: "the archive was empty",
95 });
96 }
97 const contentDir = path.join(scratch, root);
98 const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3");
99 for (const required of [executable, path.join(contentDir, "client/index.html")]) {
100 if (!(yield* fs.exists(required))) {
101 return yield* new CliArchiveSmokeError({
102 step: "checking the archive layout",
103 detail: `missing ${path.relative(contentDir, required)}`,
104 });
105 }
106 }
107
108 const version = yield* runExecutable(executable, ["--version"], contentDir);
109 if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) {
110 return yield* new CliArchiveSmokeError({
111 step: "running --version",
112 detail: `exit ${String(version.exitCode)}\n${version.stdout}${version.stderr}`,
113 });
114 }
115
116 // Starting the server is what actually opens sqlite, loads the terminal
117 // and search stacks (node-pty, fff, msgpackr-extract), and serves the
118 // client, so probe a real `serve` in a scratch home rather than a
119 // command that only reads package metadata.
120 const net = yield* NetService.NetService;
121 const port = yield* net.findAvailablePort(47700);
122 const home = path.join(scratch, "home");
123 const server = yield* spawner.spawn(
124 ChildProcess.make(
125 executable,
126 ["serve", "--host", "127.0.0.1", "--port", String(port), "--no-browser"],
127 {
128 cwd: contentDir,
129 env: {
130 PATH: "",
131 HOME: home,
132 USERPROFILE: home,
133 TMPDIR: scratch,
134 TEMP: scratch,
135 T3CODE_HOME: home,
136 },
137 extendEnv: false,
138 },
139 ),
140 );
141 const output = yield* Effect.forkScoped(
142 Effect.all([collect(server.stdout), collect(server.stderr)]),
143 );
144 const httpClient = yield* HttpClient.HttpClient;
145 // A request that connects while the server is still initializing can hang,
146 // so each probe gets its own deadline, like the SSH readiness probe.
147 const probe = httpClient.execute(HttpClientRequest.get(`http://127.0.0.1:${String(port)}/`)).pipe(
148 Effect.map((response) => response.status === 200),
149 Effect.timeout(Duration.seconds(2)),
150 Effect.orElseSucceed(() => false),
151 );
152 const pollUntilReady = Effect.gen(function* () {
153 while (!(yield* probe)) {
154 yield* Effect.sleep(Duration.millis(250));
155 }
156 return true;
157 });
158 const ready = yield* pollUntilReady.pipe(
159 Effect.timeout(Duration.seconds(30)),
160 Effect.orElseSucceed(() => false),
161 );
162 yield* server.kill({ killSignal: "SIGTERM" }).pipe(Effect.ignore);
163 yield* server.exitCode.pipe(Effect.timeout(Duration.seconds(10)), Effect.ignore);
164 const [stdout, stderr] = yield* Fiber.join(output).pipe(
165 Effect.timeout(Duration.seconds(5)),
166 Effect.orElseSucceed(() => ["", ""] as const),
167 );
168 if (!ready) {
169 return yield* new CliArchiveSmokeError({
170 step: "serving from the extracted archive",
171 detail: `no 200 from / within 30s\n${stdout}${stderr}`,
172 });
173 }
174 yield* Effect.log(`[cli-smoke] ${root}: --version passed and serve answered on ${String(port)}.`);
175});
176
177const command = Command.make(
178 "smoke-cli-archive",
179 {
180 archive: Flag.string("archive"),
181 expectVersion: Flag.string("expect-version"),
182 },
183 (input) => smokeCliArchive(input).pipe(Effect.scoped),
184).pipe(Command.withDescription("Extract a CLI archive and run its executable."));
185
186if (import.meta.main) {
187 Command.run(command, { version: "0.0.0" }).pipe(
188 Effect.provide(
189 Layer.mergeAll(
190 Logger.layer([Logger.consolePretty()]),
191 NodeServices.layer,
192 NetService.layer,
193 FetchHttpClient.layer,
194 ),
195 ),
196 NodeRuntime.runMain,
197 );
198}