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:...@@ -5,6 +5,7 @@ on:
5 tags:5 tags:
6 - "v*.*.*"6 - "v*.*.*"
7 - "!v*-nightly.*"7 - "!v*-nightly.*"
8 - "!v*-preview.*"
8 schedule:9 schedule:
9 # Avoid minute zero, when GitHub scheduled jobs are busiest.10 # Avoid minute zero, when GitHub scheduled jobs are busiest.
10 - cron: "8,38 * * * *"11 - cron: "8,38 * * * *"
...@@ -18,6 +19,7 @@ on:...@@ -18,6 +19,7 @@ on:
18 options:19 options:
19 - stable20 - stable
20 - nightly21 - nightly
22 - preview
21 version:23 version:
22 description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."24 description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed."
23 required: false25 required: false
...@@ -30,7 +32,7 @@ on:...@@ -30,7 +32,7 @@ on:
30# newest-wins single slot, so a queued stable tag can never be silently32# newest-wins single slot, so a queued stable tag can never be silently
31# dropped. Automatic nightlies recheck the release gap after leaving the queue.33# dropped. Automatic nightlies recheck the release gap after leaving the queue.
32concurrency:34concurrency:
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' }}
34 cancel-in-progress: false36 cancel-in-progress: false
35 queue: max37 queue: max
3638
...@@ -72,7 +74,7 @@ jobs:...@@ -72,7 +74,7 @@ jobs:
72 if (context.eventName === 'schedule') {74 if (context.eventName === 'schedule') {
73 core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));75 core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core }));
74 core.setOutput('ref', context.sha);76 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') {
76 const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });78 const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core });
77 core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);79 core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`);
78 core.setOutput('ref', sha);80 core.setOutput('ref', sha);
...@@ -144,6 +146,26 @@ jobs:...@@ -144,6 +146,26 @@ jobs:
144 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"146 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
145 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"147 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
146 echo "make_latest=false" >> "$GITHUB_OUTPUT"148 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"
147 else169 else
148 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then170 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
149 raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"171 raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"
...@@ -403,6 +425,12 @@ jobs:...@@ -403,6 +425,12 @@ jobs:
403 fail-fast: false425 fail-fast: false
404 matrix:426 matrix:
405 include:427 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.
406 - label: macOS arm64434 - label: macOS arm64
407 runner: blacksmith-12vcpu-macos-26435 runner: blacksmith-12vcpu-macos-26
408 platform: mac436 platform: mac
...@@ -410,6 +438,7 @@ jobs:...@@ -410,6 +438,7 @@ jobs:
410 arch: arm64438 arch: arm64
411 rust_target: aarch64-apple-darwin439 rust_target: aarch64-apple-darwin
412 resource_key: darwin-arm64440 resource_key: darwin-arm64
441 cli_archive: true
413 - label: macOS x64442 - label: macOS x64
414 runner: blacksmith-12vcpu-macos-26443 runner: blacksmith-12vcpu-macos-26
415 platform: mac444 platform: mac
...@@ -417,6 +446,7 @@ jobs:...@@ -417,6 +446,7 @@ jobs:
417 arch: x64446 arch: x64
418 rust_target: x86_64-apple-darwin447 rust_target: x86_64-apple-darwin
419 resource_key: darwin-x64448 resource_key: darwin-x64
449 cli_archive: false
420 - label: Linux x64450 - label: Linux x64
421 runner: blacksmith-32vcpu-ubuntu-2404451 runner: blacksmith-32vcpu-ubuntu-2404
422 platform: linux452 platform: linux
...@@ -424,6 +454,7 @@ jobs:...@@ -424,6 +454,7 @@ jobs:
424 arch: x64454 arch: x64
425 rust_target: x86_64-unknown-linux-gnu455 rust_target: x86_64-unknown-linux-gnu
426 resource_key: linux-x64456 resource_key: linux-x64
457 cli_archive: true
427 - label: Windows x64458 - label: Windows x64
428 runner: blacksmith-32vcpu-windows-2025459 runner: blacksmith-32vcpu-windows-2025
429 platform: win460 platform: win
...@@ -431,6 +462,7 @@ jobs:...@@ -431,6 +462,7 @@ jobs:
431 arch: x64462 arch: x64
432 rust_target: x86_64-pc-windows-msvc463 rust_target: x86_64-pc-windows-msvc
433 resource_key: win32-x64464 resource_key: win32-x64
465 cli_archive: true
434 # - label: Windows arm64466 # - label: Windows arm64
435 # runner: windows-11-arm467 # runner: windows-11-arm
436 # platform: win468 # platform: win
...@@ -710,6 +742,105 @@ jobs:...@@ -710,6 +742,105 @@ jobs:
710742
711 vp run dist:desktop:artifact "${args[@]}"743 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
713 - name: Collect release assets844 - name: Collect release assets
714 shell: bash845 shell: bash
715 run: |846 run: |
...@@ -717,13 +848,19 @@ jobs:...@@ -717,13 +848,19 @@ jobs:
717 mkdir -p release-publish848 mkdir -p release-publish
718849
719 shopt -s nullglob850 shopt -s nullglob
720 for pattern in \851 patterns=(
721 "release/*.dmg" \852 "release/*.dmg"
722 "release/*.zip" \853 "release/*.zip"
723 "release/*.AppImage" \854 "release/*.AppImage"
724 "release/*.exe" \855 "release/*.exe"
725 "release/*.blockmap" \856 )
726 "release/*.yml"; do857 # 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
727 for file in $pattern; do864 for file in $pattern; do
728 cp "$file" release-publish/865 cp "$file" release-publish/
729 done866 done
...@@ -775,10 +912,12 @@ jobs:...@@ -775,10 +912,12 @@ jobs:
775 path: resource-monitor-publish/${{ matrix.resource_key }}/*912 path: resource-monitor-publish/${{ matrix.resource_key }}/*
776 if-no-files-found: error913 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.
778 publish_cli:917 publish_cli:
779 name: Publish CLI to npm918 name: Publish CLI to npm
780 needs: [preflight, relay_public_config, quality, build]919 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' }}
782 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404921 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
783 timeout-minutes: 10922 timeout-minutes: 10
784 permissions:923 permissions:
...@@ -856,7 +995,7 @@ jobs:...@@ -856,7 +995,7 @@ jobs:
856 release:995 release:
857 name: Publish GitHub Release996 name: Publish GitHub Release
858 needs: [preflight, build, publish_cli]997 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')) }}
860 runs-on: blacksmith-8vcpu-ubuntu-2404999 runs-on: blacksmith-8vcpu-ubuntu-2404
861 timeout-minutes: 301000 timeout-minutes: 30
862 permissions:1001 permissions:
...@@ -887,7 +1026,48 @@ jobs:...@@ -887,7 +1026,48 @@ jobs:
887 merge-multiple: true1026 merge-multiple: true
888 path: release-assets1027 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
890 - name: Merge macOS updater manifests1069 - name: Merge macOS updater manifests
1070 if: needs.preflight.outputs.release_channel != 'preview'
891 run: |1071 run: |
892 shopt -s nullglob1072 shopt -s nullglob
893 for x64_manifest in release-assets/*-mac-x64.yml; do1073 for x64_manifest in release-assets/*-mac-x64.yml; do
...@@ -898,6 +1078,45 @@ jobs:...@@ -898,6 +1078,45 @@ jobs:
898 fi1078 fi
899 done1079 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
901 - name: Publish release1120 - name: Publish release
902 if: needs.preflight.outputs.previous_tag != ''1121 if: needs.preflight.outputs.previous_tag != ''
903 uses: softprops/action-gh-release@v31122 uses: softprops/action-gh-release@v3
...@@ -905,17 +1124,12 @@ jobs:...@@ -905,17 +1124,12 @@ jobs:
905 tag_name: ${{ needs.preflight.outputs.tag }}1124 tag_name: ${{ needs.preflight.outputs.tag }}
906 target_commitish: ${{ needs.preflight.outputs.ref }}1125 target_commitish: ${{ needs.preflight.outputs.ref }}
907 name: ${{ needs.preflight.outputs.release_name }}1126 name: ${{ needs.preflight.outputs.release_name }}
908 generate_release_notes: true1127 generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }}
1128 body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }}
909 previous_tag: ${{ needs.preflight.outputs.previous_tag }}1129 previous_tag: ${{ needs.preflight.outputs.previous_tag }}
910 prerelease: ${{ needs.preflight.outputs.is_prerelease }}1130 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
911 make_latest: ${{ needs.preflight.outputs.make_latest }}1131 make_latest: ${{ needs.preflight.outputs.make_latest }}
912 files: |1132 files: ${{ steps.release_files.outputs.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
919 fail_on_unmatched_files: true1133 fail_on_unmatched_files: true
920 token: ${{ github.token }}1134 token: ${{ github.token }}
9211135
...@@ -926,23 +1140,18 @@ jobs:...@@ -926,23 +1140,18 @@ jobs:
926 tag_name: ${{ needs.preflight.outputs.tag }}1140 tag_name: ${{ needs.preflight.outputs.tag }}
927 target_commitish: ${{ needs.preflight.outputs.ref }}1141 target_commitish: ${{ needs.preflight.outputs.ref }}
928 name: ${{ needs.preflight.outputs.release_name }}1142 name: ${{ needs.preflight.outputs.release_name }}
929 generate_release_notes: true1143 generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }}
1144 body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }}
930 prerelease: ${{ needs.preflight.outputs.is_prerelease }}1145 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
931 make_latest: ${{ needs.preflight.outputs.make_latest }}1146 make_latest: ${{ needs.preflight.outputs.make_latest }}
932 files: |1147 files: ${{ steps.release_files.outputs.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
939 fail_on_unmatched_files: true1148 fail_on_unmatched_files: true
940 token: ${{ github.token }}1149 token: ${{ github.token }}
9411150
942 publish_aur:1151 publish_aur:
943 name: Publish AUR package1152 name: Publish AUR package
944 needs: [preflight, release]1153 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' }}
946 uses: ./.github/workflows/publish-aur.yml1155 uses: ./.github/workflows/publish-aur.yml
947 with:1156 with:
948 release_tag: ${{ needs.preflight.outputs.tag }}1157 release_tag: ${{ needs.preflight.outputs.tag }}
...@@ -952,7 +1161,7 @@ jobs:...@@ -952,7 +1161,7 @@ jobs:
952 deploy_web:1161 deploy_web:
953 name: Deploy hosted web app1162 name: Deploy hosted web app
954 needs: [preflight, relay_public_config, release]1163 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' }}
956 runs-on: blacksmith-8vcpu-ubuntu-24041165 runs-on: blacksmith-8vcpu-ubuntu-2404
957 timeout-minutes: 101166 timeout-minutes: 10
958 env:1167 env:
...@@ -1206,6 +1415,7 @@ jobs:...@@ -1206,6 +1415,7 @@ jobs:
1206 if: |1415 if: |
1207 always() && !cancelled() &&1416 always() && !cancelled() &&
1208 needs.preflight.result == 'success' &&1417 needs.preflight.result == 'success' &&
1418 needs.preflight.outputs.release_channel != 'preview' &&
1209 needs.relay_public_config.result == 'success' &&1419 needs.relay_public_config.result == 'success' &&
1210 needs.release.result == 'success' &&1420 needs.release.result == 'success' &&
1211 needs.deploy_web.result == 'success' &&1421 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 @@...@@ -1,11 +1,17 @@
1import type { DesktopUpdateChannel } from "@t3tools/contracts";1import 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
5export function isNightlyDesktopVersion(version: string): boolean {11export function isNightlyDesktopVersion(version: string): boolean {
6 return NIGHTLY_VERSION_PATTERN.test(version);12 return PRERELEASE_VERSION_PATTERN.test(version);
7}13}
814
9export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {15export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
10 return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";16 return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest";
11}17}
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(...@@ -185,6 +185,12 @@ const buildExeCmd = Command.make(
185 "build-exe",185 "build-exe",
186 {186 {
187 verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)),187 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 ),
188 },194 },
189 (config) =>195 (config) =>
190 Effect.gen(function* () {196 Effect.gen(function* () {
...@@ -198,7 +204,14 @@ const buildExeCmd = Command.make(...@@ -198,7 +204,14 @@ const buildExeCmd = Command.make(
198 yield* runCommand(204 yield* runCommand(
199 ChildProcess.make(spawnCommand.command, spawnCommand.args, {205 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
200 cwd: serverDir,206 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 },
202 stdout: config.verbose ? "inherit" : "ignore",215 stdout: config.verbose ? "inherit" : "ignore",
203 stderr: "inherit",216 stderr: "inherit",
204 shell: spawnCommand.shell,217 shell: spawnCommand.shell,
apps/server/src/cli/invocation.test.ts+3-1
...@@ -43,9 +43,11 @@ it("treats stable installs as direct invocations", () => {...@@ -43,9 +43,11 @@ it("treats stable installs as direct invocations", () => {
43 }43 }
44});44});
4545
46it("re-suggests the nightly channel only for nightly builds", () => {46it("re-suggests the prerelease channel only for prerelease builds", () => {
47 for (const [version, expected] of [47 for (const [version, expected] of [
48 ["0.0.31-nightly.20260729", "npx t3@nightly serve"],48 ["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"],
49 ["0.0.31", "npx t3 serve"],51 ["0.0.31", "npx t3 serve"],
50 ] as const) {52 ] as const) {
51 assert.equal(53 assert.equal(
apps/server/src/cli/invocation.ts+2-1
...@@ -43,7 +43,8 @@ function detectCliRunner(entryPath: string): CliRunner | null {...@@ -43,7 +43,8 @@ function detectCliRunner(entryPath: string): CliRunner | null {
43 * anything else suggests the bare package.43 * anything else suggests the bare package.
44 */44 */
45function suggestedPackageSpec(version: string): string {45function 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}`;
47}48}
4849
49/**50/**
apps/server/src/cli/triage.ts+2-2
...@@ -189,8 +189,8 @@ export const triageCommand = Command.make("triage", {...@@ -189,8 +189,8 @@ export const triageCommand = Command.make("triage", {
189 buildTriageContext({189 buildTriageContext({
190 generatedAt: DateTime.formatIso(now),190 generatedAt: DateTime.formatIso(now),
191 version,191 version,
192 releaseTag: version.includes("-nightly.")192 releaseTag: /^[^-+]+-(?:nightly|preview)\./.test(version)
193 ? `v${version} (nightly build; if this tag does not exist, clone main)`193 ? `v${version} (prerelease build; if this tag does not exist, clone main)`
194 : `v${version}`,194 : `v${version}`,
195 os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`,195 os: `${yield* HostProcessPlatform} ${yield* HostProcessArchitecture} (${NodeOS.release()})`,
196 nodeVersion: process.version,196 nodeVersion: process.version,
apps/server/vite.config.ts+35-1
...@@ -21,13 +21,46 @@ import {...@@ -21,13 +21,46 @@ import {
21export { shouldBundleCliDependency };21export { shouldBundleCliDependency };
2222
23const repoEnv = loadRepoEnv();23const repoEnv = loadRepoEnv();
24const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest";24const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version)
25 ? "nightly"
26 : "latest";
2527
26// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe28// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe
27// step refuses multi-chunk output and counts the sourcemap as a chunk, and the29// step refuses multi-chunk output and counts the sourcemap as a chunk, and the
28// executable needs a host Node that supports `--build-sea` (25.7+), so this is30// executable needs a host Node that supports `--build-sea` (25.7+), so this is
29// a separate mode rather than a second entry in the default build.31// a separate mode rather than a second entry in the default build.
30const packExecutable = process.env.T3CODE_PACK_EXE === "1";32const 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
32export default mergeConfig(65export default mergeConfig(
33 baseConfig,66 baseConfig,
...@@ -53,6 +86,7 @@ export default mergeConfig(...@@ -53,6 +86,7 @@ export default mergeConfig(
53 exe: {86 exe: {
54 fileName: "t3",87 fileName: "t3",
55 outDir: "dist-exe",88 outDir: "dist-exe",
89 ...(packExecutableTargets ? { targets: packExecutableTargets } : {}),
56 // Node's SEA docs: `import()` does not work when useCodeCache is90 // Node's SEA docs: `import()` does not work when useCodeCache is
57 // true, and the server reaches several modules that way. The91 // true, and the server reaches several modules that way. The
58 // cache is also platform-bound, so leaving it off keeps the92 // cache is also platform-bound, so leaving it off keeps the
apps/web/src/branding.logic.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;1const NIGHTLY_SERVER_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/;
22
3export function formatAppDisplayName(input: {3export function formatAppDisplayName(input: {
4 readonly baseName: string;4 readonly baseName: string;
docs/operations/release.md+7
...@@ -12,6 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -12,6 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop
12 - push tag matching `v*.*.*` for a stable release of an explicit commit12 - push tag matching `v*.*.*` for a stable release of an explicit commit
13 - scheduled nightly check every 30 minutes13 - scheduled nightly check every 30 minutes
14 - manual `workflow_dispatch` with `channel=nightly`14 - 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.
15- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.16- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.
16 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep17 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep
17 landing while you verify and never leak into the stable build.18 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...@@ -33,9 +34,15 @@ This document covers the unified release workflow for stable and nightly desktop
33 - Nightly runs are always GitHub prereleases and never marked latest.34 - Nightly runs are always GitHub prereleases and never marked latest.
34 - 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.35 - 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.
35- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.36- 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.
36- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file:42- Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file:
37 - stable releases publish npm dist-tag `latest`43 - stable releases publish npm dist-tag `latest`
38 - nightly releases publish npm dist-tag `nightly`44 - nightly releases publish npm dist-tag `nightly`
45 - preview releases are not published to npm
39- Deploys the hosted web app to Vercel only after a release is published:46- Deploys the hosted web app to Vercel only after a release is published:
40 - stable releases are aliased to the `latest` hosted app channel47 - stable releases are aliased to the `latest` hosted app channel
41 - nightly releases are aliased to the `nightly` hosted app channel48 - 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) => {...@@ -350,7 +350,18 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
350 undefined,350 undefined,
351 );351 );
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
353 assert.notProperty(preview, "publish");363 assert.notProperty(preview, "publish");
364 assert.notProperty(previewChannel, "publish");
354 assert.deepStrictEqual(release.publish, [365 assert.deepStrictEqual(release.publish, [
355 {366 {
356 provider: "github",367 provider: "github",
scripts/build-desktop-artifact.ts+9-2
...@@ -2616,8 +2616,15 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl...@@ -2616,8 +2616,15 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl
2616 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";2616 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";
2617}2617}
26182618
2619function isDesktopPreviewVersion(version: string): boolean {2619// Pull request builds (`-pr.<n>.`) and the temporary preview train
2620 return /-pr\./.test(version);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);
2621}2628}
26222629
2623export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand {2630export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand {
scripts/lib/brand-assets.test.ts+1
...@@ -81,6 +81,7 @@ describe("brand-assets", () => {...@@ -81,6 +81,7 @@ describe("brand-assets", () => {
81 it("maps package versions to web asset brands", () => {81 it("maps package versions to web asset brands", () => {
82 expect(resolveWebAssetBrandForPackageVersion("0.0.29")).toBe("production");82 expect(resolveWebAssetBrandForPackageVersion("0.0.29")).toBe("production");
83 expect(resolveWebAssetBrandForPackageVersion("0.0.29-nightly.20260723.882")).toBe("nightly");83 expect(resolveWebAssetBrandForPackageVersion("0.0.29-nightly.20260723.882")).toBe("nightly");
84 expect(resolveWebAssetBrandForPackageVersion("0.0.29-preview.20260723.882")).toBe("nightly");
84 });85 });
8586
86 it("keeps development, nightly, and production icon families separate", () => {87 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...@@ -42,7 +42,7 @@ export function resolveWebAssetBrandForChannel(channel: WebAssetChannel): WebAss
42}42}
4343
44export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand {44export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand {
45 return version.includes("-nightly.") ? "nightly" : "production";45 return /^[^-+]+-(?:nightly|preview)\./.test(version) ? "nightly" : "production";
46}46}
4747
48export interface IconOverride {48export 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"...@@ -52,6 +52,19 @@ it("derives nightly metadata including the short commit sha in the release name"
52 );52 );
53});53});
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
55it.effect("preserves the GITHUB_OUTPUT configuration cause", () => {68it.effect("preserves the GITHUB_OUTPUT configuration cause", () => {
56 const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890");69 const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890");
57 const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" });70 const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" });
scripts/resolve-nightly-release.ts+23-4
...@@ -96,19 +96,32 @@ export const resolveNightlyTargetVersion = (version: string) => {...@@ -96,19 +96,32 @@ export const resolveNightlyTargetVersion = (version: string) => {
96 return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`);96 return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`);
97};97};
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
99export const resolveNightlyReleaseMetadata = (111export const resolveNightlyReleaseMetadata = (
100 baseVersion: string,112 baseVersion: string,
101 date: string,113 date: string,
102 runNumber: number,114 runNumber: number,
103 sha: string,115 sha: string,
116 channel: PrereleaseChannel = "nightly",
104) => {117) => {
105 const shortSha = sha.slice(0, 12);118 const shortSha = sha.slice(0, 12);
106 const version = `${baseVersion}-nightly.${date}.${runNumber}`;119 const version = `${baseVersion}-${channel}.${date}.${runNumber}`;
107 return {120 return {
108 baseVersion,121 baseVersion,
109 version,122 version,
110 tag: `v${version}`,123 tag: `v${version}`,
111 name: `T3 Code Nightly ${version} (${shortSha})`,124 name: `T3 Code ${CHANNEL_RELEASE_LABELS[channel]} ${version} (${shortSha})`,
112 shortSha,125 shortSha,
113 };126 };
114};127};
...@@ -198,6 +211,10 @@ const command = Command.make(...@@ -198,6 +211,10 @@ const command = Command.make(
198 Flag.withSchema(ShaSchema),211 Flag.withSchema(ShaSchema),
199 Flag.withDescription("Commit sha for the nightly build."),212 Flag.withDescription("Commit sha for the nightly build."),
200 ),213 ),
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 ),
201 githubOutput: Flag.boolean("github-output").pipe(218 githubOutput: Flag.boolean("github-output").pipe(
202 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),219 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
203 Flag.withDefault(false),220 Flag.withDefault(false),
...@@ -207,9 +224,11 @@ const command = Command.make(...@@ -207,9 +224,11 @@ const command = Command.make(
207 Flag.optional,224 Flag.optional,
208 ),225 ),
209 },226 },
210 ({ date, runNumber, sha, githubOutput, root }) =>227 ({ date, runNumber, sha, channel, githubOutput, root }) =>
211 readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(228 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 ),
213 Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)),232 Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)),
214 ),233 ),
215).pipe(Command.withDescription("Resolve nightly release version metadata."));234).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", ()...@@ -65,6 +65,23 @@ it.effect("accepts legacy nightly tags when selecting the previous nightly", ()
65 }),65 }),
66);66);
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
68it.effect("reports the invalid tag with its release channel", () =>85it.effect("reports the invalid tag with its release channel", () =>
69 Effect.gen(function* () {86 Effect.gen(function* () {
70 const error = yield* resolvePreviousReleaseTag("nightly", "v1.2.0", []).pipe(Effect.flip);87 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";...@@ -11,7 +11,7 @@ import * as String from "effect/String";
11import { Command, Flag } from "effect/unstable/cli";11import { Command, Flag } from "effect/unstable/cli";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1313
14const ReleaseChannel = Schema.Literals(["stable", "nightly"]);14const ReleaseChannel = Schema.Literals(["stable", "nightly", "preview"]);
15type ReleaseChannel = typeof ReleaseChannel.Type;15type ReleaseChannel = typeof ReleaseChannel.Type;
1616
17export class InvalidReleaseTagError extends Schema.TaggedError<InvalidReleaseTagError>()(17export class InvalidReleaseTagError extends Schema.TaggedError<InvalidReleaseTagError>()(
...@@ -151,10 +151,12 @@ const parseStableTag = (tag: string): StableVersion | undefined => {...@@ -151,10 +151,12 @@ const parseStableTag = (tag: string): StableVersion | undefined => {
151 if (!major || !minor || !patch) return undefined;151 if (!major || !minor || !patch) return undefined;
152152
153 const prereleaseIdentifiers = prerelease ? prerelease.split(".") : [];153 const prereleaseIdentifiers = prerelease ? prerelease.split(".") : [];
154 // Nightly tags also start with `v` and carry a `nightly.*` prerelease154 // Nightly and preview tags also start with `v` and carry their channel as
155 // identifier. They must not be considered stable candidates when resolving155 // the prerelease identifier. They must not be considered stable candidates
156 // the previous stable tag.156 // when resolving the previous stable tag.
157 if (prereleaseIdentifiers[0] === "nightly") return undefined;157 if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "preview") {
158 return undefined;
159 }
158160
159 return {161 return {
160 major: Number(major),162 major: Number(major),
...@@ -172,10 +174,15 @@ const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): nu...@@ -172,10 +174,15 @@ const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): nu
172 return left.runNumber - right.runNumber;174 return left.runNumber - right.runNumber;
173};175};
174176
175const parseNightlyTag = (tag: string): NightlyVersion | undefined => {177const parseNightlyTag = (
178 tag: string,
179 channel: "nightly" | "preview" = "nightly",
180): NightlyVersion | undefined => {
176 // Accept both the current `v<semver>` format and the legacy `nightly-v<semver>`181 // Accept both the current `v<semver>` format and the legacy `nightly-v<semver>`
177 // format so release note diffs keep working across the tag-format transition.182 // 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);
179 if (!match) return undefined;186 if (!match) return undefined;
180187
181 const [, major, minor, patch, date, runNumber] = match;188 const [, major, minor, patch, date, runNumber] = match;
...@@ -213,13 +220,13 @@ export const resolvePreviousReleaseTag = (...@@ -213,13 +220,13 @@ export const resolvePreviousReleaseTag = (
213 return candidates[0]?.tag;220 return candidates[0]?.tag;
214 }221 }
215222
216 const current = parseNightlyTag(currentTag);223 const current = parseNightlyTag(currentTag, channel);
217 if (!current) {224 if (!current) {
218 return yield* new InvalidReleaseTagError({ channel, currentTag });225 return yield* new InvalidReleaseTagError({ channel, currentTag });
219 }226 }
220227
221 const candidates = tags228 const candidates = tags
222 .map((tag) => ({ tag, parsed: parseNightlyTag(tag) }))229 .map((tag) => ({ tag, parsed: parseNightlyTag(tag, channel) }))
223 .filter(230 .filter(
224 (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined,231 (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined,
225 )232 )
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}