| author | |
| committer | |
| log | eb8f6f42a5cad1a5d0bc51eaa16cff13a9c69f45 |
| tree | 6f21d22a04ac9bb10e95bf17ab6cc7512faee528 |
| parent | 06de59b3d67e5be427ce4c7c8d41e91265709aec |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | - stable | 20 | - stable |
| 20 | - nightly | 21 | - 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: false | 25 | 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 silently | 32 | # 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. |
| 32 | concurrency: | 34 | concurrency: |
| 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: false | 36 | cancel-in-progress: false |
| 35 | queue: max | 37 | queue: max |
| 36 | 38 | ||
| ... | @@ -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 | else | 169 | else |
| 148 | if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | 170 | 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: false | 425 | 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 arm64 | 434 | - label: macOS arm64 |
| 407 | runner: blacksmith-12vcpu-macos-26 | 435 | runner: blacksmith-12vcpu-macos-26 |
| 408 | platform: mac | 436 | platform: mac |
| ... | @@ -410,6 +438,7 @@ jobs: | ... | @@ -410,6 +438,7 @@ jobs: |
| 410 | arch: arm64 | 438 | arch: arm64 |
| 411 | rust_target: aarch64-apple-darwin | 439 | rust_target: aarch64-apple-darwin |
| 412 | resource_key: darwin-arm64 | 440 | resource_key: darwin-arm64 |
| 441 | cli_archive: true | ||
| 413 | - label: macOS x64 | 442 | - label: macOS x64 |
| 414 | runner: blacksmith-12vcpu-macos-26 | 443 | runner: blacksmith-12vcpu-macos-26 |
| 415 | platform: mac | 444 | platform: mac |
| ... | @@ -417,6 +446,7 @@ jobs: | ... | @@ -417,6 +446,7 @@ jobs: |
| 417 | arch: x64 | 446 | arch: x64 |
| 418 | rust_target: x86_64-apple-darwin | 447 | rust_target: x86_64-apple-darwin |
| 419 | resource_key: darwin-x64 | 448 | resource_key: darwin-x64 |
| 449 | cli_archive: false | ||
| 420 | - label: Linux x64 | 450 | - label: Linux x64 |
| 421 | runner: blacksmith-32vcpu-ubuntu-2404 | 451 | runner: blacksmith-32vcpu-ubuntu-2404 |
| 422 | platform: linux | 452 | platform: linux |
| ... | @@ -424,6 +454,7 @@ jobs: | ... | @@ -424,6 +454,7 @@ jobs: |
| 424 | arch: x64 | 454 | arch: x64 |
| 425 | rust_target: x86_64-unknown-linux-gnu | 455 | rust_target: x86_64-unknown-linux-gnu |
| 426 | resource_key: linux-x64 | 456 | resource_key: linux-x64 |
| 457 | cli_archive: true | ||
| 427 | - label: Windows x64 | 458 | - label: Windows x64 |
| 428 | runner: blacksmith-32vcpu-windows-2025 | 459 | runner: blacksmith-32vcpu-windows-2025 |
| 429 | platform: win | 460 | platform: win |
| ... | @@ -431,6 +462,7 @@ jobs: | ... | @@ -431,6 +462,7 @@ jobs: |
| 431 | arch: x64 | 462 | arch: x64 |
| 432 | rust_target: x86_64-pc-windows-msvc | 463 | rust_target: x86_64-pc-windows-msvc |
| 433 | resource_key: win32-x64 | 464 | resource_key: win32-x64 |
| 465 | cli_archive: true | ||
| 434 | # - label: Windows arm64 | 466 | # - label: Windows arm64 |
| 435 | # runner: windows-11-arm | 467 | # runner: windows-11-arm |
| 436 | # platform: win | 468 | # platform: win |
| ... | @@ -710,6 +742,105 @@ jobs: | ... | @@ -710,6 +742,105 @@ jobs: |
| 710 | 742 | ||
| 711 | vp run dist:desktop:artifact "${args[@]}" | 743 | vp run dist:desktop:artifact "${args[@]}" |
| 712 | 744 | ||
| 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 assets | 844 | - name: Collect release assets |
| 714 | shell: bash | 845 | shell: bash |
| 715 | run: | | 846 | run: | |
| ... | @@ -717,13 +848,19 @@ jobs: | ... | @@ -717,13 +848,19 @@ jobs: |
| 717 | mkdir -p release-publish | 848 | mkdir -p release-publish |
| 718 | 849 | ||
| 719 | shopt -s nullglob | 850 | 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"; do | 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 | ||
| 727 | for file in $pattern; do | 864 | for file in $pattern; do |
| 728 | cp "$file" release-publish/ | 865 | cp "$file" release-publish/ |
| 729 | done | 866 | 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: error | 913 | if-no-files-found: error |
| 777 | 914 | ||
| 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 npm | 918 | 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-2404 | 921 | runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 |
| 783 | timeout-minutes: 10 | 922 | 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 Release | 996 | 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-2404 | 999 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 861 | timeout-minutes: 30 | 1000 | timeout-minutes: 30 |
| 862 | permissions: | 1001 | permissions: |
| ... | @@ -887,7 +1026,48 @@ jobs: | ... | @@ -887,7 +1026,48 @@ jobs: |
| 887 | merge-multiple: true | 1026 | merge-multiple: true |
| 888 | path: release-assets | 1027 | path: release-assets |
| 889 | 1028 | ||
| 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 manifests | 1069 | - name: Merge macOS updater manifests |
| 1070 | if: needs.preflight.outputs.release_channel != 'preview' | ||
| 891 | run: | | 1071 | run: | |
| 892 | shopt -s nullglob | 1072 | shopt -s nullglob |
| 893 | for x64_manifest in release-assets/*-mac-x64.yml; do | 1073 | for x64_manifest in release-assets/*-mac-x64.yml; do |
| ... | @@ -898,6 +1078,45 @@ jobs: | ... | @@ -898,6 +1078,45 @@ jobs: |
| 898 | fi | 1078 | fi |
| 899 | done | 1079 | done |
| 900 | 1080 | ||
| 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 release | 1120 | - name: Publish release |
| 902 | if: needs.preflight.outputs.previous_tag != '' | 1121 | if: needs.preflight.outputs.previous_tag != '' |
| 903 | uses: softprops/action-gh-release@v3 | 1122 | 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: true | 1127 | 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: true | 1133 | fail_on_unmatched_files: true |
| 920 | token: ${{ github.token }} | 1134 | token: ${{ github.token }} |
| 921 | 1135 | ||
| ... | @@ -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: true | 1143 | 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: true | 1148 | fail_on_unmatched_files: true |
| 940 | token: ${{ github.token }} | 1149 | token: ${{ github.token }} |
| 941 | 1150 | ||
| 942 | publish_aur: | 1151 | publish_aur: |
| 943 | name: Publish AUR package | 1152 | 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.yml | 1155 | 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 app | 1162 | 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-2404 | 1165 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 957 | timeout-minutes: 10 | 1166 | 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { isNightlyDesktopVersion, resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; | ||
| 4 | |||
| 5 | describe("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 @@ |
| 1 | import type { DesktopUpdateChannel } from "@t3tools/contracts"; | 1 | import type { DesktopUpdateChannel } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; | 3 | const 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. | ||
| 9 | const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; | ||
| 4 | 10 | ||
| 5 | export function isNightlyDesktopVersion(version: string): boolean { | 11 | export function isNightlyDesktopVersion(version: string): boolean { |
| 6 | return NIGHTLY_VERSION_PATTERN.test(version); | 12 | return PRERELEASE_VERSION_PATTERN.test(version); |
| 7 | } | 13 | } |
| 8 | 14 | ||
| 9 | export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { | 15 | export 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 | }); |
| 45 | 45 | ||
| 46 | it("re-suggests the nightly channel only for nightly builds", () => { | 46 | it("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 | */ |
| 45 | function suggestedPackageSpec(version: string): string { | 45 | function 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 | } |
| 48 | 49 | ||
| 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 { |
| 21 | export { shouldBundleCliDependency }; | 21 | export { shouldBundleCliDependency }; |
| 22 | 22 | ||
| 23 | const repoEnv = loadRepoEnv(); | 23 | const repoEnv = loadRepoEnv(); |
| 24 | const cliBuildChannel = packageJson.version.includes("-nightly.") ? "nightly" : "latest"; | 24 | const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version) |
| 25 | ? "nightly" | ||
| 26 | : "latest"; | ||
| 25 | 27 | ||
| 26 | // `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe | 28 | // `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 the | 29 | // 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 is | 30 | // 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. |
| 30 | const packExecutable = process.env.T3CODE_PACK_EXE === "1"; | 32 | const 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. | ||
| 41 | const SEA_NODE_VERSION = "26.8.2"; | ||
| 42 | const 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; | ||
| 50 | const packExecutableTarget = process.env.T3CODE_PACK_EXE_TARGET?.trim(); | ||
| 51 | if (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 | } | ||
| 56 | const packExecutableTargets = packExecutableTarget | ||
| 57 | ? [ | ||
| 58 | { | ||
| 59 | ...SEA_TARGETS[packExecutableTarget as keyof typeof SEA_TARGETS], | ||
| 60 | nodeVersion: SEA_NODE_VERSION, | ||
| 61 | }, | ||
| 62 | ] | ||
| 63 | : undefined; | ||
| 31 | 64 | ||
| 32 | export default mergeConfig( | 65 | export 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 is | 90 | // Node's SEA docs: `import()` does not work when useCodeCache is |
| 57 | // true, and the server reaches several modules that way. The | 91 | // true, and the server reaches several modules that way. The |
| 58 | // cache is also platform-bound, so leaving it off keeps the | 92 | // 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 @@ |
| 1 | const NIGHTLY_SERVER_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; | 1 | const NIGHTLY_SERVER_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; |
| 2 | 2 | ||
| 3 | export function formatAppDisplayName(input: { | 3 | export 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 commit | 12 | - push tag matching `v*.*.*` for a stable release of an explicit commit |
| 13 | - scheduled nightly check every 30 minutes | 13 | - 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` keep | 17 | 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 channel | 47 | - stable releases are aliased to the `latest` hosted app channel |
| 41 | - nightly releases are aliased to the `nightly` hosted app channel | 48 | - 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 | */ | ||
| 17 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 18 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 19 | import * as Config from "effect/Config"; | ||
| 20 | import * as Effect from "effect/Effect"; | ||
| 21 | import * as FileSystem from "effect/FileSystem"; | ||
| 22 | import * as Layer from "effect/Layer"; | ||
| 23 | import * as Logger from "effect/Logger"; | ||
| 24 | import * as Option from "effect/Option"; | ||
| 25 | import * as Path from "effect/Path"; | ||
| 26 | import * as Schema from "effect/Schema"; | ||
| 27 | import { Command, Flag } from "effect/unstable/cli"; | ||
| 28 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 29 | |||
| 30 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 31 | import { fromYaml } from "@t3tools/shared/schemaYaml"; | ||
| 32 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 33 | import rootPackageJson from "../package.json" with { type: "json" }; | ||
| 34 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; | ||
| 35 | |||
| 36 | import { | ||
| 37 | createStagePatchedDependencies, | ||
| 38 | createStageWorkspaceConfig, | ||
| 39 | resolveFffNativeDependencies, | ||
| 40 | STAGE_INSTALL_ARGS, | ||
| 41 | } from "./build-desktop-artifact.ts"; | ||
| 42 | import { selectCliRuntimeExternalDependencies } from "./lib/cli-external-packages.ts"; | ||
| 43 | import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; | ||
| 44 | |||
| 45 | const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); | ||
| 46 | const BuildArch = Schema.Literals(["arm64", "x64"]); | ||
| 47 | type BuildPlatform = typeof BuildPlatform.Type; | ||
| 48 | type BuildArch = typeof BuildArch.Type; | ||
| 49 | |||
| 50 | const 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 | }); | ||
| 56 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); | ||
| 57 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 58 | const 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 | }); | ||
| 69 | const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); | ||
| 70 | |||
| 71 | const RepoRoot = Effect.service(Path.Path).pipe( | ||
| 72 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | ||
| 73 | ); | ||
| 74 | |||
| 75 | export 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 | |||
| 84 | export 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`. */ | ||
| 94 | export function cliArchivePlatformKey(platform: BuildPlatform, arch: BuildArch): string { | ||
| 95 | const nodePlatform = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; | ||
| 96 | return `${nodePlatform}-${arch}`; | ||
| 97 | } | ||
| 98 | |||
| 99 | export function cliArchiveStem(version: string, platform: BuildPlatform, arch: BuildArch): string { | ||
| 100 | return `t3-${version}-${cliArchivePlatformKey(platform, arch)}`; | ||
| 101 | } | ||
| 102 | |||
| 103 | export 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. */ | ||
| 110 | export function windowsSystemTar(): string { | ||
| 111 | const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows"; | ||
| 112 | return `${systemRoot}\\System32\\tar.exe`; | ||
| 113 | } | ||
| 114 | |||
| 115 | const 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 | |||
| 137 | const 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 | */ | ||
| 149 | const 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. */ | ||
| 247 | const 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 | |||
| 259 | const 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 | */ | ||
| 273 | const 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 | |||
| 346 | const 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 | */ | ||
| 364 | const 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. */ | ||
| 399 | const 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 | |||
| 433 | const 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 | |||
| 529 | const 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 | |||
| 548 | if (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 | ); |
| 352 | 352 | ||
| 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 | } |
| 2618 | 2618 | ||
| 2619 | function 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. | ||
| 2626 | export function isDesktopPreviewVersion(version: string): boolean { | ||
| 2627 | return /-pr\./.test(version) || /-preview\.\d{8}\.\d+$/.test(version); | ||
| 2621 | } | 2628 | } |
| 2622 | 2629 | ||
| 2623 | export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { | 2630 | export 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 | }); |
| 85 | 86 | ||
| 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 | } |
| 43 | 43 | ||
| 44 | export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand { | 44 | export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand { |
| 45 | return version.includes("-nightly.") ? "nightly" : "production"; | 45 | return /^[^-+]+-(?:nightly|preview)\./.test(version) ? "nightly" : "production"; |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | export interface IconOverride { | 48 | export 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 | }); |
| 54 | 54 | ||
| 55 | it("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 | |||
| 55 | it.effect("preserves the GITHUB_OUTPUT configuration cause", () => { | 68 | it.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 | }; |
| 98 | 98 | ||
| 99 | /** Prerelease trains that share nightly's date-and-run versioning. */ | ||
| 100 | export const PrereleaseChannel = Schema.Literals(["nightly", "preview"]); | ||
| 101 | export 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. | ||
| 106 | const CHANNEL_RELEASE_LABELS: Record<PrereleaseChannel, string> = { | ||
| 107 | nightly: "Nightly", | ||
| 108 | preview: "Preview (maintainer test build, do not install)", | ||
| 109 | }; | ||
| 110 | |||
| 99 | export const resolveNightlyReleaseMetadata = ( | 111 | export 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 | ); |
| 67 | 67 | ||
| 68 | it.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 | |||
| 68 | it.effect("reports the invalid tag with its release channel", () => | 85 | it.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"; |
| 11 | import { Command, Flag } from "effect/unstable/cli"; | 11 | import { Command, Flag } from "effect/unstable/cli"; |
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 13 | 13 | ||
| 14 | const ReleaseChannel = Schema.Literals(["stable", "nightly"]); | 14 | const ReleaseChannel = Schema.Literals(["stable", "nightly", "preview"]); |
| 15 | type ReleaseChannel = typeof ReleaseChannel.Type; | 15 | type ReleaseChannel = typeof ReleaseChannel.Type; |
| 16 | 16 | ||
| 17 | export class InvalidReleaseTagError extends Schema.TaggedError<InvalidReleaseTagError>()( | 17 | export 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; |
| 152 | 152 | ||
| 153 | const prereleaseIdentifiers = prerelease ? prerelease.split(".") : []; | 153 | const prereleaseIdentifiers = prerelease ? prerelease.split(".") : []; |
| 154 | // Nightly tags also start with `v` and carry a `nightly.*` prerelease | 154 | // Nightly and preview tags also start with `v` and carry their channel as |
| 155 | // identifier. They must not be considered stable candidates when resolving | 155 | // 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 | } | ||
| 158 | 160 | ||
| 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 | }; |
| 174 | 176 | ||
| 175 | const parseNightlyTag = (tag: string): NightlyVersion | undefined => { | 177 | const 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; |
| 180 | 187 | ||
| 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 | } |
| 215 | 222 | ||
| 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 | } |
| 220 | 227 | ||
| 221 | const candidates = tags | 228 | 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 | */ | ||
| 9 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Fiber from "effect/Fiber"; | ||
| 14 | import * as FileSystem from "effect/FileSystem"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Logger from "effect/Logger"; | ||
| 17 | import * as Path from "effect/Path"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import * as Stream from "effect/Stream"; | ||
| 20 | import { Command, Flag } from "effect/unstable/cli"; | ||
| 21 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 22 | |||
| 23 | import * as NetService from "@t3tools/shared/Net"; | ||
| 24 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 25 | import { windowsSystemTar } from "./build-cli-archive.ts"; | ||
| 26 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 27 | |||
| 28 | export 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 | |||
| 37 | const 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 | |||
| 46 | const 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 | |||
| 68 | const 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 | |||
| 177 | const 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 | |||
| 186 | if (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 | } | ||