Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions scripts/resolve-nightly-release.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Config from "effect/Config";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
Expand All @@ -10,6 +12,7 @@ import {
resolveNightlyBaseVersion,
resolveNightlyReleaseMetadata,
resolveNightlyTargetVersion,
writeNightlyReleaseOutput,
} from "./resolve-nightly-release.ts";

it("strips prerelease and build metadata when deriving the nightly base version", () => {
Expand Down Expand Up @@ -49,6 +52,29 @@ it("derives nightly metadata including the short commit sha in the release name"
);
});

it.effect("preserves the GITHUB_OUTPUT configuration cause", () => {
const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890");
const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" });

return Effect.gen(function* () {
const configError = yield* writeNightlyReleaseOutput(metadata, true).pipe(
Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
Effect.provideService(
ConfigProvider.ConfigProvider,
ConfigProvider.make(() => Effect.fail(configCause)),
),
Effect.flip,
);

if (configError._tag !== "NightlyReleaseGitHubOutputConfigError") {
return assert.fail(`Unexpected error: ${configError._tag}`);
}
assert.instanceOf(configError.cause, Config.ConfigError);
assert.strictEqual(configError.cause.cause, configCause);
assert.notInclude(configError.message, configCause.message);
});
});

it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => {
it.effect("preserves desktop package read context and its platform cause", () =>
Effect.gen(function* () {
Expand Down
48 changes: 43 additions & 5 deletions scripts/resolve-nightly-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { Command, Flag } from "effect/unstable/cli";

interface NightlyReleaseMetadata {
export interface NightlyReleaseMetadata {
readonly baseVersion: string;
readonly version: string;
readonly tag: string;
Expand Down Expand Up @@ -53,6 +53,29 @@ export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass<N
}
}

export class NightlyReleaseGitHubOutputConfigError extends Schema.TaggedErrorClass<NightlyReleaseGitHubOutputConfigError>()(
"NightlyReleaseGitHubOutputConfigError",
{
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Failed to resolve the GITHUB_OUTPUT path for nightly release metadata.";
}
}

export class NightlyReleaseGitHubOutputAppendError extends Schema.TaggedErrorClass<NightlyReleaseGitHubOutputAppendError>()(
"NightlyReleaseGitHubOutputAppendError",
{
outputPath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to append nightly release metadata to ${this.outputPath}.`;
}
}

const RepoRoot = Effect.service(Path.Path).pipe(
Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
);
Expand Down Expand Up @@ -120,7 +143,7 @@ export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(functi
return yield* resolveNightlyTargetVersion(packageJson.version);
});

const writeOutput = Effect.fn("writeOutput")(function* (
export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* (
metadata: NightlyReleaseMetadata,
writeGithubOutput: boolean,
) {
Expand All @@ -135,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* (
] as const;

if (writeGithubOutput) {
const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");
const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe(
Effect.mapError(
(cause) =>
new NightlyReleaseGitHubOutputConfigError({
cause,
}),
),
);
const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join("");
yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" });
yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }).pipe(
Effect.mapError(
(cause) =>
new NightlyReleaseGitHubOutputAppendError({
outputPath: githubOutputPath,
cause,
}),
),
);
} else {
for (const [key, value] of entries) {
yield* Console.log(`${key}=${value}`);
Expand Down Expand Up @@ -172,7 +210,7 @@ const command = Command.make(
({ date, runNumber, sha, githubOutput, root }) =>
readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(
Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)),
Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)),
Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)),
),
).pipe(Command.withDescription("Resolve nightly release version metadata."));

Expand Down
89 changes: 86 additions & 3 deletions scripts/resolve-previous-release-tag.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { assert, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as PlatformError from "effect/PlatformError";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { ChildProcessSpawner } from "effect/unstable/process";

import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts";
import {
listGitTags,
resolvePreviousReleaseTag,
writePreviousReleaseTagOutput,
} from "./resolve-previous-release-tag.ts";

const encoder = new TextEncoder();

function mockHandle(options: {
readonly exitCode: number;
readonly stdout?: string;
readonly stderr?: string;
readonly stdoutError?: PlatformError.PlatformError;
readonly stderrError?: PlatformError.PlatformError;
}) {
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
Expand All @@ -21,8 +29,12 @@ function mockHandle(options: {
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.make(encoder.encode(options.stdout ?? "")),
stderr: Stream.make(encoder.encode(options.stderr ?? "")),
stdout: options.stdoutError
? Stream.fail(options.stdoutError)
: Stream.make(encoder.encode(options.stdout ?? "")),
stderr: options.stderrError
? Stream.fail(options.stderrError)
: Stream.make(encoder.encode(options.stderr ?? "")),
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
Expand Down Expand Up @@ -95,6 +107,43 @@ it.effect("preserves git tag spawn context and the exact platform cause", () =>
});
});

it.effect("distinguishes stdout and stderr read failures", () =>
Effect.gen(function* () {
for (const [stream, operation] of [
["stdout", "read-stdout"],
["stderr", "read-stderr"],
] as const) {
const cause = PlatformError.systemError({
_tag: "Unknown",
module: "ChildProcess",
method: stream,
description: `${stream} unavailable`,
});
const error = yield* listGitTags("/repo").pipe(
Effect.scoped,
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
mockHandle({
exitCode: 0,
...(stream === "stdout" ? { stdoutError: cause } : { stderrError: cause }),
}),
),
),
),
Effect.flip,
);

if (error._tag !== "ReleaseTagListProcessError") {
return assert.fail(`Unexpected error: ${error._tag}`);
}
assert.equal(error.operation, operation);
assert.strictEqual(error.cause, cause);
}
}),
);

it.effect("reports git tag non-zero exits without manufacturing a cause", () =>
Effect.gen(function* () {
const error = yield* listGitTags("/repo").pipe(
Expand Down Expand Up @@ -128,3 +177,37 @@ it.effect("reports git tag non-zero exits without manufacturing a cause", () =>
assert.notProperty(error, "stderr");
}),
);

it.effect("preserves the GITHUB_OUTPUT append path and exact cause", () => {
const outputPath = "/tmp/previous-tag-github-output";
const appendCause = PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "writeFileString",
pathOrDescriptor: outputPath,
});

return Effect.gen(function* () {
const appendError = yield* writePreviousReleaseTagOutput("v1.2.3", true).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
writeFileString: () => Effect.fail(appendCause),
}),
),
Effect.provideService(
ConfigProvider.ConfigProvider,
ConfigProvider.fromEnv({ env: { GITHUB_OUTPUT: outputPath } }),
),
Effect.flip,
);

if (appendError._tag !== "PreviousReleaseTagGitHubOutputAppendError") {
return assert.fail(`Unexpected error: ${appendError._tag}`);
}
assert.equal(appendError.outputPath, outputPath);
assert.strictEqual(appendError.cause, appendCause);
assert.notProperty(appendError, "contents");
assert.notInclude(appendError.message, appendCause.message);
});
});
60 changes: 50 additions & 10 deletions scripts/resolve-previous-release-tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,19 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidRelea
}
}

const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));

const releaseTagListProcessContext = {
executable: Schema.Literal("git"),
argumentCount: Schema.Number,
argumentCount: NonNegativeInt,
cwd: Schema.String,
};

export class ReleaseTagListProcessError extends Schema.TaggedErrorClass<ReleaseTagListProcessError>()(
"ReleaseTagListProcessError",
{
...releaseTagListProcessContext,
operation: Schema.Literals(["spawn", "communicate", "wait-for-exit"]),
operation: Schema.Literals(["spawn", "read-stdout", "read-stderr", "wait-for-exit"]),
cause: Schema.Defect(),
},
) {
Expand All @@ -50,15 +52,38 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele
{
...releaseTagListProcessContext,
exitCode: Schema.Number,
stdoutLength: Schema.Number,
stderrLength: Schema.Number,
stdoutLength: NonNegativeInt,
stderrLength: NonNegativeInt,
},
) {
override get message(): string {
return `Release tag listing exited with code ${this.exitCode}.`;
}
}

export class PreviousReleaseTagGitHubOutputConfigError extends Schema.TaggedErrorClass<PreviousReleaseTagGitHubOutputConfigError>()(
"PreviousReleaseTagGitHubOutputConfigError",
{
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Failed to resolve the GITHUB_OUTPUT path for the previous release tag.";
}
}

export class PreviousReleaseTagGitHubOutputAppendError extends Schema.TaggedErrorClass<PreviousReleaseTagGitHubOutputAppendError>()(
"PreviousReleaseTagGitHubOutputAppendError",
{
outputPath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to append the previous release tag to ${this.outputPath}.`;
}
}

interface StableVersion {
readonly major: number;
readonly minor: number;
Expand Down Expand Up @@ -238,7 +263,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
(cause) =>
new ReleaseTagListProcessError({
...context,
operation: "communicate",
operation: "read-stdout",
cause,
}),
),
Expand All @@ -248,7 +273,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
(cause) =>
new ReleaseTagListProcessError({
...context,
operation: "communicate",
operation: "read-stderr",
cause,
}),
),
Expand Down Expand Up @@ -280,16 +305,31 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty);
});

const writeOutput = Effect.fn("writeOutput")(function* (
export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* (
previousTag: string | undefined,
writeGithubOutput: boolean,
) {
const entry = `previous_tag=${previousTag ?? ""}\n`;

if (writeGithubOutput) {
const fs = yield* FileSystem.FileSystem;
const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");
yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" });
const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe(
Effect.mapError(
(cause) =>
new PreviousReleaseTagGitHubOutputConfigError({
cause,
}),
),
);
yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe(
Effect.mapError(
(cause) =>
new PreviousReleaseTagGitHubOutputAppendError({
outputPath: githubOutputPath,
cause,
}),
),
);
return;
}

Expand All @@ -313,7 +353,7 @@ const command = Command.make(
({ channel, currentTag, githubOutput }) =>
listGitTags().pipe(
Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)),
Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)),
),
).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));

Expand Down
Loading