#!/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/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const ReleaseChannel = Schema.Literals(["stable", "nightly"]); type ReleaseChannel = typeof ReleaseChannel.Type; export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( "InvalidReleaseTagError", { channel: ReleaseChannel, currentTag: Schema.String, }, ) { override get message(): string { return `Invalid ${this.channel} release tag '${this.currentTag}'.`; } } const releaseTagListProcessContext = { executable: Schema.Literal("git"), argumentCount: Schema.Number, cwd: Schema.String, }; export class ReleaseTagListProcessError extends Schema.TaggedErrorClass()( "ReleaseTagListProcessError", { ...releaseTagListProcessContext, operation: Schema.Literals(["spawn", "communicate", "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.TaggedErrorClass()( "ReleaseTagListProcessExitError", { ...releaseTagListProcessContext, exitCode: Schema.Number, stdoutLength: Schema.Number, stderrLength: Schema.Number, }, ) { override get message(): string { return `Release tag listing exited with code ${this.exitCode}.`; } } interface StableVersion { readonly major: number; readonly minor: number; readonly patch: number; readonly prerelease: ReadonlyArray; } 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` format and the legacy `nightly-v` // 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), }; }; export const resolvePreviousReleaseTag = ( channel: ReleaseChannel, currentTag: string, tags: ReadonlyArray, ) => 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); if (!current) { return yield* new InvalidReleaseTagError({ channel, 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 collectStreamAsString = (stream: Stream.Stream): Effect.Effect => 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: "communicate", cause, }), ), ), collectStreamAsString(child.stderr).pipe( Effect.mapError( (cause) => new ReleaseTagListProcessError({ ...context, operation: "communicate", 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); }); 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.flatMap((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, ); }