Showing the changes to scripts (7 of 49 files). Show all
Unified · Split
| @@ -2,9 +2,59 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 1 unmodified line | ||
| 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({ |
| @@ -23,23 +23,14 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); · 22 unmodified lines | ||
| 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 { · 29 unmodified lines | ||
| 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 { · 83 unmodified lines | ||
| 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({ · 41 unmodified lines | ||
| 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* ( · 30 unmodified lines | ||
| 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( · 64 unmodified lines | ||
| 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) { · 7 unmodified lines | ||
| 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( · 49 unmodified lines | ||
| 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(): · 9 unmodified lines | ||
| 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* ( · 3 unmodified lines | ||
| 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* ( · 37 unmodified lines | ||
| 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* ( · 61 unmodified lines | ||
| 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* ( · 45 unmodified lines | ||
| 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* ( · 11 unmodified lines | ||
| 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", { · 140 unmodified lines | ||
| 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 | ), |
| @@ -6,6 +6,12 @@ export const BRAND_ASSET_PATHS = { · 5 unmodified lines | ||
| 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", |
| @@ -95,7 +95,7 @@ try { · 94 unmodified lines | ||
| 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 { · 5 unmodified lines | ||
| 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, |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +); | |