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
22 import { assert, it } from "@effect/vitest";
33 import { ConfigProvider, Effect, Option } from "effect";
44
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";
613
714 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+
858 it.effect("preserves explicit false boolean flags over true env defaults", () =>
959 Effect.gen(function* () {
1060 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
2323 const RepoRoot = Effect.service(Path.Path).pipe(
2424 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
2525 );
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−);
4126 const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString);
4227
28+interface DesktopBuildIconAssets {
29+ readonly macIconPng: string;
30+ readonly linuxIconPng: string;
31+ readonly windowsIconIco: string;
32+}
33+
4334 interface PlatformConfig {
4435 readonly cliFlag: "--mac" | "--linux" | "--win";
4536 readonly defaultTarget: string;
@@ -75,7 +66,7 @@ interface BuildCliInput { · 29 unmodified lines
7566 readonly signed: Option.Option<boolean>;
7667 readonly verbose: Option.Option<boolean>;
7768 readonly mockUpdates: Option.Option<boolean>;
78− readonly mockUpdateServerPort: Option.Option<string>;
69+ readonly mockUpdateServerPort: Option.Option<number>;
7970 }
8071
8172 function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
@@ -165,7 +156,7 @@ interface ResolvedBuildOptions { · 83 unmodified lines
165156 readonly signed: boolean;
166157 readonly verbose: boolean;
167158 readonly mockUpdates: boolean;
168− readonly mockUpdateServerPort: string | undefined;
159+ readonly mockUpdateServerPort: number | undefined;
169160 }
170161
171162 interface StagePackageJson {
@@ -213,11 +204,28 @@ const BuildEnvConfig = Config.all({ · 41 unmodified lines
213204 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),
214205 });
215206
207+const MockUpdateServerPortSchema = Schema.NumberFromString.check(
208+ Schema.isInt(),
209+ Schema.isBetween({ minimum: 1, maximum: 65535 }),
210+);
211+const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema);
212+
216213 const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
217214 Option.getOrElse(flag, () => envValue);
218215 const mergeOptions = <A>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>
219216 Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue));
220217
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+
221229 export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
222230 input: BuildCliInput,
223231 ) {
@@ -254,11 +262,17 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( · 30 unmodified lines
254262 const verbose = resolveBooleanFlag(input.verbose, env.verbose);
255263
256264 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+ ));
262276
263277 return {
264278 platform,
@@ -329,14 +343,13 @@ function generateMacIconSet( · 64 unmodified lines
329343 });
330344 }
331345
332−function stageMacIcons(stageResourcesDir: string, verbose: boolean) {
346+function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) {
333347 return Effect.gen(function* () {
334348 const fs = yield* FileSystem.FileSystem;
335349 const path = yield* Path.Path;
336− const iconSource = yield* ProductionMacIconSource;
337− if (!(yield* fs.exists(iconSource))) {
350+ if (!(yield* fs.exists(sourcePng))) {
338351 return yield* new BuildScriptError({
339− message: `Production icon source is missing at ${iconSource}`,
352+ message: `Desktop macOS icon source is missing at ${sourcePng}`,
340353 });
341354 }
342355
@@ -350,42 +363,40 @@ function stageMacIcons(stageResourcesDir: string, verbose: boolean) { · 7 unmodified lines
350363 yield* runCommand(
351364 ChildProcess.make({
352365 ...commandOutputOptions(verbose),
353− })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`,
366+ })`sips -z 512 512 ${sourcePng} --out ${iconPngPath}`,
354367 );
355368
356− yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose);
369+ yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose);
357370 });
358371 }
359372
360−function stageLinuxIcons(stageResourcesDir: string) {
373+function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) {
361374 return Effect.gen(function* () {
362375 const fs = yield* FileSystem.FileSystem;
363376 const path = yield* Path.Path;
364− const iconSource = yield* ProductionLinuxIconSource;
365− if (!(yield* fs.exists(iconSource))) {
377+ if (!(yield* fs.exists(sourcePng))) {
366378 return yield* new BuildScriptError({
367− message: `Production icon source is missing at ${iconSource}`,
379+ message: `Desktop Linux icon source is missing at ${sourcePng}`,
368380 });
369381 }
370382
371383 const iconPath = path.join(stageResourcesDir, "icon.png");
372− yield* fs.copyFile(iconSource, iconPath);
384+ yield* fs.copyFile(sourcePng, iconPath);
373385 });
374386 }
375387
376−function stageWindowsIcons(stageResourcesDir: string) {
388+function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) {
377389 return Effect.gen(function* () {
378390 const fs = yield* FileSystem.FileSystem;
379391 const path = yield* Path.Path;
380− const iconSource = yield* ProductionWindowsIconSource;
381− if (!(yield* fs.exists(iconSource))) {
392+ if (!(yield* fs.exists(sourceIco))) {
382393 return yield* new BuildScriptError({
383− message: `Production Windows icon source is missing at ${iconSource}`,
394+ message: `Desktop Windows icon source is missing at ${sourceIco}`,
384395 });
385396 }
386397
387398 const iconPath = path.join(stageResourcesDir, "icon.ico");
388− yield* fs.copyFile(iconSource, iconPath);
399+ yield* fs.copyFile(sourceIco, iconPath);
389400 });
390401 }
391402
@@ -441,12 +452,13 @@ function resolveDesktopRuntimeDependencies( · 49 unmodified lines
441452 return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop");
442453 }
443454
444−function resolveGitHubPublishConfig():
455+function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"):
445456 | {
446457 readonly provider: "github";
447458 readonly owner: string;
448459 readonly repo: string;
449− readonly releaseType: "release";
460+ readonly releaseType: "release" | "prerelease";
461+ readonly channel?: "nightly";
450462 }
451463 | undefined {
452464 const rawRepo =
@@ -462,17 +474,43 @@ function resolveGitHubPublishConfig(): · 9 unmodified lines
462474 provider: "github",
463475 owner,
464476 repo,
465− releaseType: "release",
477+ releaseType: updateChannel === "nightly" ? "prerelease" : "release",
478+ ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}),
466479 };
467480 }
468481
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+
469506 const createBuildConfig = Effect.fn("createBuildConfig")(function* (
470507 platform: typeof BuildPlatform.Type,
471508 target: string,
472509 productName: string,
510+ version: string,
473511 signed: boolean,
474512 mockUpdates: boolean,
475− mockUpdateServerPort: string | undefined,
513+ mockUpdateServerPort: number | undefined,
476514 ) {
477515 const buildConfig: Record<string, unknown> = {
478516 appId: "com.t3tools.t3code",
@@ -482,14 +520,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 3 unmodified lines
482520 buildResources: "apps/desktop/resources",
483521 },
484522 };
485− const publishConfig = resolveGitHubPublishConfig();
523+ const updateChannel = resolveDesktopUpdateChannel(version);
524+ const publishConfig = resolveGitHubPublishConfig(updateChannel);
486525 if (publishConfig) {
487526 buildConfig.publish = [publishConfig];
488527 } else if (mockUpdates) {
489528 buildConfig.publish = [
490529 {
491530 provider: "generic",
492− url: `http://localhost:${mockUpdateServerPort ?? 3000}`,
531+ url: resolveMockUpdateServerUrl(mockUpdateServerPort),
493532 },
494533 ];
495534 }
@@ -533,20 +572,21 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( · 37 unmodified lines
533572 const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* (
534573 platform: typeof BuildPlatform.Type,
535574 stageResourcesDir: string,
575+ iconAssets: DesktopBuildIconAssets,
536576 verbose: boolean,
537577 ) {
538578 if (platform === "mac") {
539− yield* stageMacIcons(stageResourcesDir, verbose);
579+ yield* stageMacIcons(stageResourcesDir, iconAssets.macIconPng, verbose);
540580 return;
541581 }
542582
543583 if (platform === "linux") {
544− yield* stageLinuxIcons(stageResourcesDir);
584+ yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng);
545585 return;
546586 }
547587
548588 if (platform === "win") {
549− yield* stageWindowsIcons(stageResourcesDir);
589+ yield* stageWindowsIcons(stageResourcesDir, iconAssets.windowsIconIco);
550590 }
551591 });
552592
@@ -614,6 +654,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 61 unmodified lines
614654 });
615655
616656 const appVersion = options.version ?? serverPackageJson.version;
657+ const iconAssets = resolveDesktopBuildIconAssets(appVersion);
617658 const commitHash = resolveGitCommitHash(repoRoot);
618659 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
619660 const stageRoot = yield* mkdir({
@@ -665,7 +706,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 45 unmodified lines
665706 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
666707 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
667708
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+ );
669719
670720 // electron-builder is filtering out stageResourcesDir directory in the AppImage for production
671721 yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));
@@ -683,6 +733,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( · 11 unmodified lines
683733 options.platform,
684734 options.target,
685735 desktopPackageJson.productName ?? "T3 Code",
736+ appVersion,
686737 options.signed,
687738 options.mockUpdates,
688739 options.mockUpdateServerPort,
@@ -829,7 +880,8 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { · 140 unmodified lines
829880 Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."),
830881 Flag.optional,
831882 ),
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 }))),
833885 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
834886 Flag.optional,
835887 ),
scripts/lib/brand-assets.ts+6−0
@@ -6,6 +6,12 @@ export const BRAND_ASSET_PATHS = { · 5 unmodified lines
66 productionWebFavicon16Png: "assets/prod/t3-black-web-favicon-16x16.png",
77 productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png",
88 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",
915 developmentWindowsIconIco: "assets/dev/blueprint-windows.ico",
1016 developmentWebFaviconIco: "assets/dev/blueprint-web-favicon.ico",
1117 developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png",
scripts/release-smoke.ts+35−1
@@ -95,7 +95,7 @@ try { · 94 unmodified lines
9595 },
9696 );
9797
98− execFileSync("bun", ["install", "--lockfile-only", "--ignore-scripts"], {
98+ execFileSync("bun", ["install", "--ignore-scripts"], {
9999 cwd: tempRoot,
100100 stdio: "inherit",
101101 });
@@ -107,6 +107,40 @@ try { · 5 unmodified lines
107107 "Expected bun.lock to contain the smoke version.",
108108 );
109109
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+
110144 const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot);
111145 execFileSync(
112146 process.execPath,
scripts/resolve-nightly-release.test.ts+25−0
@@ -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+126−0
@@ -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+203−0
@@ -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+);

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