History

Nightly release channel (#2012)

Julius Marminge committed 7 months agonightly-v0.0.17-nightly.20260415.44

Commit 409ff901 parent 0d2802649 files · +1203 −131Browse files

Showing the changes to scripts (7 of 49 files). Show all

Unified · Split

7 files
  1. scripts/build-desktop-artifact.test.ts +51 −1
  2. scripts/build-desktop-artifact.ts +101 −49
  3. scripts/lib/brand-assets.ts +6 −0
  4. scripts/release-smoke.ts +35 −1
  5. scripts/resolve-nightly-release.test.ts +25 −0
  6. scripts/resolve-nightly-release.ts +126 −0
  7. scripts/resolve-previous-release-tag.ts +203 −0
scripts/build-desktop-artifact.test.ts+51−1
@@ -2,9 +2,59 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 1 unmodified line
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import { ConfigProvider, Effect, Option } from "effect";3import { ConfigProvider, Effect, Option } from "effect";
4 4
5import { resolveBuildOptions } from "./build-desktop-artifact.ts";5import {
6 resolveBuildOptions,
7 resolveDesktopBuildIconAssets,
8 resolveDesktopUpdateChannel,
9 resolveMockUpdateServerPort,
10 resolveMockUpdateServerUrl,
11} from "./build-desktop-artifact.ts";
12import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
6 13
7it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {14it.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 it.effect("preserves explicit false boolean flags over true env defaults", () =>58 it.effect("preserves explicit false boolean flags over true env defaults", () =>
9 Effect.gen(function* () {59 Effect.gen(function* () {
10 const resolved = yield* resolveBuildOptions({60 const resolved = yield* resolveBuildOptions({
scripts/build-desktop-artifact.ts+101−49
@@ -23,23 +23,14 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); · 22 unmodified lines
23const RepoRoot = Effect.service(Path.Path).pipe(23const RepoRoot = Effect.service(Path.Path).pipe(
24 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),24 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
25);25);
26const ProductionMacIconSource = Effect.zipWith(
27 RepoRoot,
28 Effect.service(Path.Path),
29 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng),
30);
31const ProductionLinuxIconSource = Effect.zipWith(
32 RepoRoot,
33 Effect.service(Path.Path),
34 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionLinuxIconPng),
35);
36const ProductionWindowsIconSource = Effect.zipWith(
37 RepoRoot,
38 Effect.service(Path.Path),
39 (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionWindowsIconIco),
40);
41const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);26const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
42 27
28interface DesktopBuildIconAssets {
29 readonly macIconPng: string;
30 readonly linuxIconPng: string;
31 readonly windowsIconIco: string;
32}
33
43interface PlatformConfig {34interface PlatformConfig {
44 readonly cliFlag: "--mac" | "--linux" | "--win";35 readonly cliFlag: "--mac" | "--linux" | "--win";
45 readonly defaultTarget: string;36 readonly defaultTarget: string;
@@ -75,7 +66,7 @@ interface BuildCliInput { · 29 unmodified lines
75 readonly signed: Option.Option<boolean>;66 readonly signed: Option.Option<boolean>;
76 readonly verbose: Option.Option<boolean>;67 readonly verbose: Option.Option<boolean>;
77 readonly mockUpdates: Option.Option<boolean>;68 readonly mockUpdates: Option.Option<boolean>;
78 readonly mockUpdateServerPort: Option.Option<string>;69 readonly mockUpdateServerPort: Option.Option<number>;
79}70}
80 71
81function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {72function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
@@ -165,7 +156,7 @@ interface ResolvedBuildOptions { · 83 unmodified lines
165 readonly signed: boolean;156 readonly signed: boolean;
166 readonly verbose: boolean;157 readonly verbose: boolean;
167 readonly mockUpdates: boolean;158 readonly mockUpdates: boolean;
168 readonly mockUpdateServerPort: string | undefined;159 readonly mockUpdateServerPort: number | undefined;
169}160}
170 161
171interface StagePackageJson {162interface StagePackageJson {
@@ -213,11 +204,28 @@ const BuildEnvConfig = Config.all({ · 41 unmodified lines
213 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),204 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),
214});205});
215 206
207const MockUpdateServerPortSchema = Schema.NumberFromString.check(
208 Schema.isInt(),
209 Schema.isBetween({ minimum: 1, maximum: 65535 }),
210);
211const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema);
212
216const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>213const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
217 Option.getOrElse(flag, () => envValue);214 Option.getOrElse(flag, () => envValue);
218const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>215const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>
219 Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue));216 Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue));
220 217
218export 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
221export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (229export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
222 input: BuildCliInput,230 input: BuildCliInput,
223) {231) {
@@ -254,11 +262,17 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( · 30 unmodified lines
254 const verbose = resolveBooleanFlag(input.verbose, env.verbose);262 const verbose = resolveBooleanFlag(input.verbose, env.verbose);
255 263
256 const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates);264 const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates);
257 const mockUpdateServerPort = mergeOptions(265 const mockUpdateServerPort =
258 input.mockUpdateServerPort,266 Option.getOrUndefined(input.mockUpdateServerPort) ??
259 env.mockUpdateServerPort,267 (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe(
260 undefined,268 Effect.mapError(
261 );269 (cause) =>
270 new BuildScriptError({
271 message: "Invalid mock update server port.",
272 cause,
273 }),
274 ),
275 ));
262 276
263 return {277 return {
264 platform,278 platform,
@@ -329,14 +343,13 @@ function generateMacIconSet( · 64 unmodified lines
329 });343 });
330}344}
331 345
332function stageMacIcons(stageResourcesDir: string, verbose: boolean) {346function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
333 return Effect.gen(function* () {347 return Effect.gen(function* () {
334 const fs = yield* FileSystem.FileSystem;348 const fs = yield* FileSystem.FileSystem;
335 const path = yield* Path.Path;349 const path = yield* Path.Path;
336 const iconSource = yield* ProductionMacIconSource;350 if (!(yield* fs.exists(sourcePng))) {
337 if (!(yield* fs.exists(iconSource))) {
338 return yield* new BuildScriptError({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 yield* runCommand(363 yield* runCommand(
351 ChildProcess.make({364 ChildProcess.make({
352 ...commandOutputOptions(verbose),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
360function stageLinuxIcons(stageResourcesDir: string) {373function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) {
361 return Effect.gen(function* () {374 return Effect.gen(function* () {
362 const fs = yield* FileSystem.FileSystem;375 const fs = yield* FileSystem.FileSystem;
363 const path = yield* Path.Path;376 const path = yield* Path.Path;
364 const iconSource = yield* ProductionLinuxIconSource;377 if (!(yield* fs.exists(sourcePng))) {
365 if (!(yield* fs.exists(iconSource))) {
366 return yield* new BuildScriptError({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 const iconPath = path.join(stageResourcesDir, "icon.png");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
376function stageWindowsIcons(stageResourcesDir: string) {388function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) {
377 return Effect.gen(function* () {389 return Effect.gen(function* () {
378 const fs = yield* FileSystem.FileSystem;390 const fs = yield* FileSystem.FileSystem;
379 const path = yield* Path.Path;391 const path = yield* Path.Path;
380 const iconSource = yield* ProductionWindowsIconSource;392 if (!(yield* fs.exists(sourceIco))) {
381 if (!(yield* fs.exists(iconSource))) {
382 return yield* new BuildScriptError({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 const iconPath = path.join(stageResourcesDir, "icon.ico");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 return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");452 return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");
442}453}
443 454
444function resolveGitHubPublishConfig():455function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"):
445 | {456 | {
446 readonly provider: "github";457 readonly provider: "github";
447 readonly owner: string;458 readonly owner: string;
448 readonly repo: string;459 readonly repo: string;
449 readonly releaseType: "release";460 readonly releaseType: "release" | "prerelease";
461 readonly channel?: "nightly";
450 }462 }
451 | undefined {463 | undefined {
452 const rawRepo =464 const rawRepo =
@@ -462,17 +474,43 @@ function resolveGitHubPublishConfig(): · 9 unmodified lines
462 provider: "github",474 provider: "github",
463 owner,475 owner,
464 repo,476 repo,
465 releaseType: "release",477 releaseType: updateChannel === "nightly" ? "prerelease" : "release",
478 ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}),
466 };479 };
467}480}
468 481
482export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" {
483 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";
484}
485
486export 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
502export function resolveMockUpdateServerUrl(mockUpdateServerPort: number | undefined): string {
503 return `http://localhost:${mockUpdateServerPort ?? 3000}`;
504}
505
469const createBuildConfig = Effect.fn("createBuildConfig")(function* (506const createBuildConfig = Effect.fn("createBuildConfig")(function* (
470 platform: typeof BuildPlatform.Type,507 platform: typeof BuildPlatform.Type,
471 target: string,508 target: string,
472 productName: string,509 productName: string,
510 version: string,
473 signed: boolean,511 signed: boolean,
474 mockUpdates: boolean,512 mockUpdates: boolean,
475 mockUpdateServerPort: string | undefined,513 mockUpdateServerPort: number | undefined,
476) {514) {
477 const buildConfig: Record<string, unknown> = {515 const buildConfig: Record<string, unknown> = {
478 appId: "com.t3tools.t3code",516 appId: "com.t3tools.t3code",
@@ -482,14 +520,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 3 unmodified lines
482 buildResources: "apps/desktop/resources",520 buildResources: "apps/desktop/resources",
483 },521 },
484 };522 };
485 const publishConfig = resolveGitHubPublishConfig();523 const updateChannel = resolveDesktopUpdateChannel(version);
524 const publishConfig = resolveGitHubPublishConfig(updateChannel);
486 if (publishConfig) {525 if (publishConfig) {
487 buildConfig.publish = [publishConfig];526 buildConfig.publish = [publishConfig];
488 } else if (mockUpdates) {527 } else if (mockUpdates) {
489 buildConfig.publish = [528 buildConfig.publish = [
490 {529 {
491 provider: "generic",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
533const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* (572const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* (
534 platform: typeof BuildPlatform.Type,573 platform: typeof BuildPlatform.Type,
535 stageResourcesDir: string,574 stageResourcesDir: string,
575 iconAssets: DesktopBuildIconAssets,
536 verbose: boolean,576 verbose: boolean,
537) {577) {
538 if (platform === "mac") {578 if (platform === "mac") {
539 yield* stageMacIcons(stageResourcesDir, verbose);579 yield* stageMacIcons(stageResourcesDir, iconAssets.macIconPng, verbose);
540 return;580 return;
541 }581 }
542 582
543 if (platform === "linux") {583 if (platform === "linux") {
544 yield* stageLinuxIcons(stageResourcesDir);584 yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng);
545 return;585 return;
546 }586 }
547 587
548 if (platform === "win") {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 const appVersion = options.version ?? serverPackageJson.version;656 const appVersion = options.version ?? serverPackageJson.version;
657 const iconAssets = resolveDesktopBuildIconAssets(appVersion);
617 const commitHash = resolveGitCommitHash(repoRoot);658 const commitHash = resolveGitCommitHash(repoRoot);
618 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;659 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
619 const stageRoot = yield* mkdir({660 const stageRoot = yield* mkdir({
@@ -665,7 +706,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 45 unmodified lines
665 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);706 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
666 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));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 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production720 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production
671 yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));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 options.platform,733 options.platform,
684 options.target,734 options.target,
685 desktopPackageJson.productName ?? "T3 Code",735 desktopPackageJson.productName ?? "T3 Code",
736 appVersion,
686 options.signed,737 options.signed,
687 options.mockUpdates,738 options.mockUpdates,
688 options.mockUpdateServerPort,739 options.mockUpdateServerPort,
@@ -829,7 +880,8 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { · 140 unmodified lines
829 Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."),880 Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."),
830 Flag.optional,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 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),885 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
834 Flag.optional,886 Flag.optional,
835 ),887 ),
scripts/lib/brand-assets.ts+6−0
@@ -6,6 +6,12 @@ export const BRAND_ASSET_PATHS = { · 5 unmodified lines
6 productionWebFavicon16Png: "assets/prod/t3-black-web-favicon-16x16.png",6 productionWebFavicon16Png: "assets/prod/t3-black-web-favicon-16x16.png",
7 productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",7 productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",
8 productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png",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 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",15 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",
10 developmentWebFaviconIco: "assets/dev/blueprint-web-favicon.ico",16 developmentWebFaviconIco: "assets/dev/blueprint-web-favicon.ico",
11 developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png",17 developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png",
scripts/release-smoke.ts+35−1
@@ -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 cwd: tempRoot,99 cwd: tempRoot,
100 stdio: "inherit",100 stdio: "inherit",
101 });101 });
@@ -107,6 +107,40 @@ try { · 5 unmodified lines
107 "Expected bun.lock to contain the smoke version.",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 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);144 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);
111 execFileSync(145 execFileSync(
112 process.execPath,146 process.execPath,
scripts/resolve-nightly-release.test.ts+25−0
@@ -0,0 +1,25 @@
1import { assert, it } from "@effect/vitest";
2
3import {
4 resolveNightlyBaseVersion,
5 resolveNightlyReleaseMetadata,
6} from "./resolve-nightly-release.ts";
7
8it("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
14it("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+126−0
@@ -0,0 +1,126 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Config, Effect, FileSystem, Option, Path, Schema } from "effect";
6import { Command, Flag } from "effect/unstable/cli";
7
8interface NightlyReleaseMetadata {
9 readonly baseVersion: string;
10 readonly version: string;
11 readonly tag: string;
12 readonly name: string;
13 readonly shortSha: string;
14}
15
16const DateSchema = Schema.String.check(Schema.isPattern(/^\d{8}$/));
17const RunNumberSchema = Schema.FiniteFromString.check(
18 Schema.isInt(),
19 Schema.isGreaterThanOrEqualTo(1),
20);
21const ShaSchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{7,40}$/i));
22const DesktopPackageJsonSchema = Schema.Struct({
23 version: Schema.NonEmptyString,
24});
25
26const RepoRoot = Effect.service(Path.Path).pipe(
27 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
28);
29const decodeDesktopPackageJson = Schema.decodeUnknownEffect(
30 Schema.fromJsonString(DesktopPackageJsonSchema),
31);
32
33export const resolveNightlyBaseVersion = (version: string) => version.replace(/[-+].*$/, "");
34
35export 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
52const 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
65const 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
90const 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
121if (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+203−0
@@ -0,0 +1,203 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Array, Config, Effect, FileSystem, Schema, Stream, String } from "effect";
6import { Command, Flag } from "effect/unstable/cli";
7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8
9const ReleaseChannel = Schema.Literals(["stable", "nightly"]);
10type ReleaseChannel = typeof ReleaseChannel.Type;
11
12interface StableVersion {
13 readonly major: number;
14 readonly minor: number;
15 readonly patch: number;
16 readonly prerelease: ReadonlyArray<string>;
17}
18
19interface NightlyVersion {
20 readonly major: number;
21 readonly minor: number;
22 readonly patch: number;
23 readonly date: number;
24 readonly runNumber: number;
25}
26
27const parseNumericIdentifier = (identifier: string): number | undefined =>
28 /^\d+$/.test(identifier) ? Number(identifier) : undefined;
29
30const 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
46const 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
71const 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
86const 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
94const 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
110const 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
146const 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
162const 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
178const 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
199Command.run(command, { version: "0.0.0" }).pipe(
200 Effect.scoped,
201 Effect.provide(NodeServices.layer),
202 NodeRuntime.runMain,
203);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t