t3-code-android-nightly/scripts/resolve-previous-release-tag.ts
Julius Marminge 194c73f3f9
chore(deps): upgrade Effect to stable 4.0.1 (#16138)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:15:14 -07:00

373 lines
12 KiB
JavaScript

#!/usr/bin/env node
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as String from "effect/String";
import { Command, Flag } from "effect/cli";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
const ReleaseChannel = Schema.Literals(["stable", "nightly", "preview"]);
type ReleaseChannel = typeof ReleaseChannel.Type;
export class InvalidReleaseTagError extends Schema.TaggedError<InvalidReleaseTagError>()(
"InvalidReleaseTagError",
{
channel: ReleaseChannel,
currentTag: Schema.String,
},
) {
override get message(): string {
return `Invalid ${this.channel} release tag '${this.currentTag}'.`;
}
}
const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
const releaseTagListProcessContext = {
executable: Schema.Literal("git"),
argumentCount: NonNegativeInt,
cwd: Schema.String,
};
export class ReleaseTagListProcessError extends Schema.TaggedError<ReleaseTagListProcessError>()(
"ReleaseTagListProcessError",
{
...releaseTagListProcessContext,
operation: Schema.Literals(["spawn", "read-stdout", "read-stderr", "wait-for-exit"]),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to list release tags during process operation "${this.operation}".`;
}
}
export class ReleaseTagListProcessExitError extends Schema.TaggedError<ReleaseTagListProcessExitError>()(
"ReleaseTagListProcessExitError",
{
...releaseTagListProcessContext,
exitCode: Schema.Number,
stdoutLength: NonNegativeInt,
stderrLength: NonNegativeInt,
},
) {
override get message(): string {
return `Release tag listing exited with code ${this.exitCode}.`;
}
}
export class PreviousReleaseTagGitHubOutputConfigError extends Schema.TaggedError<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.TaggedError<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;
readonly patch: number;
readonly prerelease: ReadonlyArray<string>;
}
interface NightlyVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
readonly date: number;
readonly runNumber: number;
}
const parseNumericIdentifier = (identifier: string): number | undefined =>
/^\d+$/.test(identifier) ? Number(identifier) : undefined;
const comparePrereleaseIdentifiers = (left: string, right: string): number => {
const leftNumeric = parseNumericIdentifier(left);
const rightNumeric = parseNumericIdentifier(right);
if (leftNumeric !== undefined && rightNumeric !== undefined) {
return leftNumeric - rightNumeric;
}
if (leftNumeric !== undefined) {
return -1;
}
if (rightNumeric !== undefined) {
return 1;
}
return left.localeCompare(right);
};
const compareStableVersions = (left: StableVersion, right: StableVersion): number => {
if (left.major !== right.major) return left.major - right.major;
if (left.minor !== right.minor) return left.minor - right.minor;
if (left.patch !== right.patch) return left.patch - right.patch;
const leftHasPrerelease = left.prerelease.length > 0;
const rightHasPrerelease = right.prerelease.length > 0;
if (!leftHasPrerelease && !rightHasPrerelease) return 0;
if (!leftHasPrerelease) return 1;
if (!rightHasPrerelease) return -1;
const maxLength = Math.max(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < maxLength; index += 1) {
const leftIdentifier = left.prerelease[index];
const rightIdentifier = right.prerelease[index];
if (leftIdentifier === undefined) return -1;
if (rightIdentifier === undefined) return 1;
const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier);
if (comparison !== 0) return comparison;
}
return 0;
};
const parseStableTag = (tag: string): StableVersion | undefined => {
const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(tag);
if (!match) return undefined;
const [, major, minor, patch, prerelease] = match;
if (!major || !minor || !patch) return undefined;
const prereleaseIdentifiers = prerelease ? prerelease.split(".") : [];
// Nightly and preview tags also start with `v` and carry their channel as
// the prerelease identifier. They must not be considered stable candidates
// when resolving the previous stable tag.
if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "preview") {
return undefined;
}
return {
major: Number(major),
minor: Number(minor),
patch: Number(patch),
prerelease: prereleaseIdentifiers,
};
};
const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => {
if (left.major !== right.major) return left.major - right.major;
if (left.minor !== right.minor) return left.minor - right.minor;
if (left.patch !== right.patch) return left.patch - right.patch;
if (left.date !== right.date) return left.date - right.date;
return left.runNumber - right.runNumber;
};
const parseNightlyTag = (
tag: string,
channel: "nightly" | "preview" = "nightly",
): NightlyVersion | undefined => {
// Accept both the current `v<semver>` format and the legacy `nightly-v<semver>`
// format so release note diffs keep working across the tag-format transition.
const match = new RegExp(
`^(?:nightly-)?v(\\d+)\\.(\\d+)\\.(\\d+)-${channel}\\.(\\d{8})\\.(\\d+)$`,
).exec(tag);
if (!match) return undefined;
const [, major, minor, patch, date, runNumber] = match;
if (!major || !minor || !patch || !date || !runNumber) return undefined;
return {
major: Number(major),
minor: Number(minor),
patch: Number(patch),
date: Number(date),
runNumber: Number(runNumber),
};
};
export const resolvePreviousReleaseTag = (
channel: ReleaseChannel,
currentTag: string,
tags: ReadonlyArray<string>,
) =>
Effect.gen(function* () {
if (channel === "stable") {
const current = parseStableTag(currentTag);
if (!current) {
return yield* new InvalidReleaseTagError({ channel, currentTag });
}
const candidates = tags
.map((tag) => ({ tag, parsed: parseStableTag(tag) }))
.filter(
(entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined,
)
.filter((entry) => compareStableVersions(entry.parsed, current) < 0)
.toSorted((left, right) => compareStableVersions(right.parsed, left.parsed));
return candidates[0]?.tag;
}
const current = parseNightlyTag(currentTag, channel);
if (!current) {
return yield* new InvalidReleaseTagError({ channel, currentTag });
}
const candidates = tags
.map((tag) => ({ tag, parsed: parseNightlyTag(tag, channel) }))
.filter(
(entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined,
)
.filter((entry) => compareNightlyVersions(entry.parsed, current) < 0)
.toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed));
return candidates[0]?.tag;
});
const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
stream.pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(acc, chunk) => acc + chunk,
),
);
export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd()) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const args = ["tag", "--list"] as const;
const context = {
executable: "git",
argumentCount: args.length,
cwd,
} as const;
const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd })).pipe(
Effect.mapError(
(cause) =>
new ReleaseTagListProcessError({
...context,
operation: "spawn",
cause,
}),
),
);
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStreamAsString(child.stdout).pipe(
Effect.mapError(
(cause) =>
new ReleaseTagListProcessError({
...context,
operation: "read-stdout",
cause,
}),
),
),
collectStreamAsString(child.stderr).pipe(
Effect.mapError(
(cause) =>
new ReleaseTagListProcessError({
...context,
operation: "read-stderr",
cause,
}),
),
),
child.exitCode.pipe(
Effect.map(Number),
Effect.mapError(
(cause) =>
new ReleaseTagListProcessError({
...context,
operation: "wait-for-exit",
cause,
}),
),
),
],
{ concurrency: "unbounded" },
);
if (exitCode !== 0) {
return yield* new ReleaseTagListProcessExitError({
...context,
exitCode,
stdoutLength: stdout.length,
stderrLength: stderr.length,
});
}
return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty);
});
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").pipe(
Effect.mapError(
(cause) =>
new PreviousReleaseTagGitHubOutputConfigError({
cause,
}),
),
);
yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe(
Effect.mapError(
(cause) =>
new PreviousReleaseTagGitHubOutputAppendError({
outputPath: githubOutputPath,
cause,
}),
),
);
return;
}
process.stdout.write(entry);
});
const command = Command.make(
"resolve-previous-release-tag",
{
channel: Flag.Literals("channel", ReleaseChannel.literals).pipe(
Flag.withDescription("Release channel whose previous tag should be resolved."),
),
currentTag: Flag.String("current-tag").pipe(
Flag.withDescription("Current release tag to compare against."),
),
githubOutput: Flag.Boolean("github-output").pipe(
Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
Flag.withDefault(false),
),
},
({ channel, currentTag, githubOutput }) =>
listGitTags().pipe(
Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)),
),
).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));
if (import.meta.main) {
Command.run(command, { version: "0.0.0" }).pipe(
Effect.scoped,
Effect.provide(NodeServices.layer),
NodeRuntime.runMain,
);
}