| author | |
| committer | |
| log | 409ff90a1401c0b8e2bef05d73b3be57ff9bc669 |
| tree | d74b22eaea5ac7a32e6913438ff3d87a9f9651de |
| parent | 0d280262f6c9dcaf529376b2007dc7447f86fa3c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>49 files changed, 1221 insertions(+), 149 deletions(-)
.github/workflows/ci.yml+3| ... | ... | @@ -92,5 +92,8 @@ jobs: |
| 92 | 92 | with: |
| 93 | 93 | node-version-file: package.json |
| 94 | 94 | |
| 95 | - name: Install dependencies | |
| 96 | run: bun install --frozen-lockfile | |
| 97 | ||
| 95 | 98 | - name: Exercise release-only workflow steps |
| 96 | 99 | run: node scripts/release-smoke.ts |
.github/workflows/release.yml+119-39| ... | ... | @@ -1,14 +1,24 @@ |
| 1 | name: Release Desktop | |
| 1 | name: Release | |
| 2 | 2 | |
| 3 | 3 | on: |
| 4 | 4 | push: |
| 5 | 5 | tags: |
| 6 | 6 | - "v*.*.*" |
| 7 | schedule: | |
| 8 | - cron: "0 9 * * *" | |
| 7 | 9 | workflow_dispatch: |
| 8 | 10 | inputs: |
| 11 | channel: | |
| 12 | description: "Release channel" | |
| 13 | required: false | |
| 14 | default: stable | |
| 15 | type: choice | |
| 16 | options: | |
| 17 | - stable | |
| 18 | - nightly | |
| 9 | 19 | version: |
| 10 | 20 | description: "Release version (for example 1.2.3 or v1.2.3)" |
| 11 | required: true | |
| 21 | required: false | |
| 12 | 22 | type: string |
| 13 | 23 | |
| 14 | 24 | permissions: |
| ... | ... | @@ -21,40 +31,21 @@ jobs: |
| 21 | 31 | runs-on: ubuntu-24.04 |
| 22 | 32 | timeout-minutes: 10 |
| 23 | 33 | outputs: |
| 34 | release_channel: ${{ steps.release_meta.outputs.release_channel }} | |
| 24 | 35 | version: ${{ steps.release_meta.outputs.version }} |
| 25 | 36 | tag: ${{ steps.release_meta.outputs.tag }} |
| 37 | release_name: ${{ steps.release_meta.outputs.name }} | |
| 38 | short_sha: ${{ steps.release_meta.outputs.short_sha }} | |
| 39 | previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} | |
| 40 | cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} | |
| 26 | 41 | is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} |
| 27 | 42 | make_latest: ${{ steps.release_meta.outputs.make_latest }} |
| 28 | 43 | ref: ${{ github.sha }} |
| 29 | 44 | steps: |
| 30 | 45 | - name: Checkout |
| 31 | 46 | uses: actions/checkout@v6 |
| 32 | ||
| 33 | - id: release_meta | |
| 34 | name: Resolve release version | |
| 35 | shell: bash | |
| 36 | run: | | |
| 37 | if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| 38 | raw="${{ github.event.inputs.version }}" | |
| 39 | else | |
| 40 | raw="${GITHUB_REF_NAME}" | |
| 41 | fi | |
| 42 | ||
| 43 | version="${raw#v}" | |
| 44 | if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then | |
| 45 | echo "Invalid release version: $raw" >&2 | |
| 46 | exit 1 | |
| 47 | fi | |
| 48 | ||
| 49 | echo "version=$version" >> "$GITHUB_OUTPUT" | |
| 50 | echo "tag=v$version" >> "$GITHUB_OUTPUT" | |
| 51 | if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| 52 | echo "is_prerelease=false" >> "$GITHUB_OUTPUT" | |
| 53 | echo "make_latest=true" >> "$GITHUB_OUTPUT" | |
| 54 | else | |
| 55 | echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| 56 | echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| 57 | fi | |
| 47 | with: | |
| 48 | fetch-depth: 0 | |
| 58 | 49 | |
| 59 | 50 | - name: Setup Bun |
| 60 | 51 | uses: oven-sh/setup-bun@v2 |
| ... | ... | @@ -69,6 +60,60 @@ jobs: |
| 69 | 60 | - name: Install dependencies |
| 70 | 61 | run: bun install --frozen-lockfile |
| 71 | 62 | |
| 63 | - id: release_meta | |
| 64 | name: Resolve release version | |
| 65 | shell: bash | |
| 66 | env: | |
| 67 | DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} | |
| 68 | DISPATCH_VERSION: ${{ github.event.inputs.version }} | |
| 69 | NIGHTLY_DATE: ${{ github.run_started_at }} | |
| 70 | NIGHTLY_SHA: ${{ github.sha }} | |
| 71 | NIGHTLY_RUN_NUMBER: ${{ github.run_number }} | |
| 72 | run: | | |
| 73 | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then | |
| 74 | nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" | |
| 75 | ||
| 76 | node scripts/resolve-nightly-release.ts \ | |
| 77 | --date "$nightly_date" \ | |
| 78 | --run-number "$NIGHTLY_RUN_NUMBER" \ | |
| 79 | --sha "$NIGHTLY_SHA" \ | |
| 80 | --github-output | |
| 81 | ||
| 82 | echo "release_channel=nightly" >> "$GITHUB_OUTPUT" | |
| 83 | echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" | |
| 84 | echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| 85 | echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| 86 | else | |
| 87 | if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| 88 | raw="${DISPATCH_VERSION}" | |
| 89 | if [[ -z "$raw" ]]; then | |
| 90 | echo "workflow_dispatch stable releases require the version input." >&2 | |
| 91 | exit 1 | |
| 92 | fi | |
| 93 | else | |
| 94 | raw="${GITHUB_REF_NAME}" | |
| 95 | fi | |
| 96 | ||
| 97 | version="${raw#v}" | |
| 98 | if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then | |
| 99 | echo "Invalid release version: $raw" >&2 | |
| 100 | exit 1 | |
| 101 | fi | |
| 102 | ||
| 103 | echo "release_channel=stable" >> "$GITHUB_OUTPUT" | |
| 104 | echo "version=$version" >> "$GITHUB_OUTPUT" | |
| 105 | echo "tag=v$version" >> "$GITHUB_OUTPUT" | |
| 106 | echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" | |
| 107 | echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" | |
| 108 | if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| 109 | echo "is_prerelease=false" >> "$GITHUB_OUTPUT" | |
| 110 | echo "make_latest=true" >> "$GITHUB_OUTPUT" | |
| 111 | else | |
| 112 | echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| 113 | echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| 114 | fi | |
| 115 | fi | |
| 116 | ||
| 72 | 117 | - name: Lint |
| 73 | 118 | run: bun run lint |
| 74 | 119 | |
| ... | ... | @@ -78,6 +123,14 @@ jobs: |
| 78 | 123 | - name: Test |
| 79 | 124 | run: bun run test |
| 80 | 125 | |
| 126 | - id: previous_tag | |
| 127 | name: Resolve previous release tag | |
| 128 | run: | | |
| 129 | node scripts/resolve-previous-release-tag.ts \ | |
| 130 | --channel "${{ steps.release_meta.outputs.release_channel }}" \ | |
| 131 | --current-tag "${{ steps.release_meta.outputs.tag }}" \ | |
| 132 | --github-output | |
| 133 | ||
| 81 | 134 | build: |
| 82 | 135 | name: Build ${{ matrix.label }} |
| 83 | 136 | needs: preflight |
| ... | ... | @@ -206,16 +259,17 @@ jobs: |
| 206 | 259 | "release/*.AppImage" \ |
| 207 | 260 | "release/*.exe" \ |
| 208 | 261 | "release/*.blockmap" \ |
| 209 | "release/latest*.yml"; do | |
| 262 | "release/*.yml"; do | |
| 210 | 263 | for file in $pattern; do |
| 211 | 264 | cp "$file" release-publish/ |
| 212 | 265 | done |
| 213 | 266 | done |
| 214 | 267 | |
| 215 | 268 | if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then |
| 216 | if [[ -f release-publish/latest-mac.yml ]]; then | |
| 217 | mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml" | |
| 218 | fi | |
| 269 | shopt -s nullglob | |
| 270 | for manifest in release-publish/*-mac.yml; do | |
| 271 | mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" | |
| 272 | done | |
| 219 | 273 | fi |
| 220 | 274 | |
| 221 | 275 | - name: Upload build artifacts |
| ... | ... | @@ -257,7 +311,7 @@ jobs: |
| 257 | 311 | run: bun run build --filter=@t3tools/web --filter=t3 |
| 258 | 312 | |
| 259 | 313 | - name: Publish CLI package |
| 260 | run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose | |
| 314 | run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose | |
| 261 | 315 | |
| 262 | 316 | release: |
| 263 | 317 | name: Publish GitHub Release |
| ... | ... | @@ -284,17 +338,42 @@ jobs: |
| 284 | 338 | |
| 285 | 339 | - name: Merge macOS updater manifests |
| 286 | 340 | run: | |
| 287 | node scripts/merge-mac-update-manifests.ts \ | |
| 288 | release-assets/latest-mac.yml \ | |
| 289 | release-assets/latest-mac-x64.yml | |
| 290 | rm -f release-assets/latest-mac-x64.yml | |
| 341 | shopt -s nullglob | |
| 342 | for x64_manifest in release-assets/*-mac-x64.yml; do | |
| 343 | arm64_manifest="${x64_manifest%-x64.yml}.yml" | |
| 344 | if [[ -f "$arm64_manifest" ]]; then | |
| 345 | node scripts/merge-mac-update-manifests.ts "$arm64_manifest" "$x64_manifest" | |
| 346 | rm -f "$x64_manifest" | |
| 347 | fi | |
| 348 | done | |
| 291 | 349 | |
| 292 | 350 | - name: Publish release |
| 351 | if: needs.preflight.outputs.previous_tag != '' | |
| 352 | uses: softprops/action-gh-release@v2 | |
| 353 | with: | |
| 354 | tag_name: ${{ needs.preflight.outputs.tag }} | |
| 355 | target_commitish: ${{ needs.preflight.outputs.ref }} | |
| 356 | name: ${{ needs.preflight.outputs.release_name }} | |
| 357 | generate_release_notes: true | |
| 358 | previous_tag: ${{ needs.preflight.outputs.previous_tag }} | |
| 359 | prerelease: ${{ needs.preflight.outputs.is_prerelease }} | |
| 360 | make_latest: ${{ needs.preflight.outputs.make_latest }} | |
| 361 | files: | | |
| 362 | release-assets/*.dmg | |
| 363 | release-assets/*.zip | |
| 364 | release-assets/*.AppImage | |
| 365 | release-assets/*.exe | |
| 366 | release-assets/*.blockmap | |
| 367 | release-assets/*.yml | |
| 368 | fail_on_unmatched_files: true | |
| 369 | ||
| 370 | - name: Publish first release | |
| 371 | if: needs.preflight.outputs.previous_tag == '' | |
| 293 | 372 | uses: softprops/action-gh-release@v2 |
| 294 | 373 | with: |
| 295 | 374 | tag_name: ${{ needs.preflight.outputs.tag }} |
| 296 | 375 | target_commitish: ${{ needs.preflight.outputs.ref }} |
| 297 | name: T3 Code v${{ needs.preflight.outputs.version }} | |
| 376 | name: ${{ needs.preflight.outputs.release_name }} | |
| 298 | 377 | generate_release_notes: true |
| 299 | 378 | prerelease: ${{ needs.preflight.outputs.is_prerelease }} |
| 300 | 379 | make_latest: ${{ needs.preflight.outputs.make_latest }} |
| ... | ... | @@ -304,11 +383,12 @@ jobs: |
| 304 | 383 | release-assets/*.AppImage |
| 305 | 384 | release-assets/*.exe |
| 306 | 385 | release-assets/*.blockmap |
| 307 | release-assets/latest*.yml | |
| 386 | release-assets/*.yml | |
| 308 | 387 | fail_on_unmatched_files: true |
| 309 | 388 | |
| 310 | 389 | finalize: |
| 311 | 390 | name: Finalize release |
| 391 | if: needs.preflight.outputs.release_channel == 'stable' | |
| 312 | 392 | needs: [preflight, release] |
| 313 | 393 | runs-on: ubuntu-24.04 |
| 314 | 394 | timeout-minutes: 10 |
.oxfmtrc.json+2-1| ... | ... | @@ -10,7 +10,8 @@ |
| 10 | 10 | "*.tsbuildinfo", |
| 11 | 11 | "**/routeTree.gen.ts", |
| 12 | 12 | "apps/web/public/mockServiceWorker.js", |
| 13 | "apps/web/src/lib/vendor/qrcodegen.ts" | |
| 13 | "apps/web/src/lib/vendor/qrcodegen.ts", | |
| 14 | "*.icon/**" | |
| 14 | 15 | ], |
| 15 | 16 | "sortPackageJson": {} |
| 16 | 17 | } |
apps/desktop/scripts/electron-launcher.mjs+67-1| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | cpSync, |
| 7 | 7 | existsSync, |
| 8 | 8 | mkdirSync, |
| 9 | mkdtempSync, | |
| 9 | 10 | readFileSync, |
| 10 | 11 | readdirSync, |
| 11 | 12 | rmSync, |
| ... | ... | @@ -23,6 +24,9 @@ const LAUNCHER_VERSION = 1; |
| 23 | 24 | |
| 24 | 25 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 25 | 26 | export const desktopDir = resolve(__dirname, ".."); |
| 27 | const repoRoot = resolve(desktopDir, "..", ".."); | |
| 28 | const defaultIconPath = join(desktopDir, "resources", "icon.icns"); | |
| 29 | const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); | |
| 26 | 30 | |
| 27 | 31 | function setPlistString(plistPath, key, value) { |
| 28 | 32 | const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { |
| ... | ... | @@ -43,6 +47,68 @@ function setPlistString(plistPath, key, value) { |
| 43 | 47 | throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); |
| 44 | 48 | } |
| 45 | 49 | |
| 50 | function runChecked(command, args) { | |
| 51 | const result = spawnSync(command, args, { encoding: "utf8" }); | |
| 52 | if (result.status === 0) { | |
| 53 | return; | |
| 54 | } | |
| 55 | ||
| 56 | const details = [result.stdout, result.stderr].filter(Boolean).join("\n"); | |
| 57 | throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); | |
| 58 | } | |
| 59 | ||
| 60 | function ensureDevelopmentIconIcns(runtimeDir) { | |
| 61 | const generatedIconPath = join(runtimeDir, "icon-dev.icns"); | |
| 62 | mkdirSync(runtimeDir, { recursive: true }); | |
| 63 | ||
| 64 | if (!existsSync(developmentMacIconPngPath)) { | |
| 65 | return defaultIconPath; | |
| 66 | } | |
| 67 | ||
| 68 | const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs; | |
| 69 | if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) { | |
| 70 | return generatedIconPath; | |
| 71 | } | |
| 72 | ||
| 73 | const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-")); | |
| 74 | const iconsetDir = join(iconsetRoot, "icon.iconset"); | |
| 75 | mkdirSync(iconsetDir, { recursive: true }); | |
| 76 | ||
| 77 | try { | |
| 78 | for (const size of [16, 32, 128, 256, 512]) { | |
| 79 | runChecked("sips", [ | |
| 80 | "-z", | |
| 81 | String(size), | |
| 82 | String(size), | |
| 83 | developmentMacIconPngPath, | |
| 84 | "--out", | |
| 85 | join(iconsetDir, `icon_${size}x${size}.png`), | |
| 86 | ]); | |
| 87 | ||
| 88 | const retinaSize = size * 2; | |
| 89 | runChecked("sips", [ | |
| 90 | "-z", | |
| 91 | String(retinaSize), | |
| 92 | String(retinaSize), | |
| 93 | developmentMacIconPngPath, | |
| 94 | "--out", | |
| 95 | join(iconsetDir, `icon_${size}x${size}@2x.png`), | |
| 96 | ]); | |
| 97 | } | |
| 98 | ||
| 99 | runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]); | |
| 100 | return generatedIconPath; | |
| 101 | } catch (error) { | |
| 102 | console.warn( | |
| 103 | "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.", | |
| 104 | error, | |
| 105 | ); | |
| 106 | return defaultIconPath; | |
| 107 | } finally { | |
| 108 | rmSync(iconsetRoot, { recursive: true, force: true }); | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 46 | 112 | function patchMainBundleInfoPlist(appBundlePath, iconPath) { |
| 47 | 113 | const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); |
| 48 | 114 | setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); |
| ... | ... | @@ -102,7 +168,7 @@ function buildMacLauncher(electronBinaryPath) { |
| 102 | 168 | const runtimeDir = join(desktopDir, ".electron-runtime"); |
| 103 | 169 | const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); |
| 104 | 170 | const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); |
| 105 | const iconPath = join(desktopDir, "resources", "icon.icns"); | |
| 171 | const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; | |
| 106 | 172 | const metadataPath = join(runtimeDir, "metadata.json"); |
| 107 | 173 | |
| 108 | 174 | mkdirSync(runtimeDir, { recursive: true }); |
apps/desktop/src/appBranding.test.ts created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding"; | |
| 4 | ||
| 5 | describe("resolveDesktopAppStageLabel", () => { | |
| 6 | it("uses Dev in desktop development", () => { | |
| 7 | expect( | |
| 8 | resolveDesktopAppStageLabel({ | |
| 9 | isDevelopment: true, | |
| 10 | appVersion: "0.0.17-nightly.20260414.1", | |
| 11 | }), | |
| 12 | ).toBe("Dev"); | |
| 13 | }); | |
| 14 | ||
| 15 | it("uses Nightly for packaged nightly builds", () => { | |
| 16 | expect( | |
| 17 | resolveDesktopAppStageLabel({ | |
| 18 | isDevelopment: false, | |
| 19 | appVersion: "0.0.17-nightly.20260414.1", | |
| 20 | }), | |
| 21 | ).toBe("Nightly"); | |
| 22 | }); | |
| 23 | ||
| 24 | it("uses Alpha for packaged stable builds", () => { | |
| 25 | expect( | |
| 26 | resolveDesktopAppStageLabel({ | |
| 27 | isDevelopment: false, | |
| 28 | appVersion: "0.0.17", | |
| 29 | }), | |
| 30 | ).toBe("Alpha"); | |
| 31 | }); | |
| 32 | }); | |
| 33 | ||
| 34 | describe("resolveDesktopAppBranding", () => { | |
| 35 | it("returns a complete desktop branding payload", () => { | |
| 36 | expect( | |
| 37 | resolveDesktopAppBranding({ | |
| 38 | isDevelopment: false, | |
| 39 | appVersion: "0.0.17-nightly.20260414.1", | |
| 40 | }), | |
| 41 | ).toEqual({ | |
| 42 | baseName: "T3 Code", | |
| 43 | stageLabel: "Nightly", | |
| 44 | displayName: "T3 Code (Nightly)", | |
| 45 | }); | |
| 46 | }); | |
| 47 | }); |
apps/desktop/src/appBranding.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | const APP_BASE_NAME = "T3 Code"; | |
| 4 | const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; | |
| 5 | ||
| 6 | export function resolveDesktopAppStageLabel(input: { | |
| 7 | readonly isDevelopment: boolean; | |
| 8 | readonly appVersion: string; | |
| 9 | }): DesktopAppStageLabel { | |
| 10 | if (input.isDevelopment) { | |
| 11 | return "Dev"; | |
| 12 | } | |
| 13 | ||
| 14 | return NIGHTLY_VERSION_PATTERN.test(input.appVersion) ? "Nightly" : "Alpha"; | |
| 15 | } | |
| 16 | ||
| 17 | export function resolveDesktopAppBranding(input: { | |
| 18 | readonly isDevelopment: boolean; | |
| 19 | readonly appVersion: string; | |
| 20 | }): DesktopAppBranding { | |
| 21 | const stageLabel = resolveDesktopAppStageLabel(input); | |
| 22 | return { | |
| 23 | baseName: APP_BASE_NAME, | |
| 24 | stageLabel, | |
| 25 | displayName: `${APP_BASE_NAME} (${stageLabel})`, | |
| 26 | }; | |
| 27 | } |
apps/desktop/src/desktopSettings.test.ts+20| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | DEFAULT_DESKTOP_SETTINGS, |
| 9 | 9 | readDesktopSettings, |
| 10 | 10 | setDesktopServerExposurePreference, |
| 11 | setDesktopUpdateChannelPreference, | |
| 11 | 12 | writeDesktopSettings, |
| 12 | 13 | } from "./desktopSettings"; |
| 13 | 14 | |
| ... | ... | @@ -35,10 +36,12 @@ describe("desktopSettings", () => { |
| 35 | 36 | |
| 36 | 37 | writeDesktopSettings(settingsPath, { |
| 37 | 38 | serverExposureMode: "network-accessible", |
| 39 | updateChannel: "latest", | |
| 38 | 40 | }); |
| 39 | 41 | |
| 40 | 42 | expect(readDesktopSettings(settingsPath)).toEqual({ |
| 41 | 43 | serverExposureMode: "network-accessible", |
| 44 | updateChannel: "latest", | |
| 42 | 45 | }); |
| 43 | 46 | }); |
| 44 | 47 | |
| ... | ... | @@ -47,11 +50,28 @@ describe("desktopSettings", () => { |
| 47 | 50 | setDesktopServerExposurePreference( |
| 48 | 51 | { |
| 49 | 52 | serverExposureMode: "local-only", |
| 53 | updateChannel: "latest", | |
| 50 | 54 | }, |
| 51 | 55 | "network-accessible", |
| 52 | 56 | ), |
| 53 | 57 | ).toEqual({ |
| 54 | 58 | serverExposureMode: "network-accessible", |
| 59 | updateChannel: "latest", | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | it("persists the requested nightly update channel", () => { | |
| 64 | expect( | |
| 65 | setDesktopUpdateChannelPreference( | |
| 66 | { | |
| 67 | serverExposureMode: "local-only", | |
| 68 | updateChannel: "latest", | |
| 69 | }, | |
| 70 | "nightly", | |
| 71 | ), | |
| 72 | ).toEqual({ | |
| 73 | serverExposureMode: "local-only", | |
| 74 | updateChannel: "nightly", | |
| 55 | 75 | }); |
| 56 | 76 | }); |
| 57 | 77 |
apps/desktop/src/desktopSettings.ts+17-1| ... | ... | @@ -1,13 +1,15 @@ |
| 1 | 1 | import * as FS from "node:fs"; |
| 2 | 2 | import * as Path from "node:path"; |
| 3 | import type { DesktopServerExposureMode } from "@t3tools/contracts"; | |
| 3 | import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; | |
| 4 | 4 | |
| 5 | 5 | export interface DesktopSettings { |
| 6 | 6 | readonly serverExposureMode: DesktopServerExposureMode; |
| 7 | readonly updateChannel: DesktopUpdateChannel; | |
| 7 | 8 | } |
| 8 | 9 | |
| 9 | 10 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { |
| 10 | 11 | serverExposureMode: "local-only", |
| 12 | updateChannel: "latest", | |
| 11 | 13 | }; |
| 12 | 14 | |
| 13 | 15 | export function setDesktopServerExposurePreference( |
| ... | ... | @@ -22,6 +24,18 @@ export function setDesktopServerExposurePreference( |
| 22 | 24 | }; |
| 23 | 25 | } |
| 24 | 26 | |
| 27 | export function setDesktopUpdateChannelPreference( | |
| 28 | settings: DesktopSettings, | |
| 29 | requestedChannel: DesktopUpdateChannel, | |
| 30 | ): DesktopSettings { | |
| 31 | return settings.updateChannel === requestedChannel | |
| 32 | ? settings | |
| 33 | : { | |
| 34 | ...settings, | |
| 35 | updateChannel: requestedChannel, | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 25 | 39 | export function readDesktopSettings(settingsPath: string): DesktopSettings { |
| 26 | 40 | try { |
| 27 | 41 | if (!FS.existsSync(settingsPath)) { |
| ... | ... | @@ -31,11 +45,13 @@ export function readDesktopSettings(settingsPath: string): DesktopSettings { |
| 31 | 45 | const raw = FS.readFileSync(settingsPath, "utf8"); |
| 32 | 46 | const parsed = JSON.parse(raw) as { |
| 33 | 47 | readonly serverExposureMode?: unknown; |
| 48 | readonly updateChannel?: unknown; | |
| 34 | 49 | }; |
| 35 | 50 | |
| 36 | 51 | return { |
| 37 | 52 | serverExposureMode: |
| 38 | 53 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", |
| 54 | updateChannel: parsed.updateChannel === "nightly" ? "nightly" : "latest", | |
| 39 | 55 | }; |
| 40 | 56 | } catch { |
| 41 | 57 | return DEFAULT_DESKTOP_SETTINGS; |
apps/desktop/src/main.ts+91-13| ... | ... | @@ -22,8 +22,10 @@ import type { MenuItemConstructorOptions } from "electron"; |
| 22 | 22 | import type { |
| 23 | 23 | ClientSettings, |
| 24 | 24 | DesktopTheme, |
| 25 | DesktopAppBranding, | |
| 25 | 26 | DesktopServerExposureMode, |
| 26 | 27 | DesktopServerExposureState, |
| 28 | DesktopUpdateChannel, | |
| 27 | 29 | PersistedSavedEnvironmentRecord, |
| 28 | 30 | DesktopUpdateActionResult, |
| 29 | 31 | DesktopUpdateCheckResult, |
| ... | ... | @@ -39,6 +41,7 @@ import { |
| 39 | 41 | DEFAULT_DESKTOP_SETTINGS, |
| 40 | 42 | readDesktopSettings, |
| 41 | 43 | setDesktopServerExposurePreference, |
| 44 | setDesktopUpdateChannelPreference, | |
| 42 | 45 | writeDesktopSettings, |
| 43 | 46 | } from "./desktopSettings"; |
| 44 | 47 | import { |
| ... | ... | @@ -69,6 +72,7 @@ import { |
| 69 | 72 | reduceDesktopUpdateStateOnUpdateAvailable, |
| 70 | 73 | } from "./updateMachine"; |
| 71 | 74 | import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; |
| 75 | import { resolveDesktopAppBranding } from "./appBranding"; | |
| 72 | 76 | |
| 73 | 77 | syncShellEnvironment(); |
| 74 | 78 | |
| ... | ... | @@ -80,9 +84,11 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 80 | 84 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 81 | 85 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 82 | 86 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 87 | const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | |
| 83 | 88 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 84 | 89 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 85 | 90 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 91 | const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | |
| 86 | 92 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; |
| 87 | 93 | const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; |
| 88 | 94 | const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; |
| ... | ... | @@ -101,7 +107,11 @@ const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments |
| 101 | 107 | const DESKTOP_SCHEME = "t3"; |
| 102 | 108 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 103 | 109 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 104 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 110 | const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ | |
| 111 | isDevelopment, | |
| 112 | appVersion: app.getVersion(), | |
| 113 | }); | |
| 114 | const APP_DISPLAY_NAME = desktopAppBranding.displayName; | |
| 105 | 115 | const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; |
| 106 | 116 | const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; |
| 107 | 117 | const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code"; |
| ... | ... | @@ -116,8 +126,6 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); |
| 116 | 126 | const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); |
| 117 | 127 | const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; |
| 118 | 128 | const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; |
| 119 | const DESKTOP_UPDATE_CHANNEL = "latest"; | |
| 120 | const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; | |
| 121 | 129 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; |
| 122 | 130 | const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; |
| 123 | 131 | const TITLEBAR_HEIGHT = 40; |
| ... | ... | @@ -167,7 +175,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ |
| 167 | 175 | runningUnderArm64Translation: app.runningUnderARM64Translation === true, |
| 168 | 176 | }); |
| 169 | 177 | const initialUpdateState = (): DesktopUpdateState => |
| 170 | createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); | |
| 178 | createInitialDesktopUpdateState( | |
| 179 | app.getVersion(), | |
| 180 | desktopRuntimeInfo, | |
| 181 | desktopSettings.updateChannel, | |
| 182 | ); | |
| 171 | 183 | |
| 172 | 184 | function logTimestamp(): string { |
| 173 | 185 | return new Date().toISOString(); |
| ... | ... | @@ -975,6 +987,18 @@ function resolveResourcePath(fileName: string): string | null { |
| 975 | 987 | } |
| 976 | 988 | |
| 977 | 989 | function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { |
| 990 | if (isDevelopment && process.platform === "darwin" && ext === "png") { | |
| 991 | const developmentDockIconPath = Path.join( | |
| 992 | ROOT_DIR, | |
| 993 | "assets", | |
| 994 | "dev", | |
| 995 | "blueprint-macos-1024.png", | |
| 996 | ); | |
| 997 | if (FS.existsSync(developmentDockIconPath)) { | |
| 998 | return developmentDockIconPath; | |
| 999 | } | |
| 1000 | } | |
| 1001 | ||
| 978 | 1002 | return resolveResourcePath(`icon.${ext}`); |
| 979 | 1003 | } |
| 980 | 1004 | |
| ... | ... | @@ -1074,6 +1098,26 @@ function setUpdateState(patch: Partial<DesktopUpdateState>): void { |
| 1074 | 1098 | emitUpdateState(); |
| 1075 | 1099 | } |
| 1076 | 1100 | |
| 1101 | function createBaseUpdateState( | |
| 1102 | channel: DesktopUpdateChannel, | |
| 1103 | enabled: boolean, | |
| 1104 | ): DesktopUpdateState { | |
| 1105 | return { | |
| 1106 | ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel), | |
| 1107 | enabled, | |
| 1108 | status: enabled ? "idle" : "disabled", | |
| 1109 | }; | |
| 1110 | } | |
| 1111 | ||
| 1112 | function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void { | |
| 1113 | autoUpdater.channel = channel; | |
| 1114 | autoUpdater.allowPrerelease = channel === "nightly"; | |
| 1115 | autoUpdater.allowDowngrade = channel === "nightly"; | |
| 1116 | console.info( | |
| 1117 | `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}).`, | |
| 1118 | ); | |
| 1119 | } | |
| 1120 | ||
| 1077 | 1121 | function shouldEnableAutoUpdates(): boolean { |
| 1078 | 1122 | const hasUpdateFeedConfig = |
| 1079 | 1123 | readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); |
| ... | ... | @@ -1193,11 +1237,7 @@ function configureAutoUpdater(): void { |
| 1193 | 1237 | } |
| 1194 | 1238 | |
| 1195 | 1239 | const enabled = shouldEnableAutoUpdates(); |
| 1196 | setUpdateState({ | |
| 1197 | ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo), | |
| 1198 | enabled, | |
| 1199 | status: enabled ? "idle" : "disabled", | |
| 1200 | }); | |
| 1240 | setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled)); | |
| 1201 | 1241 | if (!enabled) { |
| 1202 | 1242 | return; |
| 1203 | 1243 | } |
| ... | ... | @@ -1205,10 +1245,7 @@ function configureAutoUpdater(): void { |
| 1205 | 1245 | |
| 1206 | 1246 | autoUpdater.autoDownload = false; |
| 1207 | 1247 | autoUpdater.autoInstallOnAppQuit = false; |
| 1208 | // Keep alpha branding, but force all installs onto the stable update track. | |
| 1209 | autoUpdater.channel = DESKTOP_UPDATE_CHANNEL; | |
| 1210 | autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; | |
| 1211 | autoUpdater.allowDowngrade = false; | |
| 1248 | applyAutoUpdaterChannel(desktopSettings.updateChannel); | |
| 1212 | 1249 | autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); |
| 1213 | 1250 | let lastLoggedDownloadMilestone = -1; |
| 1214 | 1251 | |
| ... | ... | @@ -1486,6 +1523,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { |
| 1486 | 1523 | } |
| 1487 | 1524 | |
| 1488 | 1525 | function registerIpcHandlers(): void { |
| 1526 | ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); | |
| 1527 | ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { | |
| 1528 | event.returnValue = desktopAppBranding; | |
| 1529 | }); | |
| 1530 | ||
| 1489 | 1531 | ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); |
| 1490 | 1532 | ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { |
| 1491 | 1533 | event.returnValue = { |
| ... | ... | @@ -1713,6 +1755,42 @@ function registerIpcHandlers(): void { |
| 1713 | 1755 | ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); |
| 1714 | 1756 | ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); |
| 1715 | 1757 | |
| 1758 | ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); | |
| 1759 | ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { | |
| 1760 | if (rawChannel !== "latest" && rawChannel !== "nightly") { | |
| 1761 | throw new Error("Invalid desktop update channel input."); | |
| 1762 | } | |
| 1763 | if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { | |
| 1764 | throw new Error("Cannot change update tracks while an update action is in progress."); | |
| 1765 | } | |
| 1766 | ||
| 1767 | const nextChannel = rawChannel as DesktopUpdateChannel; | |
| 1768 | if (nextChannel === desktopSettings.updateChannel) { | |
| 1769 | return updateState; | |
| 1770 | } | |
| 1771 | ||
| 1772 | desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); | |
| 1773 | writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); | |
| 1774 | ||
| 1775 | const enabled = shouldEnableAutoUpdates(); | |
| 1776 | setUpdateState(createBaseUpdateState(nextChannel, enabled)); | |
| 1777 | ||
| 1778 | if (!enabled || !updaterConfigured) { | |
| 1779 | return updateState; | |
| 1780 | } | |
| 1781 | ||
| 1782 | applyAutoUpdaterChannel(nextChannel); | |
| 1783 | const allowDowngrade = autoUpdater.allowDowngrade; | |
| 1784 | // An explicit channel switch should allow the immediate nightly->stable rollback path. | |
| 1785 | autoUpdater.allowDowngrade = true; | |
| 1786 | try { | |
| 1787 | await checkForUpdates("channel-change"); | |
| 1788 | } finally { | |
| 1789 | autoUpdater.allowDowngrade = allowDowngrade; | |
| 1790 | } | |
| 1791 | return updateState; | |
| 1792 | }); | |
| 1793 | ||
| 1716 | 1794 | ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); |
| 1717 | 1795 | ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { |
| 1718 | 1796 | const result = await downloadAvailableUpdate(); |
apps/desktop/src/preload.ts+10| ... | ... | @@ -9,9 +9,11 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 9 | 9 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 10 | 10 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 11 | 11 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | |
| 12 | 13 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | 14 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 14 | 15 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 16 | const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | |
| 15 | 17 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; |
| 16 | 18 | const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; |
| 17 | 19 | const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; |
| ... | ... | @@ -24,6 +26,13 @@ const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; |
| 24 | 26 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 25 | 27 | |
| 26 | 28 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 29 | getAppBranding: () => { | |
| 30 | const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL); | |
| 31 | if (typeof result !== "object" || result === null) { | |
| 32 | return null; | |
| 33 | } | |
| 34 | return result as ReturnType<DesktopBridge["getAppBranding"]>; | |
| 35 | }, | |
| 27 | 36 | getLocalEnvironmentBootstrap: () => { |
| 28 | 37 | const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); |
| 29 | 38 | if (typeof result !== "object" || result === null) { |
| ... | ... | @@ -61,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 61 | 70 | }; |
| 62 | 71 | }, |
| 63 | 72 | getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), |
| 73 | setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), | |
| 64 | 74 | checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), |
| 65 | 75 | downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), |
| 66 | 76 | installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), |
apps/desktop/src/updateMachine.test.ts+7-6| ... | ... | @@ -23,7 +23,7 @@ describe("updateMachine", () => { |
| 23 | 23 | it("clears transient errors when a check starts", () => { |
| 24 | 24 | const state = reduceDesktopUpdateStateOnCheckStart( |
| 25 | 25 | { |
| 26 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 26 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 27 | 27 | enabled: true, |
| 28 | 28 | status: "error", |
| 29 | 29 | message: "network", |
| ... | ... | @@ -42,7 +42,7 @@ describe("updateMachine", () => { |
| 42 | 42 | it("records a check failure without exposing an action", () => { |
| 43 | 43 | const state = reduceDesktopUpdateStateOnCheckFailure( |
| 44 | 44 | { |
| 45 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 45 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 46 | 46 | enabled: true, |
| 47 | 47 | status: "checking", |
| 48 | 48 | }, |
| ... | ... | @@ -58,7 +58,7 @@ describe("updateMachine", () => { |
| 58 | 58 | it("preserves available version on download failure for retry", () => { |
| 59 | 59 | const state = reduceDesktopUpdateStateOnDownloadFailure( |
| 60 | 60 | { |
| 61 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 61 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 62 | 62 | enabled: true, |
| 63 | 63 | status: "downloading", |
| 64 | 64 | availableVersion: "1.1.0", |
| ... | ... | @@ -76,7 +76,7 @@ describe("updateMachine", () => { |
| 76 | 76 | it("transitions to downloaded and then preserves install retry state", () => { |
| 77 | 77 | const downloaded = reduceDesktopUpdateStateOnDownloadComplete( |
| 78 | 78 | { |
| 79 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 79 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 80 | 80 | enabled: true, |
| 81 | 81 | status: "downloading", |
| 82 | 82 | availableVersion: "1.1.0", |
| ... | ... | @@ -98,7 +98,7 @@ describe("updateMachine", () => { |
| 98 | 98 | it("clears stale download state when no update is available", () => { |
| 99 | 99 | const state = reduceDesktopUpdateStateOnNoUpdate( |
| 100 | 100 | { |
| 101 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 101 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 102 | 102 | enabled: true, |
| 103 | 103 | status: "error", |
| 104 | 104 | availableVersion: "1.1.0", |
| ... | ... | @@ -120,7 +120,7 @@ describe("updateMachine", () => { |
| 120 | 120 | it("tracks available, download start, and progress cleanly", () => { |
| 121 | 121 | const available = reduceDesktopUpdateStateOnUpdateAvailable( |
| 122 | 122 | { |
| 123 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 123 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 124 | 124 | enabled: true, |
| 125 | 125 | status: "checking", |
| 126 | 126 | }, |
| ... | ... | @@ -131,6 +131,7 @@ describe("updateMachine", () => { |
| 131 | 131 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); |
| 132 | 132 | |
| 133 | 133 | expect(available.status).toBe("available"); |
| 134 | expect(available.channel).toBe("latest"); | |
| 134 | 135 | expect(downloading.status).toBe("downloading"); |
| 135 | 136 | expect(downloading.downloadPercent).toBe(0); |
| 136 | 137 | expect(progress.downloadPercent).toBe(55.5); |
apps/desktop/src/updateMachine.ts+7-1| ... | ... | @@ -1,14 +1,20 @@ |
| 1 | import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | DesktopRuntimeInfo, | |
| 3 | DesktopUpdateChannel, | |
| 4 | DesktopUpdateState, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | 7 | import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; |
| 4 | 8 | |
| 5 | 9 | export function createInitialDesktopUpdateState( |
| 6 | 10 | currentVersion: string, |
| 7 | 11 | runtimeInfo: DesktopRuntimeInfo, |
| 12 | channel: DesktopUpdateChannel, | |
| 8 | 13 | ): DesktopUpdateState { |
| 9 | 14 | return { |
| 10 | 15 | enabled: false, |
| 11 | 16 | status: "disabled", |
| 17 | channel, | |
| 12 | 18 | currentVersion, |
| 13 | 19 | hostArch: runtimeInfo.hostArch, |
| 14 | 20 | appArch: runtimeInfo.appArch, |
apps/desktop/src/updateState.test.ts+1| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | const baseState: DesktopUpdateState = { |
| 12 | 12 | enabled: true, |
| 13 | 13 | status: "idle", |
| 14 | channel: "latest", | |
| 14 | 15 | currentVersion: "1.0.0", |
| 15 | 16 | hostArch: "x64", |
| 16 | 17 | appArch: "x64", |
apps/web/src/branding.test.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | const originalWindow = globalThis.window; | |
| 4 | ||
| 5 | afterEach(() => { | |
| 6 | vi.resetModules(); | |
| 7 | ||
| 8 | if (originalWindow === undefined) { | |
| 9 | Reflect.deleteProperty(globalThis, "window"); | |
| 10 | return; | |
| 11 | } | |
| 12 | ||
| 13 | globalThis.window = originalWindow; | |
| 14 | }); | |
| 15 | ||
| 16 | describe("branding", () => { | |
| 17 | it("uses injected desktop branding when available", async () => { | |
| 18 | Object.defineProperty(globalThis, "window", { | |
| 19 | configurable: true, | |
| 20 | value: { | |
| 21 | desktopBridge: { | |
| 22 | getAppBranding: () => ({ | |
| 23 | baseName: "T3 Code", | |
| 24 | stageLabel: "Nightly", | |
| 25 | displayName: "T3 Code (Nightly)", | |
| 26 | }), | |
| 27 | }, | |
| 28 | }, | |
| 29 | }); | |
| 30 | ||
| 31 | const branding = await import("./branding"); | |
| 32 | ||
| 33 | expect(branding.APP_BASE_NAME).toBe("T3 Code"); | |
| 34 | expect(branding.APP_STAGE_LABEL).toBe("Nightly"); | |
| 35 | expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); | |
| 36 | }); | |
| 37 | }); |
apps/web/src/branding.ts+17-3| ... | ... | @@ -1,4 +1,18 @@ |
| 1 | export const APP_BASE_NAME = "T3 Code"; | |
| 2 | export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; | |
| 3 | export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; | |
| 1 | import type { DesktopAppBranding } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | function readInjectedDesktopAppBranding(): DesktopAppBranding | null { | |
| 4 | if (typeof window === "undefined") { | |
| 5 | return null; | |
| 6 | } | |
| 7 | ||
| 8 | return window.desktopBridge?.getAppBranding?.() ?? null; | |
| 9 | } | |
| 10 | ||
| 11 | const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); | |
| 12 | ||
| 13 | export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; | |
| 14 | export const APP_STAGE_LABEL = | |
| 15 | injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha"); | |
| 16 | export const APP_DISPLAY_NAME = | |
| 17 | injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; | |
| 4 | 18 | export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; |
apps/web/src/components/desktopUpdate.logic.test.ts+1| ... | ... | @@ -17,6 +17,7 @@ import { |
| 17 | 17 | const baseState: DesktopUpdateState = { |
| 18 | 18 | enabled: true, |
| 19 | 19 | status: "idle", |
| 20 | channel: "latest", | |
| 20 | 21 | currentVersion: "1.0.0", |
| 21 | 22 | hostArch: "x64", |
| 22 | 23 | appArch: "x64", |
apps/web/src/components/settings/SettingsPanels.browser.tsx+10| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | DEFAULT_SERVER_SETTINGS, |
| 8 | 8 | EnvironmentId, |
| 9 | 9 | type DesktopBridge, |
| 10 | type DesktopUpdateChannel, | |
| 10 | 11 | type DesktopUpdateState, |
| 11 | 12 | type LocalApi, |
| 12 | 13 | type ServerConfig, |
| ... | ... | @@ -258,10 +259,12 @@ function makeClientSession(input: { |
| 258 | 259 | const createDesktopBridgeStub = (overrides?: { |
| 259 | 260 | readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>; |
| 260 | 261 | readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; |
| 262 | readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; | |
| 261 | 263 | }): DesktopBridge => { |
| 262 | 264 | const idleUpdateState: DesktopUpdateState = { |
| 263 | 265 | enabled: false, |
| 264 | 266 | status: "idle", |
| 267 | channel: "latest", | |
| 265 | 268 | currentVersion: "0.0.0-test", |
| 266 | 269 | hostArch: "arm64", |
| 267 | 270 | appArch: "arm64", |
| ... | ... | @@ -276,6 +279,7 @@ const createDesktopBridgeStub = (overrides?: { |
| 276 | 279 | }; |
| 277 | 280 | |
| 278 | 281 | return { |
| 282 | getAppBranding: vi.fn().mockReturnValue(null), | |
| 279 | 283 | getLocalEnvironmentBootstrap: () => ({ |
| 280 | 284 | label: "Local environment", |
| 281 | 285 | httpBaseUrl: "http://127.0.0.1:3773", |
| ... | ... | @@ -310,6 +314,12 @@ const createDesktopBridgeStub = (overrides?: { |
| 310 | 314 | openExternal: vi.fn().mockResolvedValue(true), |
| 311 | 315 | onMenuAction: () => () => {}, |
| 312 | 316 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), |
| 317 | setUpdateChannel: | |
| 318 | overrides?.setUpdateChannel ?? | |
| 319 | vi.fn().mockImplementation(async (channel: DesktopUpdateChannel) => ({ | |
| 320 | ...idleUpdateState, | |
| 321 | channel, | |
| 322 | })), | |
| 313 | 323 | checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), |
| 314 | 324 | downloadUpdate: vi |
| 315 | 325 | .fn() |
apps/web/src/components/settings/SettingsPanels.tsx+88-21| ... | ... | @@ -12,6 +12,7 @@ import { useQueryClient } from "@tanstack/react-query"; |
| 12 | 12 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; |
| 13 | 13 | import { |
| 14 | 14 | PROVIDER_DISPLAY_NAMES, |
| 15 | type DesktopUpdateChannel, | |
| 15 | 16 | type ScopedThreadRef, |
| 16 | 17 | type ProviderKind, |
| 17 | 18 | type ServerProvider, |
| ... | ... | @@ -232,8 +233,42 @@ function AboutVersionTitle() { |
| 232 | 233 | function AboutVersionSection() { |
| 233 | 234 | const queryClient = useQueryClient(); |
| 234 | 235 | const updateStateQuery = useDesktopUpdateState(); |
| 236 | const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); | |
| 235 | 237 | |
| 236 | 238 | const updateState = updateStateQuery.data ?? null; |
| 239 | const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); | |
| 240 | const selectedUpdateChannel = updateState?.channel ?? "latest"; | |
| 241 | ||
| 242 | const handleUpdateChannelChange = useCallback( | |
| 243 | (channel: DesktopUpdateChannel) => { | |
| 244 | const bridge = window.desktopBridge; | |
| 245 | if ( | |
| 246 | !bridge || | |
| 247 | typeof bridge.setUpdateChannel !== "function" || | |
| 248 | channel === selectedUpdateChannel | |
| 249 | ) { | |
| 250 | return; | |
| 251 | } | |
| 252 | ||
| 253 | setIsChangingUpdateChannel(true); | |
| 254 | void bridge | |
| 255 | .setUpdateChannel(channel) | |
| 256 | .then((state) => { | |
| 257 | setDesktopUpdateStateQueryData(queryClient, state); | |
| 258 | }) | |
| 259 | .catch((error: unknown) => { | |
| 260 | toastManager.add({ | |
| 261 | type: "error", | |
| 262 | title: "Could not change update track", | |
| 263 | description: error instanceof Error ? error.message : "Update track change failed.", | |
| 264 | }); | |
| 265 | }) | |
| 266 | .finally(() => { | |
| 267 | setIsChangingUpdateChannel(false); | |
| 268 | }); | |
| 269 | }, | |
| 270 | [queryClient, selectedUpdateChannel], | |
| 271 | ); | |
| 237 | 272 | |
| 238 | 273 | const handleButtonClick = useCallback(() => { |
| 239 | 274 | const bridge = window.desktopBridge; |
| ... | ... | @@ -323,27 +358,59 @@ function AboutVersionSection() { |
| 323 | 358 | : "Current version of the application."; |
| 324 | 359 | |
| 325 | 360 | return ( |
| 326 | <SettingsRow | |
| 327 | title={<AboutVersionTitle />} | |
| 328 | description={description} | |
| 329 | control={ | |
| 330 | <Tooltip> | |
| 331 | <TooltipTrigger | |
| 332 | render={ | |
| 333 | <Button | |
| 334 | size="xs" | |
| 335 | variant={action === "install" ? "default" : "outline"} | |
| 336 | disabled={buttonDisabled} | |
| 337 | onClick={handleButtonClick} | |
| 338 | > | |
| 339 | {buttonLabel} | |
| 340 | </Button> | |
| 341 | } | |
| 342 | /> | |
| 343 | {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null} | |
| 344 | </Tooltip> | |
| 345 | } | |
| 346 | /> | |
| 361 | <> | |
| 362 | <SettingsRow | |
| 363 | title={<AboutVersionTitle />} | |
| 364 | description={description} | |
| 365 | control={ | |
| 366 | <Tooltip> | |
| 367 | <TooltipTrigger | |
| 368 | render={ | |
| 369 | <Button | |
| 370 | size="xs" | |
| 371 | variant={action === "install" ? "default" : "outline"} | |
| 372 | disabled={buttonDisabled} | |
| 373 | onClick={handleButtonClick} | |
| 374 | > | |
| 375 | {buttonLabel} | |
| 376 | </Button> | |
| 377 | } | |
| 378 | /> | |
| 379 | {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null} | |
| 380 | </Tooltip> | |
| 381 | } | |
| 382 | /> | |
| 383 | <SettingsRow | |
| 384 | title="Update track" | |
| 385 | description="Stable follows full releases. Nightly follows the nightly desktop channel and can switch back to stable immediately." | |
| 386 | control={ | |
| 387 | <Select | |
| 388 | value={selectedUpdateChannel} | |
| 389 | onValueChange={(value) => { | |
| 390 | handleUpdateChannelChange(value as DesktopUpdateChannel); | |
| 391 | }} | |
| 392 | > | |
| 393 | <SelectTrigger | |
| 394 | className="w-full sm:w-40" | |
| 395 | aria-label="Update track" | |
| 396 | disabled={!hasDesktopBridge || isChangingUpdateChannel} | |
| 397 | > | |
| 398 | <SelectValue> | |
| 399 | {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} | |
| 400 | </SelectValue> | |
| 401 | </SelectTrigger> | |
| 402 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 403 | <SelectItem hideIndicator value="latest"> | |
| 404 | Stable | |
| 405 | </SelectItem> | |
| 406 | <SelectItem hideIndicator value="nightly"> | |
| 407 | Nightly | |
| 408 | </SelectItem> | |
| 409 | </SelectPopup> | |
| 410 | </Select> | |
| 411 | } | |
| 412 | /> | |
| 413 | </> | |
| 347 | 414 | ); |
| 348 | 415 | } |
| 349 | 416 |
apps/web/src/lib/desktopUpdateReactQuery.test.ts+1| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | const baseState: DesktopUpdateState = { |
| 11 | 11 | enabled: true, |
| 12 | 12 | status: "idle", |
| 13 | channel: "latest", | |
| 13 | 14 | currentVersion: "1.0.0", |
| 14 | 15 | hostArch: "x64", |
| 15 | 16 | appArch: "x64", |
apps/web/src/localApi.test.ts+4| ... | ... | @@ -157,6 +157,7 @@ function createLocalStorageStub(): Storage { |
| 157 | 157 | |
| 158 | 158 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { |
| 159 | 159 | return { |
| 160 | getAppBranding: () => null, | |
| 160 | 161 | getLocalEnvironmentBootstrap: () => null, |
| 161 | 162 | getClientSettings: async () => null, |
| 162 | 163 | setClientSettings: async () => undefined, |
| ... | ... | @@ -184,6 +185,9 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg |
| 184 | 185 | getUpdateState: async () => { |
| 185 | 186 | throw new Error("getUpdateState not implemented in test"); |
| 186 | 187 | }, |
| 188 | setUpdateChannel: async () => { | |
| 189 | throw new Error("setUpdateChannel not implemented in test"); | |
| 190 | }, | |
| 187 | 191 | checkForUpdate: async () => { |
| 188 | 192 | throw new Error("checkForUpdate not implemented in test"); |
| 189 | 193 | }, |
assets/dev/blueprint-icon-composer.icon/Assets/T3.svg created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | <svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | <path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/> | |
| 3 | </svg> |
assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg created| Binary files /dev/null and b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg differ |
assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png created| Binary files /dev/null and b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png differ |
assets/dev/blueprint-icon-composer.icon/icon.json created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | { | |
| 2 | "fill": { | |
| 3 | "solid": "display-p3:0.00000,0.00000,0.00000,1.00000" | |
| 4 | }, | |
| 5 | "groups": [ | |
| 6 | { | |
| 7 | "layers": [ | |
| 8 | { | |
| 9 | "blend-mode": "normal", | |
| 10 | "fill": "automatic", | |
| 11 | "glass": true, | |
| 12 | "hidden": false, | |
| 13 | "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png", | |
| 14 | "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871", | |
| 15 | "position": { | |
| 16 | "scale": 1.05, | |
| 17 | "translation-in-points": [0, 0] | |
| 18 | } | |
| 19 | }, | |
| 20 | { | |
| 21 | "image-name": "T3.svg", | |
| 22 | "name": "T3", | |
| 23 | "position": { | |
| 24 | "scale": 10, | |
| 25 | "translation-in-points": [0, 0] | |
| 26 | } | |
| 27 | }, | |
| 28 | { | |
| 29 | "hidden": false, | |
| 30 | "image-name": "Texturelabs_Paper_381XL.jpg", | |
| 31 | "name": "Texturelabs_Paper_381XL" | |
| 32 | } | |
| 33 | ], | |
| 34 | "shadow": { | |
| 35 | "kind": "neutral", | |
| 36 | "opacity": 0.5 | |
| 37 | }, | |
| 38 | "translucency": { | |
| 39 | "enabled": true, | |
| 40 | "value": 0.5 | |
| 41 | } | |
| 42 | } | |
| 43 | ], | |
| 44 | "supported-platforms": { | |
| 45 | "squares": ["iOS", "macOS"] | |
| 46 | } | |
| 47 | } |
assets/dev/blueprint-ios-1024.png| Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ |
assets/dev/blueprint-macos-1024.png| Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ |
assets/dev/blueprint-universal-1024.png| Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ |
assets/dev/blueprint-web-apple-touch-180.png| Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ |
assets/dev/blueprint-web-favicon-16x16.png| Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ |
assets/dev/blueprint-web-favicon-32x32.png| Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ |
assets/dev/blueprint-web-favicon.ico| Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ |
assets/dev/blueprint-windows.ico| Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ |
assets/nightly/blueprint-ios-1024.png created| Binary files /dev/null and b/assets/nightly/blueprint-ios-1024.png differ |
assets/nightly/blueprint-macos-1024.png created| Binary files /dev/null and b/assets/nightly/blueprint-macos-1024.png differ |
assets/nightly/blueprint-universal-1024.png created| Binary files /dev/null and b/assets/nightly/blueprint-universal-1024.png differ |
assets/nightly/blueprint-web-apple-touch-180.png created| Binary files /dev/null and b/assets/nightly/blueprint-web-apple-touch-180.png differ |
assets/nightly/blueprint-web-favicon-16x16.png created| Binary files /dev/null and b/assets/nightly/blueprint-web-favicon-16x16.png differ |
assets/nightly/blueprint-web-favicon-32x32.png created| Binary files /dev/null and b/assets/nightly/blueprint-web-favicon-32x32.png differ |
assets/nightly/blueprint-web-favicon.ico created| Binary files /dev/null and b/assets/nightly/blueprint-web-favicon.ico differ |
assets/nightly/blueprint-windows.ico created| Binary files /dev/null and b/assets/nightly/blueprint-windows.ico differ |
docs/release.md+37-12| ... | ... | @@ -1,23 +1,47 @@ |
| 1 | 1 | # Release Checklist |
| 2 | 2 | |
| 3 | This document covers how to run desktop releases from one tag, first without signing, then with signing. | |
| 3 | This document covers the unified release workflow for stable and nightly desktop releases. | |
| 4 | 4 | |
| 5 | 5 | ## What the workflow does |
| 6 | 6 | |
| 7 | - Trigger: push tag matching `v*.*.*`. | |
| 7 | - Workflow: `.github/workflows/release.yml` | |
| 8 | - Triggers: | |
| 9 | - push tag matching `v*.*.*` for stable releases | |
| 10 | - scheduled nightly at `09:00 UTC` | |
| 11 | - manual `workflow_dispatch` for either channel | |
| 8 | 12 | - Runs quality gates first: lint, typecheck, test. |
| 9 | - Builds four artifacts in parallel: | |
| 13 | - Builds four artifacts in parallel for both channels: | |
| 10 | 14 | - macOS `arm64` DMG |
| 11 | 15 | - macOS `x64` DMG |
| 12 | 16 | - Linux `x64` AppImage |
| 13 | 17 | - Windows `x64` NSIS installer |
| 14 | 18 | - Publishes one GitHub Release with all produced files. |
| 15 | - Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. | |
| 16 | - Only plain `X.Y.Z` releases are marked as the repository's latest release. | |
| 17 | - Includes Electron auto-update metadata (for example `latest*.yml` and `*.blockmap`) in release assets. | |
| 18 | - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing. | |
| 19 | - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. | |
| 20 | - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. | |
| 21 | - Nightly runs are always GitHub prereleases and never marked latest. | |
| 22 | - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. | |
| 23 | - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. | |
| 24 | - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: | |
| 25 | - stable releases publish npm dist-tag `latest` | |
| 26 | - nightly releases publish npm dist-tag `nightly` | |
| 19 | 27 | - Signing is optional and auto-detected per platform from secrets. |
| 20 | 28 | |
| 29 | ## Nightly builds | |
| 30 | ||
| 31 | - Workflow: `.github/workflows/release.yml` | |
| 32 | - Triggers: | |
| 33 | - scheduled every day at `09:00 UTC` | |
| 34 | - manual `workflow_dispatch` with `channel=nightly` | |
| 35 | - Runs the same desktop quality gates and artifact matrix as the tagged release flow. | |
| 36 | - Publishes a GitHub prerelease only: | |
| 37 | - tag format: `nightly-vX.Y.Z-nightly.YYYYMMDD.<run_number>` | |
| 38 | - release name includes the short commit SHA | |
| 39 | - `make_latest` is always `false` | |
| 40 | - Uses the current `apps/desktop/package.json` semver core (`X.Y.Z`) as the nightly base, then appends a nightly prerelease suffix. | |
| 41 | - Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. | |
| 42 | - Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. | |
| 43 | - Does not commit version bumps back to `main`. | |
| 44 | ||
| 21 | 45 | ## Desktop auto-update notes |
| 22 | 46 | |
| 23 | 47 | - Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`. |
| ... | ... | @@ -34,15 +58,15 @@ This document covers how to run desktop releases from one tag, first without sig |
| 34 | 58 | - the app forwards it as an `Authorization: Bearer <token>` request header for updater HTTP calls. |
| 35 | 59 | - Required release assets for updater: |
| 36 | 60 | - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads) |
| 37 | - `latest*.yml` metadata | |
| 61 | - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases | |
| 38 | 62 | - `*.blockmap` files (used for differential downloads) |
| 39 | 63 | - macOS metadata note: |
| 40 | - `electron-updater` reads `latest-mac.yml` for both Intel and Apple Silicon. | |
| 41 | - The workflow merges the per-arch mac manifests into one `latest-mac.yml` before publishing the GitHub Release. | |
| 64 | - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon. | |
| 65 | - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release. | |
| 42 | 66 | |
| 43 | 67 | ## 0) npm OIDC trusted publishing setup (CLI) |
| 44 | 68 | |
| 45 | The workflow publishes the CLI with `bun publish` from `apps/server` after bumping | |
| 69 | The workflow publishes the CLI with `npm publish` from `apps/server` after bumping | |
| 46 | 70 | the package version to the release tag version. |
| 47 | 71 | |
| 48 | 72 | Checklist: |
| ... | ... | @@ -57,7 +81,8 @@ Checklist: |
| 57 | 81 | 4. Create release tag `vX.Y.Z` and push; workflow will: |
| 58 | 82 | - set `apps/server/package.json` version to `X.Y.Z` |
| 59 | 83 | - build web + server |
| 60 | - run `bun publish --access public` | |
| 84 | - run `npm publish --access public --tag latest` | |
| 85 | 5. Nightly runs from the same workflow file publish with `npm publish --access public --tag nightly`. | |
| 61 | 86 | |
| 62 | 87 | ## 1) Dry-run release without signing |
| 63 | 88 |
packages/contracts/src/ipc.ts+11| ... | ... | @@ -73,6 +73,14 @@ export type DesktopUpdateStatus = |
| 73 | 73 | |
| 74 | 74 | export type DesktopRuntimeArch = "arm64" | "x64" | "other"; |
| 75 | 75 | export type DesktopTheme = "light" | "dark" | "system"; |
| 76 | export type DesktopUpdateChannel = "latest" | "nightly"; | |
| 77 | export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; | |
| 78 | ||
| 79 | export interface DesktopAppBranding { | |
| 80 | baseName: string; | |
| 81 | stageLabel: DesktopAppStageLabel; | |
| 82 | displayName: string; | |
| 83 | } | |
| 76 | 84 | |
| 77 | 85 | export interface DesktopRuntimeInfo { |
| 78 | 86 | hostArch: DesktopRuntimeArch; |
| ... | ... | @@ -83,6 +91,7 @@ export interface DesktopRuntimeInfo { |
| 83 | 91 | export interface DesktopUpdateState { |
| 84 | 92 | enabled: boolean; |
| 85 | 93 | status: DesktopUpdateStatus; |
| 94 | channel: DesktopUpdateChannel; | |
| 86 | 95 | currentVersion: string; |
| 87 | 96 | hostArch: DesktopRuntimeArch; |
| 88 | 97 | appArch: DesktopRuntimeArch; |
| ... | ... | @@ -132,6 +141,7 @@ export interface DesktopServerExposureState { |
| 132 | 141 | } |
| 133 | 142 | |
| 134 | 143 | export interface DesktopBridge { |
| 144 | getAppBranding: () => DesktopAppBranding | null; | |
| 135 | 145 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| 136 | 146 | getClientSettings: () => Promise<ClientSettings | null>; |
| 137 | 147 | setClientSettings: (settings: ClientSettings) => Promise<void>; |
| ... | ... | @@ -154,6 +164,7 @@ export interface DesktopBridge { |
| 154 | 164 | openExternal: (url: string) => Promise<boolean>; |
| 155 | 165 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 156 | 166 | getUpdateState: () => Promise<DesktopUpdateState>; |
| 167 | setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>; | |
| 157 | 168 | checkForUpdate: () => Promise<DesktopUpdateCheckResult>; |
| 158 | 169 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; |
| 159 | 170 | installUpdate: () => Promise<DesktopUpdateActionResult>; |
scripts/build-desktop-artifact.test.ts+51-1| ... | ... | @@ -2,9 +2,59 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | 3 | import { ConfigProvider, Effect, Option } from "effect"; |
| 4 | 4 | |
| 5 | import { resolveBuildOptions } from "./build-desktop-artifact.ts"; | |
| 5 | import { | |
| 6 | resolveBuildOptions, | |
| 7 | resolveDesktopBuildIconAssets, | |
| 8 | resolveDesktopUpdateChannel, | |
| 9 | resolveMockUpdateServerPort, | |
| 10 | resolveMockUpdateServerUrl, | |
| 11 | } from "./build-desktop-artifact.ts"; | |
| 12 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; | |
| 6 | 13 | |
| 7 | 14 | it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 15 | it("resolves the dedicated nightly updater channel from nightly versions", () => { | |
| 16 | assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); | |
| 17 | assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest"); | |
| 18 | }); | |
| 19 | ||
| 20 | it("switches desktop packaging icons to the nightly artwork for nightly versions", () => { | |
| 21 | assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17"), { | |
| 22 | macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, | |
| 23 | linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, | |
| 24 | windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, | |
| 25 | }); | |
| 26 | ||
| 27 | assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17-nightly.20260413.42"), { | |
| 28 | macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, | |
| 29 | linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, | |
| 30 | windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, | |
| 31 | }); | |
| 32 | }); | |
| 33 | ||
| 34 | it("falls back to the default mock update port when the configured port is blank", () => { | |
| 35 | assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000"); | |
| 36 | assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); | |
| 37 | }); | |
| 38 | ||
| 39 | it.effect("normalizes mock update server ports from env-style strings", () => | |
| 40 | Effect.gen(function* () { | |
| 41 | assert.equal(yield* resolveMockUpdateServerPort(undefined), undefined); | |
| 42 | assert.equal(yield* resolveMockUpdateServerPort(""), undefined); | |
| 43 | assert.equal(yield* resolveMockUpdateServerPort(" "), undefined); | |
| 44 | assert.equal(yield* resolveMockUpdateServerPort("4123"), 4123); | |
| 45 | }), | |
| 46 | ); | |
| 47 | ||
| 48 | it.effect("rejects non-numeric or out-of-range mock update ports", () => | |
| 49 | Effect.gen(function* () { | |
| 50 | const invalidPorts = ["abc", "12.5", "0", "65536"]; | |
| 51 | for (const port of invalidPorts) { | |
| 52 | const exit = yield* Effect.exit(resolveMockUpdateServerPort(port)); | |
| 53 | assert.equal(exit._tag, "Failure"); | |
| 54 | } | |
| 55 | }), | |
| 56 | ); | |
| 57 | ||
| 8 | 58 | it.effect("preserves explicit false boolean flags over true env defaults", () => |
| 9 | 59 | Effect.gen(function* () { |
| 10 | 60 | const resolved = yield* resolveBuildOptions({ |
scripts/build-desktop-artifact.ts+101-49| ... | ... | @@ -23,23 +23,14 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); |
| 23 | 23 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 24 | 24 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), |
| 25 | 25 | ); |
| 26 | const ProductionMacIconSource = Effect.zipWith( | |
| 27 | RepoRoot, | |
| 28 | Effect.service(Path.Path), | |
| 29 | (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng), | |
| 30 | ); | |
| 31 | const ProductionLinuxIconSource = Effect.zipWith( | |
| 32 | RepoRoot, | |
| 33 | Effect.service(Path.Path), | |
| 34 | (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionLinuxIconPng), | |
| 35 | ); | |
| 36 | const ProductionWindowsIconSource = Effect.zipWith( | |
| 37 | RepoRoot, | |
| 38 | Effect.service(Path.Path), | |
| 39 | (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionWindowsIconIco), | |
| 40 | ); | |
| 41 | 26 | const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); |
| 42 | 27 | |
| 28 | interface DesktopBuildIconAssets { | |
| 29 | readonly macIconPng: string; | |
| 30 | readonly linuxIconPng: string; | |
| 31 | readonly windowsIconIco: string; | |
| 32 | } | |
| 33 | ||
| 43 | 34 | interface PlatformConfig { |
| 44 | 35 | readonly cliFlag: "--mac" | "--linux" | "--win"; |
| 45 | 36 | readonly defaultTarget: string; |
| ... | ... | @@ -75,7 +66,7 @@ interface BuildCliInput { |
| 75 | 66 | readonly signed: Option.Option<boolean>; |
| 76 | 67 | readonly verbose: Option.Option<boolean>; |
| 77 | 68 | readonly mockUpdates: Option.Option<boolean>; |
| 78 | readonly mockUpdateServerPort: Option.Option<string>; | |
| 69 | readonly mockUpdateServerPort: Option.Option<number>; | |
| 79 | 70 | } |
| 80 | 71 | |
| 81 | 72 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { |
| ... | ... | @@ -165,7 +156,7 @@ interface ResolvedBuildOptions { |
| 165 | 156 | readonly signed: boolean; |
| 166 | 157 | readonly verbose: boolean; |
| 167 | 158 | readonly mockUpdates: boolean; |
| 168 | readonly mockUpdateServerPort: string | undefined; | |
| 159 | readonly mockUpdateServerPort: number | undefined; | |
| 169 | 160 | } |
| 170 | 161 | |
| 171 | 162 | interface StagePackageJson { |
| ... | ... | @@ -213,11 +204,28 @@ const BuildEnvConfig = Config.all({ |
| 213 | 204 | mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), |
| 214 | 205 | }); |
| 215 | 206 | |
| 207 | const MockUpdateServerPortSchema = Schema.NumberFromString.check( | |
| 208 | Schema.isInt(), | |
| 209 | Schema.isBetween({ minimum: 1, maximum: 65535 }), | |
| 210 | ); | |
| 211 | const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema); | |
| 212 | ||
| 216 | 213 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => |
| 217 | 214 | Option.getOrElse(flag, () => envValue); |
| 218 | 215 | const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) => |
| 219 | 216 | Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); |
| 220 | 217 | |
| 218 | export const resolveMockUpdateServerPort = Effect.fn("resolveMockUpdateServerPort")(function* ( | |
| 219 | mockUpdateServerPort: string | undefined, | |
| 220 | ) { | |
| 221 | const port = mockUpdateServerPort?.trim(); | |
| 222 | if (!port) { | |
| 223 | return undefined; | |
| 224 | } | |
| 225 | ||
| 226 | return yield* decodeMockUpdateServerPort(port); | |
| 227 | }); | |
| 228 | ||
| 221 | 229 | export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 222 | 230 | input: BuildCliInput, |
| 223 | 231 | ) { |
| ... | ... | @@ -254,11 +262,17 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 254 | 262 | const verbose = resolveBooleanFlag(input.verbose, env.verbose); |
| 255 | 263 | |
| 256 | 264 | const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates); |
| 257 | const mockUpdateServerPort = mergeOptions( | |
| 258 | input.mockUpdateServerPort, | |
| 259 | env.mockUpdateServerPort, | |
| 260 | undefined, | |
| 261 | ); | |
| 265 | const mockUpdateServerPort = | |
| 266 | Option.getOrUndefined(input.mockUpdateServerPort) ?? | |
| 267 | (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe( | |
| 268 | Effect.mapError( | |
| 269 | (cause) => | |
| 270 | new BuildScriptError({ | |
| 271 | message: "Invalid mock update server port.", | |
| 272 | cause, | |
| 273 | }), | |
| 274 | ), | |
| 275 | )); | |
| 262 | 276 | |
| 263 | 277 | return { |
| 264 | 278 | platform, |
| ... | ... | @@ -329,14 +343,13 @@ function generateMacIconSet( |
| 329 | 343 | }); |
| 330 | 344 | } |
| 331 | 345 | |
| 332 | function stageMacIcons(stageResourcesDir: string, verbose: boolean) { | |
| 346 | function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) { | |
| 333 | 347 | return Effect.gen(function* () { |
| 334 | 348 | const fs = yield* FileSystem.FileSystem; |
| 335 | 349 | const path = yield* Path.Path; |
| 336 | const iconSource = yield* ProductionMacIconSource; | |
| 337 | if (!(yield* fs.exists(iconSource))) { | |
| 350 | if (!(yield* fs.exists(sourcePng))) { | |
| 338 | 351 | return yield* new BuildScriptError({ |
| 339 | message: `Production icon source is missing at ${iconSource}`, | |
| 352 | message: `Desktop macOS icon source is missing at ${sourcePng}`, | |
| 340 | 353 | }); |
| 341 | 354 | } |
| 342 | 355 | |
| ... | ... | @@ -350,42 +363,40 @@ function stageMacIcons(stageResourcesDir: string, verbose: boolean) { |
| 350 | 363 | yield* runCommand( |
| 351 | 364 | ChildProcess.make({ |
| 352 | 365 | ...commandOutputOptions(verbose), |
| 353 | })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`, | |
| 366 | })`sips -z 512 512 ${sourcePng} --out ${iconPngPath}`, | |
| 354 | 367 | ); |
| 355 | 368 | |
| 356 | yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose); | |
| 369 | yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose); | |
| 357 | 370 | }); |
| 358 | 371 | } |
| 359 | 372 | |
| 360 | function stageLinuxIcons(stageResourcesDir: string) { | |
| 373 | function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) { | |
| 361 | 374 | return Effect.gen(function* () { |
| 362 | 375 | const fs = yield* FileSystem.FileSystem; |
| 363 | 376 | const path = yield* Path.Path; |
| 364 | const iconSource = yield* ProductionLinuxIconSource; | |
| 365 | if (!(yield* fs.exists(iconSource))) { | |
| 377 | if (!(yield* fs.exists(sourcePng))) { | |
| 366 | 378 | return yield* new BuildScriptError({ |
| 367 | message: `Production icon source is missing at ${iconSource}`, | |
| 379 | message: `Desktop Linux icon source is missing at ${sourcePng}`, | |
| 368 | 380 | }); |
| 369 | 381 | } |
| 370 | 382 | |
| 371 | 383 | const iconPath = path.join(stageResourcesDir, "icon.png"); |
| 372 | yield* fs.copyFile(iconSource, iconPath); | |
| 384 | yield* fs.copyFile(sourcePng, iconPath); | |
| 373 | 385 | }); |
| 374 | 386 | } |
| 375 | 387 | |
| 376 | function stageWindowsIcons(stageResourcesDir: string) { | |
| 388 | function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) { | |
| 377 | 389 | return Effect.gen(function* () { |
| 378 | 390 | const fs = yield* FileSystem.FileSystem; |
| 379 | 391 | const path = yield* Path.Path; |
| 380 | const iconSource = yield* ProductionWindowsIconSource; | |
| 381 | if (!(yield* fs.exists(iconSource))) { | |
| 392 | if (!(yield* fs.exists(sourceIco))) { | |
| 382 | 393 | return yield* new BuildScriptError({ |
| 383 | message: `Production Windows icon source is missing at ${iconSource}`, | |
| 394 | message: `Desktop Windows icon source is missing at ${sourceIco}`, | |
| 384 | 395 | }); |
| 385 | 396 | } |
| 386 | 397 | |
| 387 | 398 | const iconPath = path.join(stageResourcesDir, "icon.ico"); |
| 388 | yield* fs.copyFile(iconSource, iconPath); | |
| 399 | yield* fs.copyFile(sourceIco, iconPath); | |
| 389 | 400 | }); |
| 390 | 401 | } |
| 391 | 402 | |
| ... | ... | @@ -441,12 +452,13 @@ function resolveDesktopRuntimeDependencies( |
| 441 | 452 | return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop"); |
| 442 | 453 | } |
| 443 | 454 | |
| 444 | function resolveGitHubPublishConfig(): | |
| 455 | function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): | |
| 445 | 456 | | { |
| 446 | 457 | readonly provider: "github"; |
| 447 | 458 | readonly owner: string; |
| 448 | 459 | readonly repo: string; |
| 449 | readonly releaseType: "release"; | |
| 460 | readonly releaseType: "release" | "prerelease"; | |
| 461 | readonly channel?: "nightly"; | |
| 450 | 462 | } |
| 451 | 463 | | undefined { |
| 452 | 464 | const rawRepo = |
| ... | ... | @@ -462,17 +474,43 @@ function resolveGitHubPublishConfig(): |
| 462 | 474 | provider: "github", |
| 463 | 475 | owner, |
| 464 | 476 | repo, |
| 465 | releaseType: "release", | |
| 477 | releaseType: updateChannel === "nightly" ? "prerelease" : "release", | |
| 478 | ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), | |
| 466 | 479 | }; |
| 467 | 480 | } |
| 468 | 481 | |
| 482 | export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" { | |
| 483 | return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; | |
| 484 | } | |
| 485 | ||
| 486 | export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets { | |
| 487 | if (resolveDesktopUpdateChannel(version) === "nightly") { | |
| 488 | return { | |
| 489 | macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, | |
| 490 | linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, | |
| 491 | windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, | |
| 492 | }; | |
| 493 | } | |
| 494 | ||
| 495 | return { | |
| 496 | macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, | |
| 497 | linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, | |
| 498 | windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, | |
| 499 | }; | |
| 500 | } | |
| 501 | ||
| 502 | export function resolveMockUpdateServerUrl(mockUpdateServerPort: number | undefined): string { | |
| 503 | return `http://localhost:${mockUpdateServerPort ?? 3000}`; | |
| 504 | } | |
| 505 | ||
| 469 | 506 | const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 470 | 507 | platform: typeof BuildPlatform.Type, |
| 471 | 508 | target: string, |
| 472 | 509 | productName: string, |
| 510 | version: string, | |
| 473 | 511 | signed: boolean, |
| 474 | 512 | mockUpdates: boolean, |
| 475 | mockUpdateServerPort: string | undefined, | |
| 513 | mockUpdateServerPort: number | undefined, | |
| 476 | 514 | ) { |
| 477 | 515 | const buildConfig: Record<string, unknown> = { |
| 478 | 516 | appId: "com.t3tools.t3code", |
| ... | ... | @@ -482,14 +520,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 482 | 520 | buildResources: "apps/desktop/resources", |
| 483 | 521 | }, |
| 484 | 522 | }; |
| 485 | const publishConfig = resolveGitHubPublishConfig(); | |
| 523 | const updateChannel = resolveDesktopUpdateChannel(version); | |
| 524 | const publishConfig = resolveGitHubPublishConfig(updateChannel); | |
| 486 | 525 | if (publishConfig) { |
| 487 | 526 | buildConfig.publish = [publishConfig]; |
| 488 | 527 | } else if (mockUpdates) { |
| 489 | 528 | buildConfig.publish = [ |
| 490 | 529 | { |
| 491 | 530 | provider: "generic", |
| 492 | url: `http://localhost:${mockUpdateServerPort ?? 3000}`, | |
| 531 | url: resolveMockUpdateServerUrl(mockUpdateServerPort), | |
| 493 | 532 | }, |
| 494 | 533 | ]; |
| 495 | 534 | } |
| ... | ... | @@ -533,20 +572,21 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 533 | 572 | const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* ( |
| 534 | 573 | platform: typeof BuildPlatform.Type, |
| 535 | 574 | stageResourcesDir: string, |
| 575 | iconAssets: DesktopBuildIconAssets, | |
| 536 | 576 | verbose: boolean, |
| 537 | 577 | ) { |
| 538 | 578 | if (platform === "mac") { |
| 539 | yield* stageMacIcons(stageResourcesDir, verbose); | |
| 579 | yield* stageMacIcons(stageResourcesDir, iconAssets.macIconPng, verbose); | |
| 540 | 580 | return; |
| 541 | 581 | } |
| 542 | 582 | |
| 543 | 583 | if (platform === "linux") { |
| 544 | yield* stageLinuxIcons(stageResourcesDir); | |
| 584 | yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng); | |
| 545 | 585 | return; |
| 546 | 586 | } |
| 547 | 587 | |
| 548 | 588 | if (platform === "win") { |
| 549 | yield* stageWindowsIcons(stageResourcesDir); | |
| 589 | yield* stageWindowsIcons(stageResourcesDir, iconAssets.windowsIconIco); | |
| 550 | 590 | } |
| 551 | 591 | }); |
| 552 | 592 | |
| ... | ... | @@ -614,6 +654,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 614 | 654 | }); |
| 615 | 655 | |
| 616 | 656 | const appVersion = options.version ?? serverPackageJson.version; |
| 657 | const iconAssets = resolveDesktopBuildIconAssets(appVersion); | |
| 617 | 658 | const commitHash = resolveGitCommitHash(repoRoot); |
| 618 | 659 | const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; |
| 619 | 660 | const stageRoot = yield* mkdir({ |
| ... | ... | @@ -665,7 +706,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 665 | 706 | yield* fs.copy(distDirs.desktopResources, stageResourcesDir); |
| 666 | 707 | yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); |
| 667 | 708 | |
| 668 | yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose); | |
| 709 | yield* assertPlatformBuildResources( | |
| 710 | options.platform, | |
| 711 | stageResourcesDir, | |
| 712 | { | |
| 713 | macIconPng: join(repoRoot, iconAssets.macIconPng), | |
| 714 | linuxIconPng: join(repoRoot, iconAssets.linuxIconPng), | |
| 715 | windowsIconIco: join(repoRoot, iconAssets.windowsIconIco), | |
| 716 | }, | |
| 717 | options.verbose, | |
| 718 | ); | |
| 669 | 719 | |
| 670 | 720 | // electron-builder is filtering out stageResourcesDir directory in the AppImage for production |
| 671 | 721 | yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); |
| ... | ... | @@ -683,6 +733,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 683 | 733 | options.platform, |
| 684 | 734 | options.target, |
| 685 | 735 | desktopPackageJson.productName ?? "T3 Code", |
| 736 | appVersion, | |
| 686 | 737 | options.signed, |
| 687 | 738 | options.mockUpdates, |
| 688 | 739 | options.mockUpdateServerPort, |
| ... | ... | @@ -829,7 +880,8 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { |
| 829 | 880 | Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."), |
| 830 | 881 | Flag.optional, |
| 831 | 882 | ), |
| 832 | mockUpdateServerPort: Flag.string("mock-update-server-port").pipe( | |
| 883 | mockUpdateServerPort: Flag.integer("mock-update-server-port").pipe( | |
| 884 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), | |
| 833 | 885 | Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), |
| 834 | 886 | Flag.optional, |
| 835 | 887 | ), |
scripts/lib/brand-assets.ts+6| ... | ... | @@ -6,6 +6,12 @@ export const BRAND_ASSET_PATHS = { |
| 6 | 6 | productionWebFavicon16Png: "assets/prod/t3-black-web-favicon-16x16.png", |
| 7 | 7 | productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png", |
| 8 | 8 | productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png", |
| 9 | ||
| 10 | nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png", | |
| 11 | nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png", | |
| 12 | nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico", | |
| 13 | ||
| 14 | developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", | |
| 9 | 15 | developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", |
| 10 | 16 | developmentWebFaviconIco: "assets/dev/blueprint-web-favicon.ico", |
| 11 | 17 | developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png", |
scripts/release-smoke.ts+35-1| ... | ... | @@ -95,7 +95,7 @@ try { |
| 95 | 95 | }, |
| 96 | 96 | ); |
| 97 | 97 | |
| 98 | execFileSync("bun", ["install", "--lockfile-only", "--ignore-scripts"], { | |
| 98 | execFileSync("bun", ["install", "--ignore-scripts"], { | |
| 99 | 99 | cwd: tempRoot, |
| 100 | 100 | stdio: "inherit", |
| 101 | 101 | }); |
| ... | ... | @@ -107,6 +107,40 @@ try { |
| 107 | 107 | "Expected bun.lock to contain the smoke version.", |
| 108 | 108 | ); |
| 109 | 109 | |
| 110 | const nightlyReleaseMetadata = execFileSync( | |
| 111 | process.execPath, | |
| 112 | [ | |
| 113 | resolve(repoRoot, "scripts/resolve-nightly-release.ts"), | |
| 114 | "--date", | |
| 115 | "20260413", | |
| 116 | "--run-number", | |
| 117 | "321", | |
| 118 | "--sha", | |
| 119 | "abcdef1234567890", | |
| 120 | "--root", | |
| 121 | tempRoot, | |
| 122 | ], | |
| 123 | { | |
| 124 | cwd: repoRoot, | |
| 125 | encoding: "utf8", | |
| 126 | }, | |
| 127 | ); | |
| 128 | assertContains( | |
| 129 | nightlyReleaseMetadata, | |
| 130 | "version=9.9.9-nightly.20260413.321", | |
| 131 | "Expected nightly metadata to contain the derived nightly version.", | |
| 132 | ); | |
| 133 | assertContains( | |
| 134 | nightlyReleaseMetadata, | |
| 135 | "tag=nightly-v9.9.9-nightly.20260413.321", | |
| 136 | "Expected nightly metadata to contain the derived nightly tag.", | |
| 137 | ); | |
| 138 | assertContains( | |
| 139 | nightlyReleaseMetadata, | |
| 140 | "name=T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)", | |
| 141 | "Expected nightly metadata to include the short commit SHA in the release name.", | |
| 142 | ); | |
| 143 | ||
| 110 | 144 | const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); |
| 111 | 145 | execFileSync( |
| 112 | 146 | process.execPath, |
scripts/resolve-nightly-release.test.ts created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | resolveNightlyBaseVersion, | |
| 5 | resolveNightlyReleaseMetadata, | |
| 6 | } from "./resolve-nightly-release.ts"; | |
| 7 | ||
| 8 | it("strips prerelease and build metadata when deriving the nightly base version", () => { | |
| 9 | assert.equal(resolveNightlyBaseVersion("0.0.17"), "0.0.17"); | |
| 10 | assert.equal(resolveNightlyBaseVersion("9.9.9-smoke.0"), "9.9.9"); | |
| 11 | assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3"); | |
| 12 | }); | |
| 13 | ||
| 14 | it("derives nightly metadata including the short commit sha in the release name", () => { | |
| 15 | assert.deepStrictEqual( | |
| 16 | resolveNightlyReleaseMetadata("9.9.9", "20260413", 321, "abcdef1234567890"), | |
| 17 | { | |
| 18 | baseVersion: "9.9.9", | |
| 19 | version: "9.9.9-nightly.20260413.321", | |
| 20 | tag: "nightly-v9.9.9-nightly.20260413.321", | |
| 21 | name: "T3 Code Nightly 9.9.9-nightly.20260413.321 (abcdef123456)", | |
| 22 | shortSha: "abcdef123456", | |
| 23 | }, | |
| 24 | ); | |
| 25 | }); |
scripts/resolve-nightly-release.ts created+126| ... | ... | @@ -0,0 +1,126 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { Config, Effect, FileSystem, Option, Path, Schema } from "effect"; | |
| 6 | import { Command, Flag } from "effect/unstable/cli"; | |
| 7 | ||
| 8 | interface NightlyReleaseMetadata { | |
| 9 | readonly baseVersion: string; | |
| 10 | readonly version: string; | |
| 11 | readonly tag: string; | |
| 12 | readonly name: string; | |
| 13 | readonly shortSha: string; | |
| 14 | } | |
| 15 | ||
| 16 | const DateSchema = Schema.String.check(Schema.isPattern(/^\d{8}$/)); | |
| 17 | const RunNumberSchema = Schema.FiniteFromString.check( | |
| 18 | Schema.isInt(), | |
| 19 | Schema.isGreaterThanOrEqualTo(1), | |
| 20 | ); | |
| 21 | const ShaSchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{7,40}$/i)); | |
| 22 | const DesktopPackageJsonSchema = Schema.Struct({ | |
| 23 | version: Schema.NonEmptyString, | |
| 24 | }); | |
| 25 | ||
| 26 | const RepoRoot = Effect.service(Path.Path).pipe( | |
| 27 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | |
| 28 | ); | |
| 29 | const decodeDesktopPackageJson = Schema.decodeUnknownEffect( | |
| 30 | Schema.fromJsonString(DesktopPackageJsonSchema), | |
| 31 | ); | |
| 32 | ||
| 33 | export const resolveNightlyBaseVersion = (version: string) => version.replace(/[-+].*$/, ""); | |
| 34 | ||
| 35 | export const resolveNightlyReleaseMetadata = ( | |
| 36 | baseVersion: string, | |
| 37 | date: string, | |
| 38 | runNumber: number, | |
| 39 | sha: string, | |
| 40 | ) => { | |
| 41 | const shortSha = sha.slice(0, 12); | |
| 42 | const version = `${baseVersion}-nightly.${date}.${runNumber}`; | |
| 43 | return { | |
| 44 | baseVersion, | |
| 45 | version, | |
| 46 | tag: `nightly-v${version}`, | |
| 47 | name: `T3 Code Nightly ${version} (${shortSha})`, | |
| 48 | shortSha, | |
| 49 | }; | |
| 50 | }; | |
| 51 | ||
| 52 | const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( | |
| 53 | rootDir: string | undefined, | |
| 54 | ) { | |
| 55 | const fs = yield* FileSystem.FileSystem; | |
| 56 | const path = yield* Path.Path; | |
| 57 | const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot; | |
| 58 | const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json"); | |
| 59 | const packageJson = yield* fs | |
| 60 | .readFileString(packageJsonPath) | |
| 61 | .pipe(Effect.flatMap(decodeDesktopPackageJson)); | |
| 62 | return resolveNightlyBaseVersion(packageJson.version); | |
| 63 | }); | |
| 64 | ||
| 65 | const writeOutput = Effect.fn("writeOutput")(function* ( | |
| 66 | metadata: NightlyReleaseMetadata, | |
| 67 | writeGithubOutput: boolean, | |
| 68 | ) { | |
| 69 | const fs = yield* FileSystem.FileSystem; | |
| 70 | ||
| 71 | const entries = [ | |
| 72 | ["base_version", metadata.baseVersion], | |
| 73 | ["version", metadata.version], | |
| 74 | ["tag", metadata.tag], | |
| 75 | ["name", metadata.name], | |
| 76 | ["short_sha", metadata.shortSha], | |
| 77 | ] as const; | |
| 78 | ||
| 79 | if (writeGithubOutput) { | |
| 80 | const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); | |
| 81 | const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join(""); | |
| 82 | yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }); | |
| 83 | } else { | |
| 84 | for (const [key, value] of entries) { | |
| 85 | console.log(`${key}=${value}`); | |
| 86 | } | |
| 87 | } | |
| 88 | }); | |
| 89 | ||
| 90 | const command = Command.make( | |
| 91 | "resolve-nightly-release", | |
| 92 | { | |
| 93 | date: Flag.string("date").pipe( | |
| 94 | Flag.withSchema(DateSchema), | |
| 95 | Flag.withDescription("Nightly build date in YYYYMMDD."), | |
| 96 | ), | |
| 97 | runNumber: Flag.string("run-number").pipe( | |
| 98 | Flag.withSchema(RunNumberSchema), | |
| 99 | Flag.withDescription("GitHub Actions run number."), | |
| 100 | ), | |
| 101 | sha: Flag.string("sha").pipe( | |
| 102 | Flag.withSchema(ShaSchema), | |
| 103 | Flag.withDescription("Commit sha for the nightly build."), | |
| 104 | ), | |
| 105 | githubOutput: Flag.boolean("github-output").pipe( | |
| 106 | Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."), | |
| 107 | Flag.withDefault(false), | |
| 108 | ), | |
| 109 | root: Flag.string("root").pipe( | |
| 110 | Flag.withDescription("Workspace root used to resolve apps/desktop/package.json."), | |
| 111 | Flag.optional, | |
| 112 | ), | |
| 113 | }, | |
| 114 | ({ date, runNumber, sha, githubOutput, root }) => | |
| 115 | readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( | |
| 116 | Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), | |
| 117 | Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)), | |
| 118 | ), | |
| 119 | ).pipe(Command.withDescription("Resolve nightly release version metadata.")); | |
| 120 | ||
| 121 | if (import.meta.main) { | |
| 122 | Command.run(command, { version: "0.0.0" }).pipe( | |
| 123 | Effect.provide(NodeServices.layer), | |
| 124 | NodeRuntime.runMain, | |
| 125 | ); | |
| 126 | } |
scripts/resolve-previous-release-tag.ts created+203| ... | ... | @@ -0,0 +1,203 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { Array, Config, Effect, FileSystem, Schema, Stream, String } from "effect"; | |
| 6 | import { Command, Flag } from "effect/unstable/cli"; | |
| 7 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 8 | ||
| 9 | const ReleaseChannel = Schema.Literals(["stable", "nightly"]); | |
| 10 | type ReleaseChannel = typeof ReleaseChannel.Type; | |
| 11 | ||
| 12 | interface StableVersion { | |
| 13 | readonly major: number; | |
| 14 | readonly minor: number; | |
| 15 | readonly patch: number; | |
| 16 | readonly prerelease: ReadonlyArray<string>; | |
| 17 | } | |
| 18 | ||
| 19 | interface NightlyVersion { | |
| 20 | readonly major: number; | |
| 21 | readonly minor: number; | |
| 22 | readonly patch: number; | |
| 23 | readonly date: number; | |
| 24 | readonly runNumber: number; | |
| 25 | } | |
| 26 | ||
| 27 | const parseNumericIdentifier = (identifier: string): number | undefined => | |
| 28 | /^\d+$/.test(identifier) ? Number(identifier) : undefined; | |
| 29 | ||
| 30 | const comparePrereleaseIdentifiers = (left: string, right: string): number => { | |
| 31 | const leftNumeric = parseNumericIdentifier(left); | |
| 32 | const rightNumeric = parseNumericIdentifier(right); | |
| 33 | ||
| 34 | if (leftNumeric !== undefined && rightNumeric !== undefined) { | |
| 35 | return leftNumeric - rightNumeric; | |
| 36 | } | |
| 37 | if (leftNumeric !== undefined) { | |
| 38 | return -1; | |
| 39 | } | |
| 40 | if (rightNumeric !== undefined) { | |
| 41 | return 1; | |
| 42 | } | |
| 43 | return left.localeCompare(right); | |
| 44 | }; | |
| 45 | ||
| 46 | const compareStableVersions = (left: StableVersion, right: StableVersion): number => { | |
| 47 | if (left.major !== right.major) return left.major - right.major; | |
| 48 | if (left.minor !== right.minor) return left.minor - right.minor; | |
| 49 | if (left.patch !== right.patch) return left.patch - right.patch; | |
| 50 | ||
| 51 | const leftHasPrerelease = left.prerelease.length > 0; | |
| 52 | const rightHasPrerelease = right.prerelease.length > 0; | |
| 53 | if (!leftHasPrerelease && !rightHasPrerelease) return 0; | |
| 54 | if (!leftHasPrerelease) return 1; | |
| 55 | if (!rightHasPrerelease) return -1; | |
| 56 | ||
| 57 | const maxLength = Math.max(left.prerelease.length, right.prerelease.length); | |
| 58 | for (let index = 0; index < maxLength; index += 1) { | |
| 59 | const leftIdentifier = left.prerelease[index]; | |
| 60 | const rightIdentifier = right.prerelease[index]; | |
| 61 | if (leftIdentifier === undefined) return -1; | |
| 62 | if (rightIdentifier === undefined) return 1; | |
| 63 | ||
| 64 | const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier); | |
| 65 | if (comparison !== 0) return comparison; | |
| 66 | } | |
| 67 | ||
| 68 | return 0; | |
| 69 | }; | |
| 70 | ||
| 71 | const parseStableTag = (tag: string): StableVersion | undefined => { | |
| 72 | const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(tag); | |
| 73 | if (!match) return undefined; | |
| 74 | ||
| 75 | const [, major, minor, patch, prerelease] = match; | |
| 76 | if (!major || !minor || !patch) return undefined; | |
| 77 | ||
| 78 | return { | |
| 79 | major: Number(major), | |
| 80 | minor: Number(minor), | |
| 81 | patch: Number(patch), | |
| 82 | prerelease: prerelease ? prerelease.split(".") : [], | |
| 83 | }; | |
| 84 | }; | |
| 85 | ||
| 86 | const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => { | |
| 87 | if (left.major !== right.major) return left.major - right.major; | |
| 88 | if (left.minor !== right.minor) return left.minor - right.minor; | |
| 89 | if (left.patch !== right.patch) return left.patch - right.patch; | |
| 90 | if (left.date !== right.date) return left.date - right.date; | |
| 91 | return left.runNumber - right.runNumber; | |
| 92 | }; | |
| 93 | ||
| 94 | const parseNightlyTag = (tag: string): NightlyVersion | undefined => { | |
| 95 | const match = /^nightly-v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag); | |
| 96 | if (!match) return undefined; | |
| 97 | ||
| 98 | const [, major, minor, patch, date, runNumber] = match; | |
| 99 | if (!major || !minor || !patch || !date || !runNumber) return undefined; | |
| 100 | ||
| 101 | return { | |
| 102 | major: Number(major), | |
| 103 | minor: Number(minor), | |
| 104 | patch: Number(patch), | |
| 105 | date: Number(date), | |
| 106 | runNumber: Number(runNumber), | |
| 107 | }; | |
| 108 | }; | |
| 109 | ||
| 110 | const resolvePreviousReleaseTag = ( | |
| 111 | channel: ReleaseChannel, | |
| 112 | currentTag: string, | |
| 113 | tags: ReadonlyArray<string>, | |
| 114 | ): string | undefined => { | |
| 115 | if (channel === "stable") { | |
| 116 | const current = parseStableTag(currentTag); | |
| 117 | if (!current) { | |
| 118 | throw new Error(`Invalid stable release tag '${currentTag}'.`); | |
| 119 | } | |
| 120 | ||
| 121 | const candidates = tags | |
| 122 | .map((tag) => ({ tag, parsed: parseStableTag(tag) })) | |
| 123 | .filter( | |
| 124 | (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, | |
| 125 | ) | |
| 126 | .filter((entry) => compareStableVersions(entry.parsed, current) < 0) | |
| 127 | .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); | |
| 128 | ||
| 129 | return candidates[0]?.tag; | |
| 130 | } | |
| 131 | ||
| 132 | const current = parseNightlyTag(currentTag); | |
| 133 | if (!current) { | |
| 134 | throw new Error(`Invalid nightly release tag '${currentTag}'.`); | |
| 135 | } | |
| 136 | ||
| 137 | const candidates = tags | |
| 138 | .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) | |
| 139 | .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined) | |
| 140 | .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) | |
| 141 | .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); | |
| 142 | ||
| 143 | return candidates[0]?.tag; | |
| 144 | }; | |
| 145 | ||
| 146 | const listGitTags = Effect.fn("listGitTags")(function* () { | |
| 147 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 148 | const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"])); | |
| 149 | const tags = yield* child.stdout.pipe( | |
| 150 | Stream.decodeText(), | |
| 151 | Stream.runFold( | |
| 152 | () => "", | |
| 153 | (acc, chunk) => acc + chunk, | |
| 154 | ), | |
| 155 | Effect.map(String.split(/\r?\n/)), | |
| 156 | Effect.map(Array.map(String.trim)), | |
| 157 | Effect.map(Array.filter(String.isNonEmpty)), | |
| 158 | ); | |
| 159 | return tags; | |
| 160 | }); | |
| 161 | ||
| 162 | const writeOutput = Effect.fn("writeOutput")(function* ( | |
| 163 | previousTag: string | undefined, | |
| 164 | writeGithubOutput: boolean, | |
| 165 | ) { | |
| 166 | const entry = `previous_tag=${previousTag ?? ""}\n`; | |
| 167 | ||
| 168 | if (writeGithubOutput) { | |
| 169 | const fs = yield* FileSystem.FileSystem; | |
| 170 | const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); | |
| 171 | yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }); | |
| 172 | return; | |
| 173 | } | |
| 174 | ||
| 175 | process.stdout.write(entry); | |
| 176 | }); | |
| 177 | ||
| 178 | const command = Command.make( | |
| 179 | "resolve-previous-release-tag", | |
| 180 | { | |
| 181 | channel: Flag.choice("channel", ReleaseChannel.literals).pipe( | |
| 182 | Flag.withDescription("Release channel whose previous tag should be resolved."), | |
| 183 | ), | |
| 184 | currentTag: Flag.string("current-tag").pipe( | |
| 185 | Flag.withDescription("Current release tag to compare against."), | |
| 186 | ), | |
| 187 | githubOutput: Flag.boolean("github-output").pipe( | |
| 188 | Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."), | |
| 189 | Flag.withDefault(false), | |
| 190 | ), | |
| 191 | }, | |
| 192 | ({ channel, currentTag, githubOutput }) => | |
| 193 | listGitTags().pipe( | |
| 194 | Effect.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), | |
| 195 | Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)), | |
| 196 | ), | |
| 197 | ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); | |
| 198 | ||
| 199 | Command.run(command, { version: "0.0.0" }).pipe( | |
| 200 | Effect.scoped, | |
| 201 | Effect.provide(NodeServices.layer), | |
| 202 | NodeRuntime.runMain, | |
| 203 | ); |