Release #107
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| tags: | |
| - "v*.*.*" | |
| - "!v*-nightly.*" | |
| schedule: | |
| # Every three hours. The check_changes job skips the run when pylon has not | |
| # moved since the last nightly tag, so idle slots cost one short job, and | |
| # active days rebuild at most eight times. Runner minutes are free on this | |
| # public repository, which is why the once-a-day throttle from 61ba5899e | |
| # is gone; dispatch manually when a build is needed sooner than three hours. | |
| # Minute 7 rather than 0: GitHub delays scheduled runs most at the top of | |
| # the hour, where upstream measured a 13-minute median start delay. | |
| - cron: "7 */3 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| channel: | |
| description: "Release channel" | |
| required: false | |
| default: stable | |
| type: choice | |
| options: | |
| - stable | |
| - nightly | |
| version: | |
| description: "Release version (for example 1.2.3 or v1.2.3)" | |
| required: false | |
| type: string | |
| # Nightlies (scheduled and manually dispatched) share one group so overlapping | |
| # runs cannot build the same commit twice or publish out of order. Every stable | |
| # release keys off run_id instead, so it is always unique: a nightly never | |
| # blocks a real release, and a hotfix tag never queues behind the stable release | |
| # already in flight. Upstream collapses all stable runs into one shared lane, | |
| # which would have delayed a P0 tag by the better part of a build matrix. | |
| # | |
| # Nothing is cancelled mid-flight. `publish_cli` pushes to npm before `release` | |
| # creates the GitHub release, and a cancel in that window strands a published | |
| # CLI version with no matching desktop build — and npm will not take that | |
| # version number again. `queue: max` gives the nightly group 100 FIFO pending | |
| # slots instead of the default newest-wins single slot, so a nightly waiting on | |
| # its predecessor is never silently dropped; the ones with no new commits then | |
| # skip via check_changes when their turn comes. | |
| # | |
| # This supersedes the cancel-on-schedule policy from 8dd0d6e06, which existed | |
| # because a Blacksmith capacity outage let 23 nightlies stack up ~480 VM-hours | |
| # deep waiting for runners that never arrived. 124630c3f moved every workflow | |
| # onto GitHub-hosted runners, so that queue no longer forms. | |
| concurrency: | |
| group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || github.run_id }} | |
| cancel-in-progress: false | |
| queue: max | |
| permissions: | |
| contents: read | |
| id-token: none | |
| jobs: | |
| # Every job runs on GitHub-hosted standard runners, which are free on this | |
| # public repository. Heavy jobs pin an OS version (`ubuntu-24.04`, | |
| # `windows-2025`, `macos-26`) so a build cannot drift when `-latest` moves; | |
| # the short I/O-bound orchestration jobs use `ubuntu-latest` because nothing | |
| # in them is OS-sensitive. | |
| check_changes: | |
| name: Check for changes since last nightly | |
| if: github.event_name == 'schedule' | |
| runs-on: ubuntu-latest | |
| # Nothing cancels a wedged predecessor now that nightlies queue, and this | |
| # was the only job in the file relying on GitHub's 360-minute default. A | |
| # stuck checkout here would hold release-nightly across two cron slots. | |
| timeout-minutes: 5 | |
| outputs: | |
| has_changes: ${{ steps.check.outputs.has_changes }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - id: check | |
| name: Compare HEAD to last nightly tag | |
| run: | | |
| last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) | |
| if [[ -z "$last_nightly_tag" ]]; then | |
| echo "No previous nightly tag found. Proceeding with release." | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") | |
| head_sha=$(git rev-parse HEAD) | |
| if [[ "$last_nightly_sha" == "$head_sha" ]]; then | |
| echo "No changes since last nightly release ($last_nightly_tag). Skipping." | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Changes detected since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| preflight: | |
| name: Preflight | |
| needs: [check_changes] | |
| if: | | |
| !failure() && !cancelled() && | |
| (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') | |
| runs-on: ubuntu-24.04 | |
| # Runs the whole repo's check, typecheck, and test suites. | |
| timeout-minutes: 30 | |
| outputs: | |
| release_channel: ${{ steps.release_meta.outputs.release_channel }} | |
| version: ${{ steps.release_meta.outputs.version }} | |
| tag: ${{ steps.release_meta.outputs.tag }} | |
| release_name: ${{ steps.release_meta.outputs.name }} | |
| short_sha: ${{ steps.release_meta.outputs.short_sha }} | |
| previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} | |
| cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} | |
| is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} | |
| make_latest: ${{ steps.release_meta.outputs.make_latest }} | |
| ref: ${{ github.sha }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: true | |
| - id: release_meta | |
| name: Resolve release version | |
| shell: bash | |
| env: | |
| DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} | |
| DISPATCH_VERSION: ${{ github.event.inputs.version }} | |
| NIGHTLY_DATE: ${{ github.run_started_at }} | |
| NIGHTLY_SHA: ${{ github.sha }} | |
| NIGHTLY_RUN_NUMBER: ${{ github.run_number }} | |
| run: | | |
| if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then | |
| nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" | |
| node scripts/resolve-nightly-release.ts \ | |
| --date "$nightly_date" \ | |
| --run-number "$NIGHTLY_RUN_NUMBER" \ | |
| --sha "$NIGHTLY_SHA" \ | |
| --github-output | |
| echo "release_channel=nightly" >> "$GITHUB_OUTPUT" | |
| echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" | |
| echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| else | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| raw="${DISPATCH_VERSION}" | |
| if [[ -z "$raw" ]]; then | |
| echo "workflow_dispatch stable releases require the version input." >&2 | |
| exit 1 | |
| fi | |
| else | |
| raw="${GITHUB_REF_NAME}" | |
| fi | |
| version="${raw#v}" | |
| if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then | |
| echo "Invalid release version: $raw" >&2 | |
| exit 1 | |
| fi | |
| echo "release_channel=stable" >> "$GITHUB_OUTPUT" | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| echo "tag=v$version" >> "$GITHUB_OUTPUT" | |
| echo "name=Pylon v$version" >> "$GITHUB_OUTPUT" | |
| echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" | |
| if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "is_prerelease=false" >> "$GITHUB_OUTPUT" | |
| echo "make_latest=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| fi | |
| - id: previous_tag | |
| name: Resolve previous release tag | |
| run: | | |
| node scripts/resolve-previous-release-tag.ts \ | |
| --channel "${{ steps.release_meta.outputs.release_channel }}" \ | |
| --current-tag "${{ steps.release_meta.outputs.tag }}" \ | |
| --github-output | |
| quality: | |
| name: Release quality checks | |
| needs: [preflight] | |
| if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} | |
| runs-on: ubuntu-24.04 | |
| # `vp run test` runs the whole repository's suites serially here — the | |
| # same work ci.yml spreads over four jobs. On a 4-vCPU GitHub-hosted | |
| # runner the job lands around fifteen minutes (the Test step alone was | |
| # still running at 7m44s when a 10-minute ceiling cut it on 2026-08-26, | |
| # cancelling the first nightly after the runner migration), so give it | |
| # 2x headroom. Blacksmith's 8 vCPU did the Test step in 3m16s. | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.preflight.outputs.ref }} | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: true | |
| - name: Ensure Electron runtime is installed | |
| run: vp run --filter @t3tools/desktop ensure:electron | |
| - name: Check | |
| run: vp check | |
| - name: Typecheck | |
| run: vp run typecheck | |
| - name: Test | |
| run: vp run test | |
| relay_public_config: | |
| name: Resolve T3 Connect public config | |
| # Consumes only the commit SHA, never preflight's resolved version, so it | |
| # runs alongside preflight instead of waiting on it. The condition mirrors | |
| # preflight's own: check_changes is skipped on non-schedule events, and a | |
| # skipped job is neither failure nor success, so success() would be wrong. | |
| needs: [check_changes] | |
| if: | | |
| !failure() && !cancelled() && | |
| (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| environment: | |
| name: production | |
| outputs: | |
| clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} | |
| clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} | |
| clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} | |
| relay_url: ${{ steps.public_config.outputs.relay_url }} | |
| env: | |
| CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} | |
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} | |
| RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} | |
| CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} | |
| CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} | |
| CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.sha }} | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=t3code-relay... | |
| - id: relay_state | |
| name: Read production relay tracing config | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Connect is optional. Without Cloudflare credentials there is no relay | |
| # to read state from, so emit an empty config rather than failing the | |
| # whole release: the desktop apps build and auto-update without it. | |
| if [[ -z "${CLOUDFLARE_API_TOKEN:-}" || -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]]; then | |
| echo "Cloudflare credentials absent; building without Connect relay tracing." >&2 | |
| printf '# Connect is not configured; no relay client tracing.\n' \ | |
| > "$RUNNER_TEMP/relay-client-tracing.env" | |
| exit 0 | |
| fi | |
| vp run --filter t3code-relay deploy \ | |
| --stage prod \ | |
| --read-state \ | |
| --github-output \ | |
| --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" | |
| # Non-fatal on purpose, matching the same reasoning as the upload in | |
| # ci.yml. Actions storage is an org-wide quota that anything in the | |
| # account can exhaust, and when it is full every upload in every repo | |
| # fails. Connect is already optional here and the loader below degrades | |
| # to a build without relay tracing, so a storage condition must not take | |
| # down the release matrix — which is exactly what killed every nightly | |
| # from 2026-08-11 onward. | |
| - name: Upload relay client tracing config | |
| uses: actions/upload-artifact@v7 | |
| continue-on-error: true | |
| with: | |
| name: relay-client-tracing-config | |
| path: ${{ runner.temp }}/relay-client-tracing.env | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - id: public_config | |
| name: Resolve production relay public config | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| relay_domain="${RELAY_DOMAIN:-}" | |
| if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then | |
| relay_domain="relay.$RELAY_API_ZONE_NAME" | |
| fi | |
| # Connect is opt-in. A repository that has not configured Clerk and a | |
| # relay still produces working desktop builds; it just ships without | |
| # cloud sign-in. Partial configuration is treated as none, because | |
| # half-configured Connect fails at runtime in the user's app rather | |
| # than here, where the cause is still visible. | |
| required=( | |
| relay_domain | |
| CLERK_PUBLISHABLE_KEY | |
| CLERK_JWT_TEMPLATE | |
| CLERK_CLI_OAUTH_CLIENT_ID | |
| ) | |
| missing=() | |
| for name in "${required[@]}"; do | |
| if [[ -z "${!name:-}" ]]; then | |
| missing+=("$name") | |
| fi | |
| done | |
| if (( ${#missing[@]} > 0 )); then | |
| printf 'Connect is not configured (missing: %s); building without it.\n' "${missing[*]}" >&2 | |
| { | |
| echo "clerk_publishable_key=" | |
| echo "clerk_jwt_template=" | |
| echo "clerk_cli_oauth_client_id=" | |
| echo "relay_url=" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" | |
| echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" | |
| echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" | |
| echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" | |
| # node-pty publishes no Linux prebuilt and the WSL backend runs under the | |
| # distro's own (Linux) Node, which can't load the Windows/Electron binary. We | |
| # build the Linux pty.node here, on Linux, and hand it to the Windows packaging | |
| # job — the Windows artifact then ships a ready WSL backend binary with no | |
| # cross-compiling and no first-launch compiler/node-gyp/network on the user's | |
| # machine. node-pty is N-API, so one binary works across all WSL Node versions. | |
| build_wsl_node_pty: | |
| name: Build WSL node-pty (linux-x64) | |
| # Same gating as relay_public_config: only the commit SHA is needed here, | |
| # so this runs alongside preflight. See the condition comment there. | |
| needs: [check_changes] | |
| if: | | |
| !failure() && !cancelled() && | |
| (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.sha }} | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=t3... | |
| - name: Build node-pty linux-x64 prebuild | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Resolve node-pty from apps/server (where it's a dependency) and build | |
| # its native binary from source for Linux. node-addon-api resolves from | |
| # node-pty's own dependency tree, so node-gyp has everything it needs. | |
| pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" | |
| pty_dir="$(dirname "$pty_pkg")" | |
| ( cd "$pty_dir" && npx --yes node-gyp rebuild ) | |
| mkdir -p wsl-prebuild | |
| cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node | |
| file wsl-prebuild/pty.node | |
| # Consumed by the Windows build in this same run, so it only needs to | |
| # outlive the run. Left unset it inherited the 90-day repository default. | |
| - name: Upload node-pty linux-x64 prebuild | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: wsl-node-pty-x64 | |
| path: wsl-prebuild/pty.node | |
| if-no-files-found: error | |
| retention-days: 1 | |
| build: | |
| name: Build ${{ matrix.label }} | |
| # build_wsl_node_pty stays in `needs` so it runs first and its artifact is | |
| # available to download, but only the Windows matrix entry consumes it. We | |
| # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring | |
| # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux | |
| # builds. `!cancelled()` (not `!failure()`) lets the job run even when | |
| # build_wsl_node_pty failed; the Windows-only download step below then fails | |
| # that single platform if the prebuild is missing. | |
| needs: [preflight, relay_public_config, build_wsl_node_pty] | |
| if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} | |
| runs-on: ${{ matrix.runner }} | |
| # Electron packaging plus a Rust target; the macOS legs also cross-build | |
| # the second architecture. Generous on purpose: a ceiling costs nothing on | |
| # a fast runner, and a run that dies at the limit wastes the whole matrix. | |
| timeout-minutes: 90 | |
| 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 }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - label: macOS arm64 | |
| runner: macos-26 | |
| platform: mac | |
| target: dmg | |
| arch: arm64 | |
| rust_target: aarch64-apple-darwin | |
| resource_key: darwin-arm64 | |
| - label: macOS x64 | |
| runner: macos-26 | |
| platform: mac | |
| target: dmg | |
| arch: x64 | |
| rust_target: x86_64-apple-darwin | |
| resource_key: darwin-x64 | |
| - label: Linux x64 | |
| runner: ubuntu-24.04 | |
| platform: linux | |
| target: AppImage | |
| arch: x64 | |
| rust_target: x86_64-unknown-linux-gnu | |
| resource_key: linux-x64 | |
| - label: Windows x64 | |
| runner: windows-2025 | |
| platform: win | |
| target: nsis | |
| arch: x64 | |
| rust_target: x86_64-pc-windows-msvc | |
| resource_key: win32-x64 | |
| # - label: Windows arm64 | |
| # runner: windows-11-arm | |
| # platform: win | |
| # target: nsis | |
| # arch: arm64 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.preflight.outputs.ref }} | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: ${{ matrix.platform != 'win' }} | |
| run-install: false | |
| - name: Resolve Windows package cache path | |
| if: matrix.platform == 'win' | |
| id: package_cache_path | |
| shell: pwsh | |
| run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' | |
| - name: Cache Windows packages | |
| if: matrix.platform == 'win' | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ steps.package_cache_path.outputs.path }} | |
| key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} | |
| - name: Install desktop dependencies | |
| run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... | |
| - name: Cache resource monitor | |
| id: resource_monitor_cache | |
| uses: actions/cache@v6 | |
| with: | |
| path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} | |
| key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} | |
| - name: Setup Rust | |
| if: steps.resource_monitor_cache.outputs.cache-hit != 'true' | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.rust_target }} | |
| - name: Download relay client tracing config | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: relay-client-tracing-config | |
| path: ${{ runner.temp }}/relay-client-tracing | |
| - name: Load relay client tracing config | |
| shell: bash | |
| run: | | |
| config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" | |
| # The upload is best-effort against a shared storage quota, so the | |
| # file can be missing entirely. Building without relay tracing is the | |
| # correct way to degrade, but it is worth surfacing: a silently | |
| # untraced production build should not look identical to a healthy one. | |
| if [[ ! -f "$config_path" ]]; then | |
| echo "::warning::Relay client tracing config artifact unavailable; building without Connect tracing." | |
| exit 0 | |
| fi | |
| # Present but carrying no variables when Connect is not configured at | |
| # all, which is a supported setup rather than a problem. | |
| if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path"; then | |
| echo "No relay client tracing config; continuing without it." >&2 | |
| exit 0 | |
| fi | |
| tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" | |
| if [[ -n "$tracing_token" ]]; then | |
| echo "::add-mask::$tracing_token" | |
| fi | |
| cat "$config_path" >> "$GITHUB_ENV" | |
| - name: Align package versions to release version | |
| run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | |
| - name: Download WSL node-pty prebuild | |
| if: matrix.platform == 'win' | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: wsl-node-pty-x64 | |
| path: wsl-prebuild | |
| - name: Install Spectre-mitigated MSVC libs | |
| if: matrix.platform == 'win' | |
| shell: pwsh | |
| run: | | |
| $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" | |
| $installPath = & $vswhere -products * -latest -property installationPath | |
| $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" | |
| $proc = Start-Process -FilePath $setupExe ` | |
| -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` | |
| "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` | |
| -Wait -PassThru -NoNewWindow | |
| if ($null -eq $proc -or $proc.ExitCode -ne 0) { | |
| $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } | |
| Write-Error "Visual Studio Installer failed with exit code $code" | |
| exit $code | |
| } | |
| - name: Install ImageMagick | |
| if: matrix.platform == 'linux' | |
| shell: bash | |
| run: | | |
| if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then | |
| sudo apt-get update | |
| sudo apt-get install -y imagemagick | |
| fi | |
| if command -v magick >/dev/null 2>&1; then | |
| magick -version | |
| else | |
| convert -version | |
| fi | |
| - name: Prepare Azure Trusted Signing | |
| if: matrix.platform == 'win' | |
| shell: pwsh | |
| env: | |
| AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} | |
| AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} | |
| AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} | |
| AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} | |
| AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} | |
| AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} | |
| run: | | |
| $ErrorActionPreference = "Stop" | |
| $requiredSecrets = @( | |
| $env:AZURE_TENANT_ID, | |
| $env:AZURE_CLIENT_ID, | |
| $env:AZURE_CLIENT_SECRET, | |
| $env:AZURE_TRUSTED_SIGNING_ENDPOINT, | |
| $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, | |
| $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, | |
| $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME | |
| ) | |
| if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { | |
| Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." | |
| exit 0 | |
| } | |
| try { | |
| Install-PackageProvider ` | |
| -Name NuGet ` | |
| -MinimumVersion 2.8.5.201 ` | |
| -Force ` | |
| -Scope CurrentUser ` | |
| -ErrorAction Stop | |
| } catch { | |
| Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" | |
| } | |
| Install-Module ` | |
| -Name TrustedSigning ` | |
| -MinimumVersion 0.5.0 ` | |
| -Force ` | |
| -AllowClobber ` | |
| -Repository PSGallery ` | |
| -Scope CurrentUser ` | |
| -ErrorAction Stop | |
| Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force | |
| Get-Command Invoke-TrustedSigning -ErrorAction Stop | |
| $moduleRoots = @( | |
| [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), | |
| [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), | |
| [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), | |
| [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") | |
| ) | |
| $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | | |
| Where-Object { $_ -and (Test-Path $_) } | | |
| Select-Object -Unique | |
| "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV | |
| - name: Build desktop artifact | |
| shell: bash | |
| env: | |
| # Where installed apps look for updates. Must match wherever the | |
| # release job actually publishes, or builds go looking in the wrong | |
| # place forever. Falls back to this repository when unset. | |
| PYLON_DESKTOP_UPDATE_REPOSITORY: ${{ vars.PYLON_DESKTOP_UPDATE_REPOSITORY }} | |
| T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} | |
| CSC_LINK: ${{ secrets.CSC_LINK }} | |
| CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} | |
| APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} | |
| APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} | |
| APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} | |
| APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} | |
| MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} | |
| T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }} | |
| AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} | |
| AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} | |
| AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} | |
| AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} | |
| AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} | |
| AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} | |
| run: | | |
| args=( | |
| --platform "${{ matrix.platform }}" | |
| --target "${{ matrix.target }}" | |
| --arch "${{ matrix.arch }}" | |
| --build-version "${{ needs.preflight.outputs.version }}" | |
| --verbose | |
| ) | |
| has_all() { | |
| for value in "$@"; do | |
| if [[ -z "$value" ]]; then | |
| return 1 | |
| fi | |
| done | |
| return 0 | |
| } | |
| if [[ "${{ matrix.platform }}" == "mac" ]]; then | |
| if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then | |
| key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" | |
| printf '%s' "$APPLE_API_KEY" > "$key_path" | |
| export APPLE_API_KEY="$key_path" | |
| # Passkeys are a separate, optional capability. Signing must not | |
| # depend on them: an unsigned macOS build cannot auto-update at | |
| # all, because Squirrel refuses to replace a bundle without a | |
| # valid signature — and it fails silently when it does. | |
| if has_all "$APPLE_TEAM_ID" "$MACOS_PROVISIONING_PROFILE"; then | |
| profile_path="$RUNNER_TEMP/pylon.provisionprofile" | |
| printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" | |
| security cms -D -i "$profile_path" >/dev/null | |
| export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" | |
| export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" | |
| echo "macOS passkey entitlements enabled." | |
| elif has_all "$APPLE_TEAM_ID" || has_all "$MACOS_PROVISIONING_PROFILE"; then | |
| echo "macOS passkey signing needs both APPLE_TEAM_ID and MACOS_PROVISIONING_PROFILE; only one is set." >&2 | |
| exit 1 | |
| else | |
| echo "macOS passkey entitlements disabled (no provisioning profile configured)." | |
| fi | |
| echo "macOS signing enabled." | |
| args+=(--signed) | |
| else | |
| echo "macOS signing disabled (missing one or more Apple signing secrets)." | |
| fi | |
| elif [[ "${{ matrix.platform }}" == "win" ]]; then | |
| # Bundle the Linux node-pty binary built by the build_wsl_node_pty job | |
| # so the packaged WSL backend ships a ready binary (no first-launch | |
| # compile). Required for a working WSL backend on Windows. | |
| args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") | |
| if has_all \ | |
| "$AZURE_TENANT_ID" \ | |
| "$AZURE_CLIENT_ID" \ | |
| "$AZURE_CLIENT_SECRET" \ | |
| "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ | |
| "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ | |
| "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ | |
| "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then | |
| echo "Windows signing enabled (Azure Trusted Signing)." | |
| args+=(--signed) | |
| else | |
| echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." | |
| fi | |
| else | |
| echo "Signing disabled for ${{ matrix.platform }}." | |
| fi | |
| vp run dist:desktop:artifact "${args[@]}" | |
| - name: Collect release assets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p release-publish | |
| shopt -s nullglob | |
| for pattern in \ | |
| "release/*.dmg" \ | |
| "release/*.zip" \ | |
| "release/*.AppImage" \ | |
| "release/*.exe" \ | |
| "release/*.blockmap" \ | |
| "release/*.yml"; do | |
| for file in $pattern; do | |
| cp "$file" release-publish/ | |
| done | |
| done | |
| if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then | |
| shopt -s nullglob | |
| for manifest in release-publish/*-mac.yml; do | |
| mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" | |
| done | |
| fi | |
| # Enable if Windows arm64 builds are enabled. | |
| # Windows updater metadata is channel-specific (for example | |
| # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the | |
| # release job can merge matching arm64/x64 manifests back into one | |
| # canonical manifest per channel. | |
| # if [[ "${{ matrix.platform }}" == "win" ]]; then | |
| # shopt -s nullglob | |
| # for manifest in release-publish/*.yml; do | |
| # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" | |
| # done | |
| # fi | |
| - name: Collect resource monitor | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| binary_name="t3-resource-monitor" | |
| if [[ "${{ matrix.platform }}" == "win" ]]; then | |
| binary_name="${binary_name}.exe" | |
| fi | |
| source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}" | |
| target_dir="resource-monitor-publish/${{ matrix.resource_key }}" | |
| mkdir -p "$target_dir" | |
| cp "$source_path" "$target_dir/$binary_name" | |
| # These are intra-run handoff to `publish_release`, which downloads | |
| # `desktop-*` and attaches them to the GitHub Release — that Release is the | |
| # durable copy, not the artifact. Retention here has to be sized against | |
| # the org's included Actions storage, because overrunning it fails *every* | |
| # upload in the org, reddening unrelated PRs: the Team plan includes | |
| # 2 GB-month, and GitHub meters in GB-hours, so the budget is 2 * 730 = | |
| # 1460 GB-hours. At ~915 MB per run across the four platforms, a daily | |
| # nightly costs 0.915 * retention_days * 730 GB-hours. The repository | |
| # default of 90 days reached ~14 GB/day and exhausted the quota; 7 days | |
| # still bills ~4665 GB-hours, over triple the budget. One day costs ~666 | |
| # and leaves room for PR CI and tagged stable releases. Re-running a | |
| # failed publish job against an older build is not worth the outage — | |
| # re-dispatch the workflow instead. | |
| - name: Upload build artifacts | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: desktop-${{ matrix.platform }}-${{ matrix.arch }} | |
| path: release-publish/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - name: Upload resource monitor | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: resource-monitor-${{ matrix.resource_key }} | |
| path: resource-monitor-publish/${{ matrix.resource_key }}/* | |
| if-no-files-found: error | |
| # Consumed by the CLI publish job in this same run, so it only has to | |
| # outlive the matrix. The old 7-day window was the one release | |
| # artifact held above the minimum, and Actions storage is billed by | |
| # GB-hour against a quota this repo already sits on top of. | |
| retention-days: 1 | |
| publish_cli: | |
| name: Publish CLI to npm | |
| needs: [preflight, relay_public_config, quality, build] | |
| # Opt-in: publishing the CLI needs an npm package this repository owns and | |
| # trusted publishing configured for it. Set the PUBLISH_CLI_TO_NPM | |
| # repository variable to "true" once both exist. | |
| if: ${{ !failure() && !cancelled() && vars.PUBLISH_CLI_TO_NPM == 'true' && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| id-token: write | |
| 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 }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.preflight.outputs.ref }} | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=t3... | |
| - --filter=@t3tools/web... | |
| - --filter=@t3tools/scripts... | |
| - name: Download relay client tracing config | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: relay-client-tracing-config | |
| path: ${{ runner.temp }}/relay-client-tracing | |
| - name: Load relay client tracing config | |
| shell: bash | |
| run: | | |
| config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" | |
| # The upload is best-effort against a shared storage quota, so the | |
| # file can be missing entirely. Building without relay tracing is the | |
| # correct way to degrade, but it is worth surfacing: a silently | |
| # untraced production build should not look identical to a healthy one. | |
| if [[ ! -f "$config_path" ]]; then | |
| echo "::warning::Relay client tracing config artifact unavailable; building without Connect tracing." | |
| exit 0 | |
| fi | |
| # Present but carrying no variables when Connect is not configured at | |
| # all, which is a supported setup rather than a problem. | |
| if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path"; then | |
| echo "No relay client tracing config; continuing without it." >&2 | |
| exit 0 | |
| fi | |
| tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" | |
| if [[ -n "$tracing_token" ]]; then | |
| echo "::add-mask::$tracing_token" | |
| fi | |
| cat "$config_path" >> "$GITHUB_ENV" | |
| - name: Align package versions to release version | |
| run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | |
| # The t3 build task depends on @t3tools/web#build, so the web client is | |
| # built (once) as part of this step. | |
| - name: Build CLI package | |
| run: vp run --filter t3 build | |
| - name: Download resource monitors | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: resource-monitor-* | |
| path: ${{ runner.temp }}/resource-monitors | |
| - name: Bundle resource monitors into CLI package | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do | |
| resource_key="${artifact_dir##*/resource-monitor-}" | |
| target_dir="apps/server/dist/resource-monitor/${resource_key}" | |
| mkdir -p "$target_dir" | |
| cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" | |
| chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true | |
| done | |
| - name: Publish CLI package | |
| run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose | |
| release: | |
| name: Publish GitHub Release | |
| needs: [preflight, quality, build, publish_cli] | |
| # The desktop release is the point of this workflow, so a skipped CLI | |
| # publish must not withhold it. A failed one still does. | |
| # `quality` must stay in `needs`: it is the only job running check and test, | |
| # and the CLI publish that also depends on it is opt-in, so without this the | |
| # desktop release ships from a commit whose suites are red. | |
| if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' && (needs.publish_cli.result == 'success' || needs.publish_cli.result == 'skipped') }} | |
| runs-on: ubuntu-latest | |
| # Uploading the desktop artifacts regularly outruns 10 minutes, and it does | |
| # so after a 90-minute build (pingdotgg/t3code#6034). | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.preflight.outputs.ref }} | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=@t3tools/scripts... | |
| - name: Download all desktop artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: desktop-* | |
| merge-multiple: true | |
| path: release-assets | |
| - name: Merge macOS updater manifests | |
| run: | | |
| shopt -s nullglob | |
| for x64_manifest in release-assets/*-mac-x64.yml; do | |
| arm64_manifest="${x64_manifest%-x64.yml}.yml" | |
| if [[ -f "$arm64_manifest" ]]; then | |
| node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" | |
| rm -f "$x64_manifest" | |
| fi | |
| done | |
| # - name: Merge Windows updater manifests | |
| # run: | | |
| # shopt -s nullglob | |
| # found_windows_manifest=false | |
| # for x64_manifest in release-assets/*-win-x64.yml; do | |
| # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then | |
| # continue | |
| # fi | |
| # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" | |
| # output_manifest="${x64_manifest/-win-x64.yml/.yml}" | |
| # if [[ ! -f "$arm64_manifest" ]]; then | |
| # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 | |
| # exit 1 | |
| # fi | |
| # found_windows_manifest=true | |
| # node scripts/merge-update-manifests.ts --platform win \ | |
| # "$arm64_manifest" \ | |
| # "$x64_manifest" \ | |
| # "$output_manifest" | |
| # rm -f "$arm64_manifest" "$x64_manifest" | |
| # done | |
| # if [[ "$found_windows_manifest" != true ]]; then | |
| # echo "No Windows updater manifests found to merge." >&2 | |
| # exit 1 | |
| # fi | |
| # Same-repository publication. Used when no separate releases repository | |
| # is configured, and the only path that can generate release notes: | |
| # note generation compares tags, which only exist alongside the source. | |
| - name: Publish release | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY == '' && needs.preflight.outputs.previous_tag != '' | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| tag_name: ${{ needs.preflight.outputs.tag }} | |
| target_commitish: ${{ needs.preflight.outputs.ref }} | |
| name: ${{ needs.preflight.outputs.release_name }} | |
| generate_release_notes: true | |
| previous_tag: ${{ needs.preflight.outputs.previous_tag }} | |
| prerelease: ${{ needs.preflight.outputs.is_prerelease }} | |
| make_latest: ${{ needs.preflight.outputs.make_latest }} | |
| files: | | |
| release-assets/*.dmg | |
| release-assets/*.zip | |
| release-assets/*.AppImage | |
| release-assets/*.exe | |
| release-assets/*.blockmap | |
| release-assets/*.yml | |
| fail_on_unmatched_files: true | |
| token: ${{ github.token }} | |
| - name: Publish first release | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY == '' && needs.preflight.outputs.previous_tag == '' | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| tag_name: ${{ needs.preflight.outputs.tag }} | |
| target_commitish: ${{ needs.preflight.outputs.ref }} | |
| name: ${{ needs.preflight.outputs.release_name }} | |
| generate_release_notes: true | |
| prerelease: ${{ needs.preflight.outputs.is_prerelease }} | |
| make_latest: ${{ needs.preflight.outputs.make_latest }} | |
| files: | | |
| release-assets/*.dmg | |
| release-assets/*.zip | |
| release-assets/*.AppImage | |
| release-assets/*.exe | |
| release-assets/*.blockmap | |
| release-assets/*.yml | |
| fail_on_unmatched_files: true | |
| token: ${{ github.token }} | |
| # Cross-repository publication. Auto-update reads release assets over the | |
| # public GitHub API, which cannot see a private repository's releases, so | |
| # the artifacts are published to a public repository instead. | |
| # | |
| # Release notes are not generated here: the tag and the commit history | |
| # they would be derived from live in the source repository, not this one. | |
| # Generating them would also republish private commit subjects in public. | |
| - name: Check releases repository credentials | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY != '' | |
| shell: bash | |
| env: | |
| RELEASES_REPO_TOKEN: ${{ secrets.RELEASES_REPO_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${RELEASES_REPO_TOKEN:-}" ]]; then | |
| echo "PYLON_DESKTOP_UPDATE_REPOSITORY is set to '${{ vars.PYLON_DESKTOP_UPDATE_REPOSITORY }}' but the RELEASES_REPO_TOKEN secret is missing." >&2 | |
| echo "Builds already embed that repository as their update feed, so publishing anywhere else would strand them." >&2 | |
| exit 1 | |
| fi | |
| - name: Publish release to the releases repository | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY != '' | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| repository: ${{ vars.PYLON_DESKTOP_UPDATE_REPOSITORY }} | |
| tag_name: ${{ needs.preflight.outputs.tag }} | |
| name: ${{ needs.preflight.outputs.release_name }} | |
| body: | | |
| Pylon ${{ needs.preflight.outputs.version }} (${{ needs.preflight.outputs.release_channel }}), built from `${{ needs.preflight.outputs.short_sha || needs.preflight.outputs.ref }}`. | |
| Download the file for your platform below. Installed apps on this | |
| channel update themselves. | |
| prerelease: ${{ needs.preflight.outputs.is_prerelease }} | |
| make_latest: ${{ needs.preflight.outputs.make_latest }} | |
| files: | | |
| release-assets/*.dmg | |
| release-assets/*.zip | |
| release-assets/*.AppImage | |
| release-assets/*.exe | |
| release-assets/*.blockmap | |
| release-assets/*.yml | |
| fail_on_unmatched_files: true | |
| token: ${{ secrets.RELEASES_REPO_TOKEN }} | |
| # Cross-repository publication creates the tag in the releases repository, | |
| # so this one never gains it. The nightly change check looks for those | |
| # tags here to decide whether anything has changed, finds none, and | |
| # therefore rebuilds all four platforms on every scheduled run forever — | |
| # even when the commit is identical to the last release. Recording the tag | |
| # restores that check. | |
| - name: Record the release tag in this repository | |
| if: vars.PYLON_DESKTOP_UPDATE_REPOSITORY != '' | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.preflight.outputs.tag }} | |
| RELEASE_SHA: ${{ needs.preflight.outputs.ref }} | |
| run: | | |
| set -euo pipefail | |
| if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" >/dev/null 2>&1; then | |
| echo "Tag $RELEASE_TAG already recorded." | |
| exit 0 | |
| fi | |
| gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" \ | |
| -f "ref=refs/tags/$RELEASE_TAG" \ | |
| -f "sha=$RELEASE_SHA" >/dev/null | |
| echo "Recorded $RELEASE_TAG at $RELEASE_SHA." | |
| deploy_web: | |
| name: Deploy hosted web app | |
| needs: [preflight, relay_public_config, release] | |
| # Opt-in: the hosted web app needs a Vercel project and its domains. Set | |
| # the DEPLOY_HOSTED_WEB repository variable to "true" once they exist. | |
| if: ${{ !failure() && !cancelled() && vars.DEPLOY_HOSTED_WEB == 'true' && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| 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 }} | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | |
| VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | |
| T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} | |
| T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} | |
| T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} | |
| VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.preflight.outputs.ref }} | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=@t3tools/scripts... | |
| - --filter=@t3tools/web... | |
| - name: Download relay client tracing config | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: relay-client-tracing-config | |
| path: ${{ runner.temp }}/relay-client-tracing | |
| - name: Load relay client tracing config | |
| shell: bash | |
| run: | | |
| config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" | |
| # The upload is best-effort against a shared storage quota, so the | |
| # file can be missing entirely. Building without relay tracing is the | |
| # correct way to degrade, but it is worth surfacing: a silently | |
| # untraced production build should not look identical to a healthy one. | |
| if [[ ! -f "$config_path" ]]; then | |
| echo "::warning::Relay client tracing config artifact unavailable; building without Connect tracing." | |
| exit 0 | |
| fi | |
| # Present but carrying no variables when Connect is not configured at | |
| # all, which is a supported setup rather than a problem. | |
| if ! grep -q '^T3CODE_RELAY_CLIENT_OTLP_TRACES_' "$config_path"; then | |
| echo "No relay client tracing config; continuing without it." >&2 | |
| exit 0 | |
| fi | |
| tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" | |
| if [[ -n "$tracing_token" ]]; then | |
| echo "::add-mask::$tracing_token" | |
| fi | |
| cat "$config_path" >> "$GITHUB_ENV" | |
| - name: Align package versions to release version | |
| run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | |
| - name: Refresh release lockfile | |
| run: vp install --lockfile-only --ignore-scripts | |
| - name: Deploy and alias channel | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then | |
| echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 | |
| exit 1 | |
| fi | |
| # Defaults must match the routing hosts in apps/web/vercel.ts. | |
| router_url="${T3CODE_WEB_ROUTER_URL:-https://app.pylon-code.com}" | |
| latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.pylon-code.com}" | |
| nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.pylon-code.com}" | |
| router_domain="${router_url#http://}" | |
| router_domain="${router_domain#https://}" | |
| router_domain="${router_domain%%/*}" | |
| if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then | |
| channel_domain="$latest_domain" | |
| channel_name="latest" | |
| else | |
| channel_domain="$nightly_domain" | |
| channel_name="nightly" | |
| fi | |
| vercel_scope="${VERCEL_TEAM_SLUG:-$VERCEL_ORG_ID}" | |
| vercel_scope_args=(--scope "$vercel_scope") | |
| echo "Deploying hosted web app for $channel_name channel." | |
| deployment_url="$( | |
| vp dlx vercel@53.1.1 deploy \ | |
| --archive=tgz \ | |
| --prod \ | |
| --skip-domain \ | |
| --yes \ | |
| --token "$VERCEL_TOKEN" \ | |
| "${vercel_scope_args[@]}" \ | |
| --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ | |
| --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ | |
| --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ | |
| --build-env "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=${T3CODE_CLERK_CLI_OAUTH_CLIENT_ID:-}" \ | |
| --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ | |
| --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \ | |
| --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \ | |
| --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \ | |
| --build-env "VITE_HOSTED_APP_URL=$router_url" \ | |
| --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" | |
| )" | |
| # `vercel alias set` resolves the target against account-level domains | |
| # and reports "You don't have access to the domain" for a domain that | |
| # is attached to the project instead — which is how these are held. | |
| # The REST alias endpoint accepts project domains directly. | |
| alias_deployment() { | |
| local url="${1#https://}" | |
| local domain="$2" | |
| local dep_id | |
| dep_id="$( | |
| curl -sS --fail-with-body \ | |
| -H "Authorization: Bearer $VERCEL_TOKEN" \ | |
| "https://api.vercel.com/v13/deployments/${url}?teamId=${VERCEL_ORG_ID}" | | |
| node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const j=JSON.parse(s);if(!j.id){console.error(j.error?.message??"deployment id missing");process.exit(1)}process.stdout.write(j.id)})' | |
| )" | |
| echo "Aliasing $url ($dep_id) to $domain." | |
| curl -sS --fail-with-body -X POST \ | |
| -H "Authorization: Bearer $VERCEL_TOKEN" \ | |
| -H "Content-Type: application/json" \ | |
| -d "{\"alias\":\"${domain}\"}" \ | |
| "https://api.vercel.com/v2/deployments/${dep_id}/aliases?teamId=${VERCEL_ORG_ID}" | | |
| node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const j=JSON.parse(s);if(j.error){console.error("alias failed:",j.error.message??j.error.code);process.exit(1)}console.log("aliased ->",j.alias)})' | |
| } | |
| alias_deployment "$deployment_url" "$channel_domain" | |
| if [[ "$channel_name" == "latest" && -n "$router_domain" && "$router_domain" != "$channel_domain" ]]; then | |
| alias_deployment "$deployment_url" "$router_domain" | |
| fi | |
| finalize: | |
| name: Finalize release | |
| # Opt-in: pushes a version-bump commit back to the product branch through a | |
| # GitHub App. Set the FINALIZE_RELEASE_COMMIT repository variable to "true" | |
| # once the app is installed and its credentials are stored. | |
| if: ${{ !failure() && !cancelled() && vars.FINALIZE_RELEASE_COMMIT == 'true' && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} | |
| needs: [preflight, release] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - id: app_token | |
| name: Mint release app token | |
| uses: actions/create-github-app-token@v2 | |
| with: | |
| app-id: ${{ secrets.RELEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| # Pylon's product branch. The inherited `main` is not it. | |
| ref: pylon | |
| fetch-depth: 0 | |
| token: ${{ steps.app_token.outputs.token }} | |
| persist-credentials: true | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - id: app_bot | |
| name: Resolve GitHub App bot identity | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| APP_SLUG: ${{ steps.app_token.outputs.app-slug }} | |
| run: | | |
| user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" | |
| echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" | |
| echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=@t3tools/scripts... | |
| - --filter=@t3tools/oxlint-plugin-t3code... | |
| - id: update_versions | |
| name: Update version strings | |
| env: | |
| RELEASE_VERSION: ${{ needs.preflight.outputs.version }} | |
| run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" --github-output | |
| - name: Format package.json files | |
| if: steps.update_versions.outputs.changed == 'true' | |
| run: vp fmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json | |
| - name: Refresh lockfile | |
| if: steps.update_versions.outputs.changed == 'true' | |
| run: vp install --lockfile-only --ignore-scripts | |
| - name: Commit and push version bump | |
| if: steps.update_versions.outputs.changed == 'true' | |
| shell: bash | |
| env: | |
| RELEASE_TAG: ${{ needs.preflight.outputs.tag }} | |
| run: | | |
| if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml; then | |
| echo "No version changes to commit." | |
| exit 0 | |
| fi | |
| git config user.name "${{ steps.app_bot.outputs.name }}" | |
| git config user.email "${{ steps.app_bot.outputs.email }}" | |
| git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml | |
| git commit -m "chore(release): prepare $RELEASE_TAG" | |
| git push origin HEAD:pylon | |
| announce_discord: | |
| name: Announce release on Discord | |
| if: | | |
| always() && !cancelled() && | |
| needs.preflight.result == 'success' && | |
| needs.relay_public_config.result == 'success' && | |
| needs.release.result == 'success' && | |
| vars.ANNOUNCE_RELEASE_ON_DISCORD == 'true' && | |
| (needs.deploy_web.result == 'success' || needs.deploy_web.result == 'skipped') && | |
| (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') | |
| needs: [preflight, relay_public_config, release, deploy_web, finalize] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.preflight.outputs.ref }} | |
| sparse-checkout: | | |
| /* | |
| !/.repos/ | |
| sparse-checkout-cone-mode: false | |
| - name: Setup Vite+ | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=@t3tools/scripts... | |
| - name: Announce prerelease on Discord | |
| if: needs.preflight.outputs.is_prerelease == 'true' | |
| continue-on-error: true | |
| env: | |
| DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_NIGHTLY_ROLE_ID }} | |
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} | |
| run: | | |
| node scripts/notify-discord-release.ts prerelease \ | |
| --role-id "$DISCORD_MENTION_ROLE_ID" \ | |
| --release-name "${{ needs.preflight.outputs.release_name }}" \ | |
| --release-version "${{ needs.preflight.outputs.version }}" \ | |
| --tag "${{ needs.preflight.outputs.tag }}" \ | |
| --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" | |
| - name: Announce latest release on Discord | |
| if: needs.preflight.outputs.make_latest == 'true' | |
| continue-on-error: true | |
| env: | |
| DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_RELEASE_LATEST_ROLE_ID }} | |
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} | |
| run: | | |
| node scripts/notify-discord-release.ts latest \ | |
| --role-id "$DISCORD_MENTION_ROLE_ID" \ | |
| --release-name "${{ needs.preflight.outputs.release_name }}" \ | |
| --release-version "${{ needs.preflight.outputs.version }}" \ | |
| --tag "${{ needs.preflight.outputs.tag }}" \ | |
| --release-url "https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.tag }}" |