diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4c45728c234..78cf097fba15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: # open: if the diff cannot be resolved, everything runs. changes: name: Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 5 outputs: typecheck: ${{ steps.detect.outputs.typecheck }} @@ -63,7 +63,7 @@ jobs: contents: read id-token: write name: Lint - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -119,7 +119,7 @@ jobs: name: Build and Typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -213,7 +213,7 @@ jobs: name: Test needs: changes if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -289,7 +289,7 @@ jobs: name: Test Web needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -353,7 +353,7 @@ jobs: contents: read id-token: write name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} # Shards take 2-5 minutes. When the shared task cache throttles reads, each # failed fetch stalls vp for 15-90s, which pushed shards past 10 minutes. timeout-minutes: 20 @@ -469,7 +469,7 @@ jobs: name: Rust needs: changes if: ${{ !cancelled() && needs.changes.outputs.rust != 'false' }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -502,7 +502,7 @@ jobs: name: Mobile Native Static Analysis needs: changes if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: ${{ vars.T3CODE_MACOS_RUNNER || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 10 steps: - name: Checkout @@ -535,7 +535,7 @@ jobs: name: Release Smoke needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -575,7 +575,7 @@ jobs: mobile_native_static_analysis, release_smoke, ] - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 5 steps: - name: Require every job to pass diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 5db7a86eb98c..cdb1d003df97 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -27,7 +27,7 @@ jobs: name: Deploy production relay # A manual run from another branch would deploy that branch to production. if: github.repository == 'pingdotgg/t3code' && github.ref == 'refs/heads/main' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 15 environment: name: production diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index 6e8805b7f790..5ed6f095988f 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -309,7 +309,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.resolve.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.resolve.outputs.relay_url }} label: macOS arm64 preview - runner: blacksmith-12vcpu-macos-26 + runner: ${{ vars.T3CODE_MACOS_RUNNER || 'blacksmith-12vcpu-macos-26' }} platform: mac target: dmg arch: arm64 @@ -325,7 +325,7 @@ jobs: name: Publish anonymous download needs: [resolve, build] if: needs.resolve.outputs.eligible == 'true' && needs.build.result == 'success' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 # Its own group, so a publish never cancels a newer commit's signing job # (they would share the build group) and is never cancelled mid-upload. diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 6dcdd702c2da..6f7906f0bb38 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -30,7 +30,7 @@ jobs: build: name: Build preview JS bundle if: github.event.label.name == 'preview:mac' - runs-on: blacksmith-32vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-32vcpu-ubuntu-2404' }} timeout-minutes: 30 steps: # This repository is public, so Git needs no credentials. checkout's diff --git a/.github/workflows/mobile-android-apk.yml b/.github/workflows/mobile-android-apk.yml index 18d977c50f4a..760941dd1b38 100644 --- a/.github/workflows/mobile-android-apk.yml +++ b/.github/workflows/mobile-android-apk.yml @@ -16,7 +16,7 @@ jobs: mirror: name: Mirror beta APK if: github.repository == 'pingdotgg/t3code' - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 10 permissions: contents: write diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml index e7615d46011b..85101a325282 100644 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -22,7 +22,7 @@ jobs: if: >- github.event.pull_request.head.repo.full_name != github.repository && github.event.label.name == '🚀 Mobile Continuous Deployment' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} concurrency: group: mobile-eas-preview-fork-${{ github.event.pull_request.number }} cancel-in-progress: true diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 08183d038581..eccb1730222f 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -12,7 +12,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} concurrency: group: mobile-eas-preview-${{ github.event.pull_request.number }} cancel-in-progress: true diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 6877f5c831a0..ca80d414dcd3 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -76,7 +76,7 @@ jobs: production: name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }} if: github.repository == 'pingdotgg/t3code' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} permissions: contents: read env: diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index 9fabb3a5f2ad..3cc7ab72a193 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -25,7 +25,7 @@ concurrency: jobs: fingerprint: name: Native fingerprint diff - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} permissions: contents: read env: diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index c02be425c1ab..866f20e37021 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -45,7 +45,7 @@ jobs: ios: name: iPhone, iPhone Duo, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 + runs-on: ${{ vars.T3CODE_MACOS_RUNNER || 'blacksmith-12vcpu-macos-26' }} # Capturing every palette multiplies the device matrix by six, and only the # one native build is shared between them. timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} @@ -108,7 +108,7 @@ jobs: android: name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-16vcpu-ubuntu-2404' }} # Capturing every palette multiplies the device matrix by six, and only the # one native build is shared between them. timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 4ab6db89ab70..506b1edf1190 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -38,7 +38,7 @@ jobs: publish: name: Validate and publish if: github.repository == 'pingdotgg/t3code' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 30 container: image: archlinux:base-devel diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index eed067adfd77..9776fecec180 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -40,7 +40,7 @@ jobs: contents: read issues: write pull-requests: write - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 30 environment: name: relay-preview-cleanup @@ -101,7 +101,7 @@ jobs: list_closed_previews: name: List closed relay previews if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request_target' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 outputs: numbers: ${{ steps.list.outputs.numbers }} @@ -125,7 +125,7 @@ jobs: name: Retry teardown for PR ${{ matrix.number }} needs: list_closed_previews if: github.ref == 'refs/heads/main' && needs.list_closed_previews.outputs.numbers != '[]' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 30 strategy: fail-fast: false diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml index fdce22c68a59..4b919405e6d4 100644 --- a/.github/workflows/relay-preview.yml +++ b/.github/workflows/relay-preview.yml @@ -22,7 +22,7 @@ jobs: if: >- github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, 'relay:preview') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 20 environment: name: relay-preview diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c36d51bdadb1..79a919ee4418 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,8 +50,10 @@ jobs: # have already run. Scheduled runs also decide here whether a nightly is due. resolve_commit: name: Resolve release commit - if: github.repository == 'pingdotgg/t3code' - runs-on: blacksmith-8vcpu-ubuntu-2404 + # Forks opt in with the T3CODE_RELEASES repository variable; see + # docs/operations/fork-releases.md for the variables a fork sets. + if: github.repository == 'pingdotgg/t3code' || vars.T3CODE_RELEASES == 'true' + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 5 outputs: ref: ${{ steps.resolve.outputs.ref }} @@ -105,7 +107,7 @@ jobs: if: | needs.resolve_commit.result == 'success' && (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 outputs: release_channel: ${{ steps.release_meta.outputs.release_channel }} @@ -233,7 +235,7 @@ jobs: name: Release quality checks needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -265,7 +267,7 @@ jobs: name: Release tests needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -301,7 +303,7 @@ jobs: name: Release web tests needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout @@ -355,7 +357,7 @@ jobs: name: Release server tests ${{ matrix.shard }} needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} timeout-minutes: 10 strategy: fail-fast: false @@ -393,7 +395,7 @@ jobs: if: | needs.resolve_commit.result == 'success' && (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 5 environment: name: production @@ -434,8 +436,15 @@ jobs: - name: Read production relay tracing config shell: bash working-directory: infra/relay + env: + UPSTREAM: ${{ github.repository == 'pingdotgg/t3code' }} run: | set -euo pipefail + if [[ "${UPSTREAM:-true}" != "true" && -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then + echo "::notice::No relay deployment state for this repository; client tracing stays off." + : > "$RUNNER_TEMP/relay-client-tracing.env" + exit 0 + fi output="$(npx alchemy state read T3CodeRelay/prod/output --backend cloudflare --log-level error --no-input | sed -n '/^{/,$p')" field() { jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \ @@ -501,13 +510,15 @@ jobs: # this runs alongside preflight. See the condition comment there. needs: [preflight, relay_public_config] if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} - runs-on: blacksmith-32vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-32vcpu-ubuntu-2404' }} timeout-minutes: 30 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} + # Without the npm package, servers check this repository's GitHub releases for updates. + T3CODE_NPM_RELEASES: ${{ (github.repository == 'pingdotgg/t3code' || vars.T3CODE_NPM_PUBLISH == 'true') && 'true' || 'false' }} steps: - name: Checkout uses: actions/checkout@v6 @@ -603,7 +614,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 + runner: ${{ vars.T3CODE_MACOS_RUNNER || 'blacksmith-12vcpu-macos-26' }} platform: mac target: dmg arch: arm64 @@ -627,7 +638,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: macOS x64 - runner: blacksmith-12vcpu-macos-26 + runner: ${{ vars.T3CODE_MACOS_RUNNER || 'blacksmith-12vcpu-macos-26' }} platform: mac target: dmg arch: x64 @@ -653,7 +664,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 + runner: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-32vcpu-ubuntu-2404' }} platform: linux target: AppImage arch: x64 @@ -679,7 +690,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Linux arm64 - runner: blacksmith-16vcpu-ubuntu-2404-arm + runner: ${{ vars.T3CODE_LINUX_ARM_RUNNER || 'blacksmith-16vcpu-ubuntu-2404-arm' }} platform: linux target: AppImage arch: arm64 @@ -707,7 +718,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows x64 - runner: blacksmith-16vcpu-ubuntu-2404 + runner: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-16vcpu-ubuntu-2404' }} verify_runner: windows-2025 platform: win target: nsis @@ -731,7 +742,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows arm64 - runner: blacksmith-16vcpu-ubuntu-2404 + runner: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-16vcpu-ubuntu-2404' }} verify_runner: windows-11-arm platform: win target: nsis @@ -758,7 +769,7 @@ jobs: desktop_win_arm64, ] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 30 permissions: contents: write @@ -871,6 +882,19 @@ jobs: fi done + # Servers built without the npm package read t3-release.json for update checks, and the + # attached install scripts default to the repository that cut this release. + - name: Write release manifest and install scripts + shell: bash + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + node scripts/write-release-manifest.ts --version "$RELEASE_VERSION" --output release-assets/t3-release.json + for script in install.sh install.ps1; do + sed "s#pingdotgg/t3code#${GITHUB_REPOSITORY}#g" "scripts/$script" > "release-assets/$script" + done + # Updater manifests and blockmaps are what electron-updater consumes. # They are only listed for channels an updater is meant to follow. - id: release_files @@ -894,6 +918,9 @@ jobs: [[ -f "$apk" ]] && echo "$apk" done echo 'release-assets/SHA256SUMS' + echo 'release-assets/t3-release.json' + echo 'release-assets/install.sh' + echo 'release-assets/install.ps1' echo 'EOF' } >> "$GITHUB_OUTPUT" @@ -931,7 +958,7 @@ jobs: > [!WARNING] > **This is a preview build. Do not install it unless you know exactly why you are here.** > - > 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. + > 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/${{ github.repository }}/releases/latest) or a nightly instead. Built from `${{ needs.preflight.outputs.ref }}`. EOF @@ -987,7 +1014,7 @@ jobs: publish_cli: name: Publish CLI to npm needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && (github.repository == 'pingdotgg/t3code' || vars.T3CODE_NPM_PUBLISH == 'true') }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 permissions: @@ -1021,7 +1048,7 @@ jobs: publish_aur: name: Publish AUR package needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' && github.repository == 'pingdotgg/t3code' }} uses: ./.github/workflows/publish-aur.yml with: release_tag: ${{ needs.preflight.outputs.tag }} @@ -1037,8 +1064,8 @@ jobs: build_web: name: Build hosted web app needs: [preflight, relay_public_config] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.preflight.outputs.release_channel != 'preview' && (github.repository == 'pingdotgg/t3code' || vars.T3CODE_WEB_DEPLOY == 'true') }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 outputs: deployment_url: ${{ steps.deploy.outputs.deployment_url }} @@ -1139,7 +1166,7 @@ jobs: name: Deploy hosted web app needs: [preflight, build_web, release, publish_cli] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_web.result == 'success' && needs.release.result == 'success' && needs.publish_cli.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 10 env: DEPLOYMENT_URL: ${{ needs.build_web.outputs.deployment_url }} @@ -1210,8 +1237,8 @@ jobs: build_marketing: name: Build marketing site needs: [preflight] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' && github.repository == 'pingdotgg/t3code' }} + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 outputs: deployment_url: ${{ steps.deploy.outputs.deployment_url }} @@ -1278,7 +1305,7 @@ jobs: name: Deploy marketing site needs: [preflight, build_marketing, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build_marketing.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'nightly' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-2vcpu-ubuntu-2404' }} timeout-minutes: 10 env: DEPLOYMENT_URL: ${{ needs.build_marketing.outputs.deployment_url }} @@ -1319,9 +1346,10 @@ jobs: finalize: name: Finalize release - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} + # The version bump is pushed by the release GitHub App; without one, bump versions by hand. + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' && (github.repository == 'pingdotgg/t3code' || vars.T3CODE_RELEASE_APP == 'true') }} needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - id: app_token @@ -1395,6 +1423,7 @@ jobs: name: Announce release on Discord if: | always() && !cancelled() && + github.repository == 'pingdotgg/t3code' && needs.preflight.result == 'success' && needs.preflight.outputs.release_channel != 'preview' && needs.relay_public_config.result == 'success' && @@ -1402,7 +1431,7 @@ jobs: needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') needs: [preflight, relay_public_config, release, deploy_web, finalize] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index 4b3f8952d503..0bbc8d7d19d4 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -37,7 +37,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, 'preview:web') && (github.event.action != 'labeled' || github.event.label.name == 'preview:web') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ vars.T3CODE_LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: 10 env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 3a70ad5a26a0..3cac60544546 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -26,7 +26,7 @@ permissions: jobs: test: name: Test (${{ inputs.package || 'all non-server' }}) - runs-on: blacksmith-8vcpu-windows-2025 + runs-on: ${{ vars.T3CODE_WINDOWS_RUNNER || 'blacksmith-8vcpu-windows-2025' }} timeout-minutes: 45 steps: - name: Checkout diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts index 4d2dbcfb1440..60bc3a7f8699 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -1,4 +1,5 @@ import type { DesktopUpdateState } from "@t3tools/contracts"; +import { CLI_RELEASES_PAGE_URL } from "@t3tools/shared/cliRelease"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -18,7 +19,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopState from "./DesktopState.ts"; -const RELEASES_URL = "https://github.com/pingdotgg/t3code/releases"; +const RELEASES_URL = CLI_RELEASES_PAGE_URL; const LAST_STARTED_VERSION_FILE = "desktop-last-started-version"; // Buttons are plain links to this host; will-navigate cancels them and runs the action. const ACTION_ORIGIN = "https://startup-recovery.invalid"; diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 55c3aa03b649..54116f4a0f09 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -2,7 +2,7 @@ import "vite-plus/test/config"; import { defaultInclude, defineConfig } from "vite-plus"; import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; -import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import { loadRepoEnv, releaseIdentityDefine } from "../../scripts/lib/public-config.ts"; const repoEnv = loadRepoEnv(); @@ -11,6 +11,7 @@ const isMainProcessExternal = (id: string) => id === "electron" || id.startsWith("electron/") || isDesktopRuntimeExternalDependency(id); const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; const publicConfigDefine = { + ...releaseIdentityDefine(), __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", ), diff --git a/apps/server/src/cloud/npmRelease.test.ts b/apps/server/src/cloud/npmRelease.test.ts index 152309f699b9..a2c66946c934 100644 --- a/apps/server/src/cloud/npmRelease.test.ts +++ b/apps/server/src/cloud/npmRelease.test.ts @@ -36,6 +36,7 @@ const makeService = Effect.fnUntraced(function* ( run: ProcessRunner.ProcessRunner["Service"]["run"], publicRegistry: HttpClient.HttpClient = noPublicRegistry, npmInstalled = true, + source: "npm" | "github" = "npm", ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -43,7 +44,7 @@ const makeService = Effect.fnUntraced(function* ( for (const name of ["npm", "npm.cmd"]) { yield* fs.writeFileString(path.join(npmBin, name), "", { mode: 0o755 }); } - return yield* NpmRelease.make().pipe( + return yield* NpmRelease.make({ source }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, { run }), Effect.provideService(HttpClient.HttpClient, publicRegistry), Effect.provideService(HostProcess.Environment, { @@ -196,3 +197,47 @@ it.effect.each(["--registry=https://example.com", "../other", "other-package@lat expect(Result.isFailure(yield* service.get({ spec }).pipe(Effect.result))).toBe(true); }), ); + +it.effect( + "reads a fork's GitHub releases and their manifest when it publishes no npm package", + () => + Effect.gen(function* () { + const stable = "0.1.4"; + const release = (tag: string, assets: ReadonlyArray) => ({ + tag_name: tag, + assets: assets.map((name) => ({ + name, + browser_download_url: `https://github.com/acme/t3code/releases/download/${tag}/${name}`, + })), + }); + const urls: string[] = []; + const service = yield* makeService( + () => Effect.die("a GitHub release lookup must not run npm"), + HttpClient.make((request) => { + urls.push(request.url); + const body = request.url.includes("/releases?") + ? request.url.endsWith("page=1") + ? [ + release(`v${version}`, ["t3-release.json"]), + release(`v${stable}`, []), + release("v0.1.3-preview.7", ["t3-release.json"]), + ] + : [] + : { version, t3code: { databaseMigration: 42 } }; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(JSON.stringify(body))), + ); + }), + false, + "github", + ); + expect(yield* service.get({ spec: "nightly" })).toEqual({ version, databaseMigration: 42 }); + // A release from before the manifest existed still reports its version. + expect(yield* service.get({ spec: "latest" })).toEqual({ version: stable }); + expect(yield* service.get({ spec: "9.9.9" })).toBeNull(); + expect(urls).toContain( + `https://github.com/acme/t3code/releases/download/v${version}/t3-release.json`, + ); + expect(urls.some((url) => url.startsWith("https://registry.npmjs.org"))).toBe(false); + }), +); diff --git a/apps/server/src/cloud/npmRelease.ts b/apps/server/src/cloud/npmRelease.ts index 230c050d3e9a..8c5cc5657d07 100644 --- a/apps/server/src/cloud/npmRelease.ts +++ b/apps/server/src/cloud/npmRelease.ts @@ -3,7 +3,13 @@ import { ServerNpmReleaseInput, type ServerNpmRelease, } from "@t3tools/contracts"; -import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; +import { + CLI_RELEASE_MANIFEST_ASSET, + CLI_RELEASES_ON_NPM, + cliReleaseIndexPageUrl, + newestCliReleaseVersion, + npmReleaseDatabaseMigration, +} from "@t3tools/shared/cliRelease"; import { resolveCommandPath } from "@t3tools/shared/shell"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -27,6 +33,21 @@ const decodeError = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.String }) })), ); const decodeInput = Schema.decodeUnknownEffect(ServerNpmReleaseInput); +const decodeGitHubReleases = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + tag_name: Schema.String, + draft: Schema.optionalKey(Schema.Boolean), + assets: Schema.Array( + Schema.Struct({ name: Schema.String, browser_download_url: Schema.String }), + ), + }), + ), + ), +); +// Nightlies can push a stable or exact release several pages back. +const GITHUB_RELEASE_PAGES = 5; export class NpmRelease extends Context.Service< NpmRelease, @@ -55,9 +76,56 @@ function toRelease(spec: string, json: string) { }); } -export const make = Effect.fn("NpmRelease.make")(function* () { +/** `github` reads the build's GitHub releases, for a fork that publishes no npm package. */ +export const make = Effect.fn("NpmRelease.make")(function* (options?: { + readonly source?: "npm" | "github"; +}) { + const source = options?.source ?? (CLI_RELEASES_ON_NPM ? "npm" : "github"); const runner = yield* ProcessRunner.ProcessRunner; const http = yield* HttpClient.HttpClient; + const githubUnreachable = new ServerNpmReleaseError({ + reason: "GitHub releases could not be reached.", + }); + const githubText = Effect.fn("NpmRelease.githubText")( + function* (url: string) { + const response = yield* http.get(url, { + headers: { accept: "application/vnd.github+json", "user-agent": "t3code-server" }, + }); + if (response.status < 200 || response.status >= 300) return yield* githubUnreachable; + return yield* response.text; + }, + Effect.timeout(Duration.seconds(15)), + Effect.mapError(() => githubUnreachable), + ); + const lookupGitHub = Effect.fn("NpmRelease.lookupGitHub")(function* ( + spec: string, + ): Effect.fn.Return { + for (let page = 1; page <= GITHUB_RELEASE_PAGES; page += 1) { + const releases = Option.getOrUndefined( + decodeGitHubReleases(yield* githubText(cliReleaseIndexPageUrl(page))), + ); + if (releases === undefined) { + return yield* new ServerNpmReleaseError({ + reason: "GitHub returned invalid release metadata.", + }); + } + if (releases.length === 0) return null; + const version = + spec === "latest" || spec === "nightly" + ? newestCliReleaseVersion(releases, spec === "nightly" ? "nightly" : "stable") + : releases.some((release) => !release.draft && release.tag_name === `v${spec}`) + ? spec + : undefined; + if (version === undefined) continue; + const manifest = releases + .find((release) => release.tag_name === `v${version}`) + ?.assets.find((asset) => asset.name === CLI_RELEASE_MANIFEST_ASSET); + // Releases cut before the manifest existed carry no migration metadata. + if (manifest === undefined) return { version }; + return yield* toRelease(version, yield* githubText(manifest.browser_download_url)); + } + return null; + }); const lookupPublic = Effect.fn("NpmRelease.lookupPublic")(function* (spec: string) { const unreachable = new ServerNpmReleaseError({ reason: "npm is not installed and the public npm registry could not be reached.", @@ -79,6 +147,7 @@ export const make = Effect.fn("NpmRelease.make")(function* () { capacity: 32, timeToLive: Duration.minutes(1), lookup: Effect.fn("NpmRelease.lookup")(function* (spec: string) { + if (source === "github") return yield* lookupGitHub(spec); // Without npm there is no configured registry to honor, so use the public one. const npmPath = yield* resolveCommandPath("npm"); if (npmPath === null) return yield* lookupPublic(spec); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 69e73a35cefc..05d959f36843 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,3 +1,4 @@ +import { isNonRepositoryGitStderr } from "../vcs/gitNonRepository.ts"; import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; @@ -192,7 +193,7 @@ type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; function isNotGitRepositoryError(error: GitCommandError): boolean { - return error.message.toLowerCase().includes("not a git repository"); + return isNonRepositoryGitStderr(error.message); } interface OpenPrInfo { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 13ad286861b9..07924c5de84e 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1,3 +1,4 @@ +import { isNonRepositoryGitStderr } from "./vcs/gitNonRepository.ts"; import { GitCommandError, OrchestrationV2AppThreadJson, @@ -545,7 +546,7 @@ const make = Effect.gen(function* () { ); if (result === null) return null; if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; - if (result.stderr.toLowerCase().includes("not a git repository")) return null; + if (isNonRepositoryGitStderr(result.stderr)) return null; return yield* failure( result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", ); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 07a78dc6d50d..4b05422d1e32 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2637,8 +2637,10 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("reports saved usage of a removed directory only for windows it reaches", () => + it.effect("reports saved usage of a removed directory only for windows it reaches", () => Effect.gen(function* () { + // Saved usage is kept for a fixed number of days, so pin "now" near the transcript's date. + yield* TestClock.setTime(Date.parse("2026-08-05T00:00:00Z")); const { transcript, settings, home } = yield* setup; yield* Effect.promise(async () => { await NodeFSP.writeFile(transcript, claudeLine(1, 5)); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 02f67388f219..f954d3b8a6b6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,3 +1,4 @@ +import { isNonRepositoryGitStderr } from "./gitNonRepository.ts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -557,9 +558,6 @@ function isMissingGitCwdError(error: GitCommandError): boolean { ); } -function isNonRepositoryGitStderr(stderr: string): boolean { - return stderr.toLowerCase().includes("not a git repository"); -} function isUnbornHeadStderr(stderr: string): boolean { const normalized = stderr.toLowerCase(); return ( diff --git a/apps/server/src/vcs/gitNonRepository.test.ts b/apps/server/src/vcs/gitNonRepository.test.ts new file mode 100644 index 000000000000..1fb08e48dd4b --- /dev/null +++ b/apps/server/src/vcs/gitNonRepository.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vite-plus/test"; + +import { isNonRepositoryGitStderr } from "./gitNonRepository.ts"; + +it.each([ + ["fatal: not a git repository (or any of the parent directories): .git", true], + // A worktree whose .git file points at a deleted directory, worded by Git 2.56. + ["fatal: gitfile does not point to a valid repository: /tmp/worktree/.git", true], + ["fatal: Unable to create '/repo/.git/index.lock': File exists.", false], +])("classifies %j", (stderr, expected) => { + expect(isNonRepositoryGitStderr(stderr)).toBe(expected); +}); diff --git a/apps/server/src/vcs/gitNonRepository.ts b/apps/server/src/vcs/gitNonRepository.ts new file mode 100644 index 000000000000..6005031ebd51 --- /dev/null +++ b/apps/server/src/vcs/gitNonRepository.ts @@ -0,0 +1,7 @@ +/** Git 2.56+ reports a `.git` file that points at a missing directory as an invalid gitfile. */ +export function isNonRepositoryGitStderr(stderr: string): boolean { + const text = stderr.toLowerCase(); + return ( + text.includes("not a git repository") || text.includes("does not point to a valid repository") + ); +} diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index cb77ab545c6b..c83fa63844fb 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -2,7 +2,7 @@ import "vite-plus/test/config"; import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; -import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; +import { loadRepoEnv, releaseIdentityDefine } from "../../scripts/lib/public-config.ts"; import rootPackageJson from "../../package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" }; import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts"; @@ -113,6 +113,7 @@ export default mergeConfig( define: { __T3CODE_BUILD_CHANNEL__: JSON.stringify(cliBuildChannel), __T3CODE_TELEMETRY_CHANNEL__: JSON.stringify(telemetryChannel), + ...releaseIdentityDefine(), __T3CODE_BUILD_RELAY_URL__: JSON.stringify(repoEnv.T3CODE_RELAY_URL?.trim() ?? ""), __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 5277747ee630..5a8e3dd9fa7a 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -1,8 +1,9 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import { CLI_RELEASE_REPOSITORY } from "@t3tools/shared/cliRelease"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; -const DEFAULT_DESKTOP_RELEASE_REPOSITORY = "pingdotgg/t3code"; +const DEFAULT_DESKTOP_RELEASE_REPOSITORY = CLI_RELEASE_REPOSITORY; const GITHUB_REPOSITORY_SEGMENT = /^[A-Za-z0-9._-]+$/u; function isValidRepositorySegment(value: string | undefined): value is string { diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index fc5a1aa7b91f..f14e640f27ae 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2547,8 +2547,17 @@ it("keeps workspace PDF scroll and zoom after reloads", async () => { expect(region().scrollLeft).toBe(80); expect(offset(3)).toBeCloseTo(savedOffset, 0); }); - await screen.getByRole("button", { name: "Previous page", exact: true }).click(); +}); + +// Split from the zoom case above: each reload re-renders the PDF, and slow runners can't fit them all in one test. +it("keeps workspace PDF fit width and page zoom after reloads, and resets for another file", async () => { + using fixture = await workspacePdfPreview(); + const { preview, urls } = fixture; + await using screen = await render(preview(urls.original, "one")); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); + const { region, offset, scrollTo } = pdfScroll(screen); await screen.getByRole("button", { name: "Fit width", exact: true }).click(); await scrollTo({ top: region().scrollTop + 30 }); const fitOffset = offset(2); diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx index 9b1299aee1de..470198b00c8b 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -62,6 +62,10 @@ vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: () => nul vi.mock("@t3tools/client-runtime/state/runtime", () => ({ isAtomCommandInterrupted: () => false, })); +// The guard reads server state through the runtime mocked above. +vi.mock("../components/chat/useGitAccountGuard", () => ({ + useGitAccountGuard: () => ({ ensureGitAccount: async () => true }), +})); function setup() { fixture.allowed = new Set(["environment-a", "environment-b"]); diff --git a/apps/web/src/lib/bootError.ts b/apps/web/src/lib/bootError.ts index 3e0b05a0164f..cbdbc39e091d 100644 --- a/apps/web/src/lib/bootError.ts +++ b/apps/web/src/lib/bootError.ts @@ -1,8 +1,15 @@ import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; -const FALLBACK_TRIAGE_COMMAND = "npx t3 triage"; -const DOWNLOAD_URL = "https://t3.codes/download"; -const ISSUE_URL = "https://github.com/pingdotgg/t3code/issues/new"; +// The boot bundle can't import packages, so it reads the repository the web build injected. +const RELEASE_REPOSITORY = import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY || "pingdotgg/t3code"; +const UPSTREAM_BUILD = RELEASE_REPOSITORY === "pingdotgg/t3code"; +// A fork without the npm package would run upstream's CLI through npx. +const FALLBACK_TRIAGE_COMMAND = + UPSTREAM_BUILD || import.meta.env.T3CODE_NPM_RELEASES === "true" ? "npx t3 triage" : "t3 triage"; +const DOWNLOAD_URL = UPSTREAM_BUILD + ? "https://t3.codes/download" + : `https://github.com/${RELEASE_REPOSITORY}/releases`; +const ISSUE_URL = `https://github.com/${RELEASE_REPOSITORY}/issues/new`; const LAST_FAILURE_KEY = "t3code:boot-failed-at"; const REPEAT_FAILURE_MS = 5 * 60_000; const MAX_CAUSES = 3; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 63f7cca9cbfc..acfbb4d9daa6 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -15,6 +15,7 @@ interface ImportMetaEnv { readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; readonly T3CODE_DESKTOP_UPDATE_REPOSITORY: string; + readonly T3CODE_NPM_RELEASES: string; readonly T3CODE_FEATURE_SECRETS: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index da07814b29f6..b26076bce61f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -18,7 +18,7 @@ import { DEV_PROXIED_PATH_PREFIXES, } from "@t3tools/shared/devProxy"; -import { loadRepoEnv } from "../../scripts/lib/public-config"; +import { loadRepoEnv, releaseIdentityDefine } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; import { tailwindPlugins } from "./vite/tailwind"; import { warmBundledDevPlugin } from "./vite/warmBundledDev"; @@ -45,10 +45,13 @@ const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() | const configuredReleaseChannel = process.env.T3CODE_BUILT_DEV === "1" ? "dev" : process.env.VITE_RELEASE_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; -const configuredDesktopUpdateRepository = - process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || - process.env.GITHUB_REPOSITORY?.trim() || - ""; +// Tests keep upstream's identity whichever repository CI runs in. +const configuredDesktopUpdateRepository = process.env.VITEST + ? "" + : process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || + process.env.T3CODE_RELEASE_REPOSITORY?.trim() || + process.env.GITHUB_REPOSITORY?.trim() || + ""; const configuredFeatureSecrets = process.env.T3CODE_FEATURE_SECRETS?.trim() || ""; const configuredHostedAppUrl = (() => { const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); @@ -298,6 +301,7 @@ export default defineConfig(() => { ], }, define: { + ...releaseIdentityDefine(), "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), // Override .env URLs with empty values in single-origin dev so clients use window.location.origin. "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), @@ -318,6 +322,9 @@ export default defineConfig(() => { configuredDesktopUpdateRepository, ), "import.meta.env.T3CODE_FEATURE_SECRETS": JSON.stringify(configuredFeatureSecrets), + "import.meta.env.T3CODE_NPM_RELEASES": JSON.stringify( + process.env.VITEST ? "" : (process.env.T3CODE_NPM_RELEASES?.trim() ?? ""), + ), "import.meta.env.VITE_RELEASE_CHANNEL": JSON.stringify(configuredReleaseChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, diff --git a/docs/operations/fork-releases.md b/docs/operations/fork-releases.md new file mode 100644 index 000000000000..b47039bb296c --- /dev/null +++ b/docs/operations/fork-releases.md @@ -0,0 +1,71 @@ +# Releases and CI on a fork + +A fork can run CI, publish its own desktop and CLI releases, and have installs update from them. +Everything is driven by repository variables (**Settings → Secrets and variables → Actions → +Variables**). With none set, the workflows behave exactly as they do upstream. + +## Runners + +Upstream runs on Blacksmith. A fork without Blacksmith sets these to GitHub-hosted labels, which +are free for public repositories: + +| Variable | Example | +| ------------------------- | ------------------ | +| `T3CODE_LINUX_RUNNER` | `ubuntu-24.04` | +| `T3CODE_LINUX_ARM_RUNNER` | `ubuntu-24.04-arm` | +| `T3CODE_MACOS_RUNNER` | `macos-26` | +| `T3CODE_WINDOWS_RUNNER` | `windows-2025` | + +One variable covers every size of its platform, so jobs sized for 32 vCPU on Blacksmith run on a +standard runner and take longer. + +## Releases + +Set `T3CODE_RELEASES` to `true`. The release workflow then builds nightlies on its schedule and +stable or preview releases when dispatched, the same as upstream, and publishes them as GitHub +releases of the fork. Release builds take the fork's repository from `GITHUB_REPOSITORY`, so: + +- Desktop apps update from the fork's releases. +- `t3 update` and the server's update check read the fork's releases. Each release carries + `t3-release.json` with the migration number the update check needs. +- Each release attaches `install.sh` and `install.ps1` that install from the fork: + + ```bash + curl -fsSL https://github.com///releases/latest/download/install.sh | sh + ``` + + ```powershell + irm https://github.com///releases/latest/download/install.ps1 | iex + ``` + +Upstream-only publishers are skipped on a fork: AUR, the marketing site, and Discord. These run +only when the fork opts in: + +| Variable | Turns on | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `T3CODE_NPM_PUBLISH` | Publishing the `t3` npm package. A fork can't publish that name, so most leave it unset; servers then check GitHub releases. | +| `T3CODE_WEB_DEPLOY` | Building and deploying the hosted web app to Vercel (`VERCEL_*` secrets). | +| `T3CODE_RELEASE_APP` | The stable-release version bump pushed to `main` by a GitHub App (`RELEASE_APP_ID`, `RELEASE_APP_PRIVATE_KEY`). Without it, bump versions by hand after a stable release. | + +Builds are unsigned unless the signing secrets in [release](./release.md) are set. Unsigned +Windows and Linux builds install and update; macOS needs signing and notarization before it can +update itself. + +## T3 Connect + +Release builds bake in where T3 Connect lives. To use the hosted service, set these variables to +the public values the hosted app ships with: + +| Variable | Value | +| --------------------------- | ------------------------------ | +| `RELAY_DOMAIN` | `relay.t3.codes` | +| `CLERK_PUBLISHABLE_KEY` | `pk_live_Y2xlcmsudDMuY29kZXMk` | +| `CLERK_JWT_TEMPLATE` | `t3-relay` | +| `CLERK_CLI_OAUTH_CLIENT_ID` | `hzxSgY2cH10sDU2r` | + +Desktop sign-in to the hosted service works only while the app keeps the upstream app id and +`t3code://` scheme. To run your own Connect instead, see [Connect setup](./connect-setup.md) and +point these variables at it. + +The release workflow uses a `production` environment for these values; GitHub creates it on the +first run. diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 66777cc04447..14c13fcb6777 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -3,7 +3,25 @@ import * as Schema from "effect/Schema"; import { compareSemverVersions } from "./semver.ts"; -const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; +declare const __T3CODE_RELEASE_REPOSITORY__: string | undefined; +declare const __T3CODE_NPM_RELEASES__: string | undefined; + +const UPSTREAM_RELEASE_REPOSITORY = "pingdotgg/t3code"; + +/** The GitHub repository whose releases this build installs and updates from; a fork's CI sets its own. */ +export const CLI_RELEASE_REPOSITORY = + typeof __T3CODE_RELEASE_REPOSITORY__ === "string" && + /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(__T3CODE_RELEASE_REPOSITORY__) + ? __T3CODE_RELEASE_REPOSITORY__ + : UPSTREAM_RELEASE_REPOSITORY; + +/** Upstream also publishes the `t3` npm package; a fork does only when its build opts in. */ +export const CLI_RELEASES_ON_NPM = + CLI_RELEASE_REPOSITORY === UPSTREAM_RELEASE_REPOSITORY || + (typeof __T3CODE_NPM_RELEASES__ === "string" && __T3CODE_NPM_RELEASES__ === "true"); + +/** Each GitHub release carries this manifest, with the same fields as the npm `t3` package. */ +export const CLI_RELEASE_MANIFEST_ASSET = "t3-release.json"; export const CLI_RELEASES_PAGE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases`; export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; diff --git a/scripts/build-npm-launcher.ts b/scripts/build-npm-launcher.ts index bae124fb008a..7be1e65a3c06 100644 --- a/scripts/build-npm-launcher.ts +++ b/scripts/build-npm-launcher.ts @@ -47,7 +47,7 @@ export function npmLauncherPackageManifest(version: string, databaseMigration: n } /** Highest server migration id, from the `NNN_Name.ts` files the server registers. */ -const latestServerMigrationId = Effect.fn("latestServerMigrationId")(function* () { +export const latestServerMigrationId = Effect.fn("latestServerMigrationId")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = yield* path.fromFileUrl( diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index 7fe8d05386cd..7119e2e56e35 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -169,3 +169,17 @@ function firstNonEmpty(sources: readonly Environment[], ...names: readonly strin function readEnvFile(path: string): Record { return NodeFS.existsSync(path) ? NodeUtil.parseEnv(NodeFS.readFileSync(path, "utf8")) : {}; } + +/** + * Which GitHub releases a build installs and updates from. Actions sets GITHUB_REPOSITORY, so a + * fork's release builds point at the fork; local builds and tests keep upstream. + */ +export function releaseIdentityDefine(env: Environment = process.env) { + if (env.VITEST) return { __T3CODE_RELEASE_REPOSITORY__: '""', __T3CODE_NPM_RELEASES__: '""' }; + return { + __T3CODE_RELEASE_REPOSITORY__: JSON.stringify( + env.T3CODE_RELEASE_REPOSITORY?.trim() || env.GITHUB_REPOSITORY?.trim() || "", + ), + __T3CODE_NPM_RELEASES__: JSON.stringify(env.T3CODE_NPM_RELEASES?.trim() ?? ""), + }; +} diff --git a/scripts/write-release-manifest.ts b/scripts/write-release-manifest.ts new file mode 100644 index 000000000000..2f46cf1f8ecd --- /dev/null +++ b/scripts/write-release-manifest.ts @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Servers that update from GitHub releases instead of npm read this asset; see CLI_RELEASE_MANIFEST_ASSET. +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/cli"; + +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; + +import { latestServerMigrationId } from "./build-npm-launcher.ts"; + +const encodeJson = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown)); + +/** The fields npmReleaseDatabaseMigration reads, matching the npm `t3` manifest. */ +const writeReleaseManifest = Effect.fn("writeReleaseManifest")(function* (input: { + readonly version: string; + readonly output: string; +}) { + const fs = yield* FileSystem.FileSystem; + const manifest = { + version: input.version, + t3code: { databaseMigration: yield* latestServerMigrationId() }, + }; + yield* fs.writeFileString(input.output, `${yield* encodeJson(manifest)}\n`); + yield* Effect.log(`[release-manifest] Wrote ${input.output}`); +}); + +const command = Command.make( + "write-release-manifest", + { + version: Flag.String("version").pipe(Flag.withDescription("Exact release version.")), + output: Flag.String("output").pipe(Flag.withDefault("t3-release.json")), + }, + writeReleaseManifest, +).pipe(Command.withDescription("Write the release manifest servers read from GitHub releases.")); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide(Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer)), + NodeRuntime.runMain, + ); +}