authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:31:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:31:09-07:00
log7eb7b4f4a36aea3b858494e5983ee9bc9a288933
tree7fdb256de8a6cabf020f9227874f52cbccec38fb
parente01b1903de403c720792a896d8c50ebcd8163b44
signature Signed by PGP key B5690EEEBB952194

[codex] Structure release metadata failures (#3296)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 111 insertions(+), 33 deletions(-)

scripts/resolve-nightly-release.test.ts+18-5
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
23
3import {4import {
4 resolveNightlyBaseVersion,5 resolveNightlyBaseVersion,
...@@ -12,11 +13,23 @@ it("strips prerelease and build metadata when deriving the nightly base version"...@@ -12,11 +13,23 @@ it("strips prerelease and build metadata when deriving the nightly base version"
12 assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3");13 assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3");
13});14});
1415
15it("bumps the patch version before deriving nightly prerelease versions", () => {16it.effect("bumps the patch version before deriving nightly prerelease versions", () =>
16 assert.equal(resolveNightlyTargetVersion("0.0.17"), "0.0.18");17 Effect.gen(function* () {
17 assert.equal(resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10");18 assert.equal(yield* resolveNightlyTargetVersion("0.0.17"), "0.0.18");
18 assert.equal(resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4");19 assert.equal(yield* resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10");
19});20 assert.equal(yield* resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4");
21 }),
22);
23
24it.effect("reports the invalid desktop package version", () =>
25 Effect.gen(function* () {
26 const error = yield* resolveNightlyTargetVersion("nightly").pipe(Effect.flip);
27
28 assert.equal(error._tag, "InvalidDesktopPackageVersionError");
29 assert.equal(error.version, "nightly");
30 assert.equal(error.message, "Invalid desktop package version 'nightly'.");
31 }),
32);
2033
21it("derives nightly metadata including the short commit sha in the release name", () => {34it("derives nightly metadata including the short commit sha in the release name", () => {
22 assert.deepStrictEqual(35 assert.deepStrictEqual(
scripts/resolve-nightly-release.ts+14-3
...@@ -29,6 +29,17 @@ const DesktopPackageJsonSchema = Schema.Struct({...@@ -29,6 +29,17 @@ const DesktopPackageJsonSchema = Schema.Struct({
29 version: Schema.NonEmptyString,29 version: Schema.NonEmptyString,
30});30});
3131
32export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass<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
32const RepoRoot = Effect.service(Path.Path).pipe(43const RepoRoot = Effect.service(Path.Path).pipe(
33 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),44 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
34);45);
...@@ -42,11 +53,11 @@ export const resolveNightlyTargetVersion = (version: string) => {...@@ -42,11 +53,11 @@ export const resolveNightlyTargetVersion = (version: string) => {
42 const stableCore = resolveNightlyBaseVersion(version);53 const stableCore = resolveNightlyBaseVersion(version);
43 const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore);54 const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore);
44 if (!match) {55 if (!match) {
45 throw new Error(`Invalid desktop package version '${version}'.`);56 return Effect.fail(new InvalidDesktopPackageVersionError({ version }));
46 }57 }
4758
48 const [, major, minor, patch] = match;59 const [, major, minor, patch] = match;
49 return `${major}.${minor}.${Number(patch) + 1}`;60 return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`);
50};61};
5162
52export const resolveNightlyReleaseMetadata = (63export const resolveNightlyReleaseMetadata = (
...@@ -76,7 +87,7 @@ const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (...@@ -76,7 +87,7 @@ const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (
76 const packageJson = yield* fs87 const packageJson = yield* fs
77 .readFileString(packageJsonPath)88 .readFileString(packageJsonPath)
78 .pipe(Effect.flatMap(decodeDesktopPackageJson));89 .pipe(Effect.flatMap(decodeDesktopPackageJson));
79 return resolveNightlyTargetVersion(packageJson.version);90 return yield* resolveNightlyTargetVersion(packageJson.version);
80});91});
8192
82const writeOutput = Effect.fn("writeOutput")(function* (93const writeOutput = Effect.fn("writeOutput")(function* (
scripts/resolve-previous-release-tag.test.ts created+39
...@@ -0,0 +1,39 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3
4import { resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts";
5
6it.effect("selects the latest earlier stable tag and ignores nightlies", () =>
7 Effect.gen(function* () {
8 const previous = yield* resolvePreviousReleaseTag("stable", "v1.2.0", [
9 "v1.1.0",
10 "v1.1.1-nightly.20260619.1",
11 "v1.1.2",
12 "v1.2.0",
13 ]);
14
15 assert.equal(previous, "v1.1.2");
16 }),
17);
18
19it.effect("accepts legacy nightly tags when selecting the previous nightly", () =>
20 Effect.gen(function* () {
21 const previous = yield* resolvePreviousReleaseTag("nightly", "v1.2.0-nightly.20260620.2", [
22 "nightly-v1.2.0-nightly.20260620.1",
23 "v1.1.0-nightly.20260619.9",
24 ]);
25
26 assert.equal(previous, "nightly-v1.2.0-nightly.20260620.1");
27 }),
28);
29
30it.effect("reports the invalid tag with its release channel", () =>
31 Effect.gen(function* () {
32 const error = yield* resolvePreviousReleaseTag("nightly", "v1.2.0", []).pipe(Effect.flip);
33
34 assert.equal(error._tag, "InvalidReleaseTagError");
35 assert.equal(error.channel, "nightly");
36 assert.equal(error.currentTag, "v1.2.0");
37 assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'.");
38 }),
39);
scripts/resolve-previous-release-tag.ts+40-25
...@@ -15,6 +15,18 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -15,6 +15,18 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
15const ReleaseChannel = Schema.Literals(["stable", "nightly"]);15const ReleaseChannel = Schema.Literals(["stable", "nightly"]);
16type ReleaseChannel = typeof ReleaseChannel.Type;16type ReleaseChannel = typeof ReleaseChannel.Type;
1717
18export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidReleaseTagError>()(
19 "InvalidReleaseTagError",
20 {
21 channel: ReleaseChannel,
22 currentTag: Schema.String,
23 },
24) {
25 override get message(): string {
26 return `Invalid ${this.channel} release tag '${this.currentTag}'.`;
27 }
28}
29
18interface StableVersion {30interface StableVersion {
19 readonly major: number;31 readonly major: number;
20 readonly minor: number;32 readonly minor: number;
...@@ -121,41 +133,44 @@ const parseNightlyTag = (tag: string): NightlyVersion | undefined => {...@@ -121,41 +133,44 @@ const parseNightlyTag = (tag: string): NightlyVersion | undefined => {
121 };133 };
122};134};
123135
124const resolvePreviousReleaseTag = (136export const resolvePreviousReleaseTag = (
125 channel: ReleaseChannel,137 channel: ReleaseChannel,
126 currentTag: string,138 currentTag: string,
127 tags: ReadonlyArray<string>,139 tags: ReadonlyArray<string>,
128): string | undefined => {140) =>
129 if (channel === "stable") {141 Effect.gen(function* () {
130 const current = parseStableTag(currentTag);142 if (channel === "stable") {
143 const current = parseStableTag(currentTag);
144 if (!current) {
145 return yield* new InvalidReleaseTagError({ channel, currentTag });
146 }
147
148 const candidates = tags
149 .map((tag) => ({ tag, parsed: parseStableTag(tag) }))
150 .filter(
151 (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined,
152 )
153 .filter((entry) => compareStableVersions(entry.parsed, current) < 0)
154 .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed));
155
156 return candidates[0]?.tag;
157 }
158
159 const current = parseNightlyTag(currentTag);
131 if (!current) {160 if (!current) {
132 throw new Error(`Invalid stable release tag '${currentTag}'.`);161 return yield* new InvalidReleaseTagError({ channel, currentTag });
133 }162 }
134163
135 const candidates = tags164 const candidates = tags
136 .map((tag) => ({ tag, parsed: parseStableTag(tag) }))165 .map((tag) => ({ tag, parsed: parseNightlyTag(tag) }))
137 .filter(166 .filter(
138 (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined,167 (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined,
139 )168 )
140 .filter((entry) => compareStableVersions(entry.parsed, current) < 0)169 .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0)
141 .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed));170 .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed));
142171
143 return candidates[0]?.tag;172 return candidates[0]?.tag;
144 }173 });
145
146 const current = parseNightlyTag(currentTag);
147 if (!current) {
148 throw new Error(`Invalid nightly release tag '${currentTag}'.`);
149 }
150
151 const candidates = tags
152 .map((tag) => ({ tag, parsed: parseNightlyTag(tag) }))
153 .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined)
154 .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0)
155 .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed));
156
157 return candidates[0]?.tag;
158};
159174
160const listGitTags = Effect.fn("listGitTags")(function* () {175const listGitTags = Effect.fn("listGitTags")(function* () {
161 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;176 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
...@@ -205,7 +220,7 @@ const command = Command.make(...@@ -205,7 +220,7 @@ const command = Command.make(
205 },220 },
206 ({ channel, currentTag, githubOutput }) =>221 ({ channel, currentTag, githubOutput }) =>
207 listGitTags().pipe(222 listGitTags().pipe(
208 Effect.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),223 Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
209 Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)),224 Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)),
210 ),225 ),
211).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));226).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));