#!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import * as Array from "effect/Array";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/unstable/cli";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const ReleaseChannel = Schema.Literals(["stable", "nightly"]);type ReleaseChannel = typeof ReleaseChannel.Type; 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 tags also start with `v` and carry a `nightly.*` prerelease // identifier. They must not be considered stable candidates when resolving // the previous stable tag. if (prereleaseIdentifiers[0] === "nightly") 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): 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 = /^(?:nightly-)?v(\d+)\.(\d+)\.(\d+)-nightly\.(\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), };}; const resolvePreviousReleaseTag = ( channel: ReleaseChannel, currentTag: string, tags: ReadonlyArray<string>,): string | undefined => { if (channel === "stable") { const current = parseStableTag(currentTag); if (!current) { throw new Error(`Invalid stable release tag '${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); if (!current) { throw new Error(`Invalid nightly release tag '${currentTag}'.`); } const candidates = tags .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) .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 listGitTags = Effect.fn("listGitTags")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"])); const tags = yield* child.stdout.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), Effect.map(String.split(/\r?\n/)), Effect.map(Array.map(String.trim)), Effect.map(Array.filter(String.isNonEmpty)), ); return tags;}); const writeOutput = Effect.fn("writeOutput")(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" }); return; } process.stdout.write(entry);}); const command = Command.make( "resolve-previous-release-tag", { channel: Flag.choice("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.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), Effect.flatMap((previousTag) => writeOutput(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, );}
#!/usr/bin/env node
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Array from "effect/Array";
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/unstable/cli";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
const ReleaseChannel = Schema.Literals(["stable", "nightly"]);
type ReleaseChannel = typeof ReleaseChannel.Type;
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 tags also start with `v` and carry a `nightly.*` prerelease
// identifier. They must not be considered stable candidates when resolving
// the previous stable tag.
if (prereleaseIdentifiers[0] === "nightly") 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): 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 = /^(?:nightly-)?v(\d+)\.(\d+)\.(\d+)-nightly\.(\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),
};
};
const resolvePreviousReleaseTag = (
channel: ReleaseChannel,
currentTag: string,
tags: ReadonlyArray<string>,
): string | undefined => {
if (channel === "stable") {
const current = parseStableTag(currentTag);
if (!current) {
throw new Error(`Invalid stable release tag '${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);
if (!current) {
throw new Error(`Invalid nightly release tag '${currentTag}'.`);
}
const candidates = tags
.map((tag) => ({ tag, parsed: parseNightlyTag(tag) }))
.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 listGitTags = Effect.fn("listGitTags")(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"]));
const tags = yield* child.stdout.pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(acc, chunk) => acc + chunk,
),
Effect.map(String.split(/\r?\n/)),
Effect.map(Array.map(String.trim)),
Effect.map(Array.filter(String.isNonEmpty)),
);
return tags;
});
const writeOutput = Effect.fn("writeOutput")(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" });
return;
}
process.stdout.write(entry);
});
const command = Command.make(
"resolve-previous-release-tag",
{
channel: Flag.choice("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.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
Effect.flatMap((previousTag) => writeOutput(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,
);
}