1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Console from "effect/Console";
6import * as Config from "effect/Config";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import { Command, Flag } from "effect/cli";
13
14export interface NightlyReleaseMetadata {
15 readonly baseVersion: string;
16 readonly version: string;
17 readonly tag: string;
18 readonly name: string;
19 readonly shortSha: string;
20}
21
22const DateSchema = Schema.String.check(Schema.isPattern(/^\d{8}$/));
23const RunNumberSchema = Schema.FiniteFromString.check(
24 Schema.isInt(),
25 Schema.isGreaterThanOrEqualTo(1),
26);
27const ShaSchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{7,40}$/i));
28const DesktopPackageJsonSchema = Schema.Struct({
29 version: Schema.NonEmptyString,
30});
31
32export class InvalidDesktopPackageVersionError extends Schema.TaggedError<InvalidDesktopPackageVersionError>()(
33 "InvalidDesktopPackageVersionError",
34 {
35 version: Schema.String,
36 },
37) {
38 override get message(): string {
39 return `Invalid desktop package version '${this.version}'.`;
40 }
41}
42
43export class NightlyReleaseDesktopPackageError extends Schema.TaggedError<NightlyReleaseDesktopPackageError>()(
44 "NightlyReleaseDesktopPackageError",
45 {
46 operation: Schema.Literals(["read", "decode"]),
47 packageJsonPath: Schema.String,
48 cause: Schema.Defect(),
49 },
50) {
51 override get message(): string {
52 return `Failed to ${this.operation} desktop package metadata at ${this.packageJsonPath}.`;
53 }
54}
55
56export class NightlyReleaseGitHubOutputConfigError extends Schema.TaggedError<NightlyReleaseGitHubOutputConfigError>()(
57 "NightlyReleaseGitHubOutputConfigError",
58 {
59 cause: Schema.Defect(),
60 },
61) {
62 override get message(): string {
63 return "Failed to resolve the GITHUB_OUTPUT path for nightly release metadata.";
64 }
65}
66
67export class NightlyReleaseGitHubOutputAppendError extends Schema.TaggedError<NightlyReleaseGitHubOutputAppendError>()(
68 "NightlyReleaseGitHubOutputAppendError",
69 {
70 outputPath: Schema.String,
71 cause: Schema.Defect(),
72 },
73) {
74 override get message(): string {
75 return `Failed to append nightly release metadata to ${this.outputPath}.`;
76 }
77}
78
79const RepoRoot = Effect.service(Path.Path).pipe(
80 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
81);
82const decodeDesktopPackageJson = Schema.decodeUnknownEffect(
83 Schema.fromJsonString(DesktopPackageJsonSchema),
84);
85
86export const resolveNightlyBaseVersion = (version: string) => version.replace(/[-+].*$/, "");
87
88export const resolveNightlyTargetVersion = (version: string) => {
89 const stableCore = resolveNightlyBaseVersion(version);
90 const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore);
91 if (!match) {
92 return Effect.fail(new InvalidDesktopPackageVersionError({ version }));
93 }
94
95 const [, major, minor, patch] = match;
96 return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`);
97};
98
99/** Prerelease trains that share nightly's date-and-run versioning. */
100export const PrereleaseChannel = Schema.Literals(["nightly", "preview"]);
101export type PrereleaseChannel = typeof PrereleaseChannel.Type;
102
103// The preview label is deliberately loud: the releases page is the one place
104// a preview build can be found, and its name is the first thing a visitor
105// reads before the warning in the body.
106const CHANNEL_RELEASE_LABELS: Record<PrereleaseChannel, string> = {
107 nightly: "Nightly",
108 preview: "Preview (maintainer test build, do not install)",
109};
110
111export const resolveNightlyReleaseMetadata = (
112 baseVersion: string,
113 date: string,
114 runNumber: number,
115 sha: string,
116 channel: PrereleaseChannel = "nightly",
117) => {
118 const shortSha = sha.slice(0, 12);
119 const version = `${baseVersion}-${channel}.${date}.${runNumber}`;
120 return {
121 baseVersion,
122 version,
123 tag: `v${version}`,
124 name: `T3 Code ${CHANNEL_RELEASE_LABELS[channel]} ${version} (${shortSha})`,
125 shortSha,
126 };
127};
128
129export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (
130 rootDir: string | undefined,
131) {
132 const fs = yield* FileSystem.FileSystem;
133 const path = yield* Path.Path;
134 const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot;
135 const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json");
136 const packageJsonSource = yield* fs.readFileString(packageJsonPath).pipe(
137 Effect.mapError(
138 (cause) =>
139 new NightlyReleaseDesktopPackageError({
140 operation: "read",
141 packageJsonPath,
142 cause,
143 }),
144 ),
145 );
146 const packageJson = yield* decodeDesktopPackageJson(packageJsonSource).pipe(
147 Effect.mapError(
148 (cause) =>
149 new NightlyReleaseDesktopPackageError({
150 operation: "decode",
151 packageJsonPath,
152 cause,
153 }),
154 ),
155 );
156 return yield* resolveNightlyTargetVersion(packageJson.version);
157});
158
159export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* (
160 metadata: NightlyReleaseMetadata,
161 writeGithubOutput: boolean,
162) {
163 const fs = yield* FileSystem.FileSystem;
164
165 const entries = [
166 ["base_version", metadata.baseVersion],
167 ["version", metadata.version],
168 ["tag", metadata.tag],
169 ["name", metadata.name],
170 ["short_sha", metadata.shortSha],
171 ] as const;
172
173 if (writeGithubOutput) {
174 const githubOutputPath = yield* Config.NonEmptyString("GITHUB_OUTPUT").pipe(
175 Effect.mapError(
176 (cause) =>
177 new NightlyReleaseGitHubOutputConfigError({
178 cause,
179 }),
180 ),
181 );
182 const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join("");
183 yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }).pipe(
184 Effect.mapError(
185 (cause) =>
186 new NightlyReleaseGitHubOutputAppendError({
187 outputPath: githubOutputPath,
188 cause,
189 }),
190 ),
191 );
192 } else {
193 for (const [key, value] of entries) {
194 yield* Console.log(`${key}=${value}`);
195 }
196 }
197});
198
199const command = Command.make(
200 "resolve-nightly-release",
201 {
202 date: Flag.String("date").pipe(
203 Flag.withSchema(DateSchema),
204 Flag.withDescription("Nightly build date in YYYYMMDD."),
205 ),
206 runNumber: Flag.String("run-number").pipe(
207 Flag.withSchema(RunNumberSchema),
208 Flag.withDescription("GitHub Actions run number."),
209 ),
210 sha: Flag.String("sha").pipe(
211 Flag.withSchema(ShaSchema),
212 Flag.withDescription("Commit sha for the nightly build."),
213 ),
214 channel: Flag.Literals("channel", PrereleaseChannel.literals).pipe(
215 Flag.withDescription("Prerelease channel whose identifier the version carries."),
216 Flag.withDefault("nightly" as const),
217 ),
218 githubOutput: Flag.Boolean("github-output").pipe(
219 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
220 Flag.withDefault(false),
221 ),
222 root: Flag.String("root").pipe(
223 Flag.withDescription("Workspace root used to resolve apps/desktop/package.json."),
224 Flag.optional,
225 ),
226 },
227 ({ date, runNumber, sha, channel, githubOutput, root }) =>
228 readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(
229 Effect.map((baseVersion) =>
230 resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha, channel),
231 ),
232 Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)),
233 ),
234).pipe(Command.withDescription("Resolve nightly release version metadata."));
235
236if (import.meta.main) {
237 Command.run(command, { version: "0.0.0" }).pipe(
238 Effect.provide(NodeServices.layer),
239 NodeRuntime.runMain,
240 );
241}