diff --git a/tools/release/commands/commands.ts b/tools/release/commands/commands.ts index fd3b859b71..7b59867562 100644 --- a/tools/release/commands/commands.ts +++ b/tools/release/commands/commands.ts @@ -1,67 +1,201 @@ -import { Effect, Stream, Console } from 'effect'; +import { Effect } from 'effect'; import { Command } from '@effect/platform'; +import { FileSystem, Path } from '@effect/platform'; +import { + CommandExitError, + GitStatusError, + ChangesetError, + ChangesetConfigError, + GitRestoreError, +} from '../errors'; -export const buildPackages = Command.make('pnpm', 'build').pipe( - Command.string, - Stream.tap((line) => Console.log(`Build: ${line}`)), - Stream.runDrain, -); - -// Effect to check git status for staged files -export const checkGitStatus = Command.make('git', 'status', '--porcelain').pipe( - Command.string, - Effect.flatMap((output) => { - // Check if the output contains lines indicating staged changes (e.g., starting with M, A, D, R, C, U followed by a space) - const stagedChanges = output.split('\n').some((line) => /^[MADRCU] /.test(line.trim())); - if (stagedChanges) { - return Effect.fail( - 'Git repository has staged changes. Please commit or stash them before releasing.', - ); - } - return Effect.void; // No staged changes - }), - // If the command fails (e.g., not a git repo), treat it as an error too. - Effect.catchAll((error) => Effect.fail(`Git status check command failed: ${error}`)), - Effect.tapError((error) => Console.error(error)), // Log the specific error message - Effect.asVoid, // Don't need the output on success -); - -// Effect to run changesets snapshot -export const runChangesetsSnapshot = Command.make( - 'pnpm', - 'changeset', - 'version', - '--snapshot', - 'beta', -).pipe(Command.exitCode); - -// Effect to start local registry (run in background) -export const startLocalRegistry = Command.make('pnpm', 'nx', 'local-registry').pipe( - Command.start, // Starts the process and returns immediately - Effect.tap(() => - Console.log('Attempting to start local registry (Verdaccio) in the background...'), - ), - Effect.tapError((error) => Console.error(`Failed to start local registry: ${error}`)), - Effect.asVoid, // We don't need the Process handle for this script's logic -); - -export const restoreGitFiles = Command.make('git', 'restore', '.').pipe(Command.start); - -export const publishPackages = Command.make( - 'pnpm', - 'publish', - '-r', - '--tag', - 'beta', - '--registry=http://localhost:4873', - '--no-git-checks', -).pipe( - Command.string, - Stream.tap((line) => Console.log(`Publish: ${line}`)), - Stream.runDrain, - Effect.tapBoth({ - onFailure: (error) => Effect.fail(() => Console.error(`Publishing failed: ${error}`)), - onSuccess: () => Console.log('Packages were published successfully to the local registry.'), - }), - Effect.asVoid, -); +const SNAPSHOT_TAG = 'beta'; +const LOCAL_REGISTRY_URL = 'http://localhost:4873'; + +/** + * Runs a command with fully inherited stdio and `CI=true` so that tools + * like Nx use non-interactive output. Fails with `CommandExitError` if + * the exit code is non-zero. + * + * @param description - Human-readable label for the command (e.g. "pnpm build") + * @param cmd - The Effect Command to execute + */ +const runInherited = (description: string, cmd: Command.Command) => + cmd.pipe( + Command.env({ CI: 'true' }), + Command.stdin('inherit'), + Command.stdout('inherit'), + Command.stderr('inherit'), + Command.exitCode, + Effect.flatMap((code) => + code === 0 + ? Effect.void + : Effect.fail( + new CommandExitError({ + message: `${description} exited with code ${code}`, + cause: `Non-zero exit code: ${code}`, + command: description, + exitCode: code, + }), + ), + ), + ); + +/** Fails with `GitStatusError` if the git working tree has staged changes. */ +export const assertCleanGitStatus = Effect.gen(function* () { + const output = yield* Command.make('git', 'status', '--porcelain').pipe(Command.string); + + const hasStagedChanges = output.split('\n').some((line) => /^[MADRCU] /.test(line)); + + if (hasStagedChanges) { + yield* Effect.fail( + new GitStatusError({ + message: 'Git has staged changes. Commit or stash them before releasing.', + cause: 'Staged changes detected in git working tree', + }), + ); + } + + yield* Effect.log('Git status clean — no staged changes.'); +}); + +/** Fails with `ChangesetError` if the `.changeset/` directory contains no changeset markdown files. */ +export const assertChangesetsExist = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const changesetDir = path.join(process.cwd(), '.changeset'); + + const files = yield* fs.readDirectory(changesetDir).pipe( + Effect.catchTag('SystemError', (e) => + Effect.fail( + new ChangesetError({ + message: + e.reason === 'NotFound' + ? 'No .changeset directory found.' + : `Failed to read .changeset directory: ${e.message}`, + cause: e.reason, + }), + ), + ), + ); + + const hasChangesets = files + .filter((f) => f !== 'README.md' && f !== 'config.json') + .some((f) => f.endsWith('.md')); + + if (!hasChangesets) { + yield* Effect.fail( + new ChangesetError({ + message: 'No changeset files found. Add a changeset before releasing.', + cause: 'No markdown files in .changeset directory', + }), + ); + } + + yield* Effect.log('Changeset files found.'); +}); + +/** + * Versions all packages as snapshot releases via `changeset version --snapshot`. + * Temporarily disables the GitHub changelog in `.changeset/config.json` to avoid + * requiring a GITHUB_TOKEN for local releases. The modified config is reverted + * by `restoreGitFiles`. + */ +export const versionSnapshotPackages = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const configPath = path.join(process.cwd(), '.changeset', 'config.json'); + + const raw = yield* fs.readFileString(configPath).pipe( + Effect.catchTag('SystemError', (e) => + Effect.fail( + new ChangesetConfigError({ + message: `Failed to read .changeset/config.json: ${e.message}`, + cause: e.reason, + }), + ), + ), + ); + + const config: Record = yield* Effect.try({ + try: () => JSON.parse(raw) as Record, + catch: (e) => + new ChangesetConfigError({ + message: 'Invalid JSON in .changeset/config.json', + cause: String(e), + }), + }); + + if (typeof config !== 'object' || config === null || Array.isArray(config)) { + yield* Effect.fail( + new ChangesetConfigError({ + message: '.changeset/config.json must be a JSON object', + cause: `Unexpected shape: ${typeof config}`, + }), + ); + } + + config.changelog = false; + yield* fs.writeFileString(configPath, JSON.stringify(config, null, 2) + '\n').pipe( + Effect.catchTag('SystemError', (e) => + Effect.fail( + new ChangesetConfigError({ + message: `Failed to write .changeset/config.json: ${e.message}`, + cause: e.reason, + }), + ), + ), + ); + + yield* Effect.log('Running changeset version --snapshot...'); + yield* runInherited( + 'changeset version --snapshot', + Command.make('pnpm', 'changeset', 'version', '--snapshot', SNAPSHOT_TAG), + ); + yield* Effect.log('Snapshot versioning complete.'); +}); + +/** Runs `pnpm build` with output visible in the terminal. */ +export const buildPackages = Effect.gen(function* () { + yield* runInherited('pnpm build', Command.make('pnpm', 'build')); + yield* Effect.log('Build complete.'); +}); + +/** Starts the Verdaccio local registry as a background process. */ +export const startLocalRegistry = Effect.gen(function* () { + yield* Command.make('pnpm', 'nx', 'local-registry').pipe(Command.start, Effect.asVoid); + yield* Effect.log('Verdaccio local registry starting...'); +}); + +/** Publishes all packages to the local Verdaccio registry. */ +export const publishToLocalRegistry = Effect.gen(function* () { + yield* runInherited( + 'pnpm publish', + Command.make( + 'pnpm', + 'publish', + '-r', + '--tag', + SNAPSHOT_TAG, + `--registry=${LOCAL_REGISTRY_URL}`, + '--no-git-checks', + ), + ); + yield* Effect.log('Packages published to local registry.'); +}); + +/** Restores all modified files in the working tree via `git restore .`. */ +export const restoreGitFiles = Effect.gen(function* () { + const code = yield* Command.make('git', 'restore', '.').pipe(Command.exitCode); + + if (code !== 0) { + yield* Effect.fail( + new GitRestoreError({ + message: `git restore exited with code ${code}`, + cause: `Non-zero exit code: ${code}`, + }), + ); + } +}); diff --git a/tools/release/errors.ts b/tools/release/errors.ts new file mode 100644 index 0000000000..6fe7833590 --- /dev/null +++ b/tools/release/errors.ts @@ -0,0 +1,48 @@ +import { Data } from 'effect'; + +/** Staged changes detected in git working tree. */ +export class GitStatusError extends Data.TaggedError('GitStatusError')<{ + message: string; + cause: string; +}> {} + +/** Missing `.changeset/` directory or no changeset markdown files found. */ +export class ChangesetError extends Data.TaggedError('ChangesetError')<{ + message: string; + cause: string; +}> {} + +/** A shell command exited with a non-zero exit code. */ +export class CommandExitError extends Data.TaggedError('CommandExitError')<{ + message: string; + cause: string; + command: string; + exitCode: number; +}> {} + +/** `.changeset/config.json` is unreadable, contains invalid JSON, or has an unexpected shape. */ +export class ChangesetConfigError extends Data.TaggedError('ChangesetConfigError')<{ + message: string; + cause: string; +}> {} + +/** Verdaccio registry did not respond within the retry timeout. */ +export class RegistryNotReadyError extends Data.TaggedError('RegistryNotReadyError')<{ + message: string; + cause: string; +}> {} + +/** `git restore .` failed during cleanup. */ +export class GitRestoreError extends Data.TaggedError('GitRestoreError')<{ + message: string; + cause: string; +}> {} + +/** Union of all typed errors the release pipeline can produce. */ +export type ReleaseError = + | GitStatusError + | ChangesetError + | CommandExitError + | ChangesetConfigError + | RegistryNotReadyError + | GitRestoreError; diff --git a/tools/release/release.ts b/tools/release/release.ts index 702f9c0c5f..b5f0ba2007 100644 --- a/tools/release/release.ts +++ b/tools/release/release.ts @@ -1,88 +1,82 @@ /* eslint-disable import/extensions */ -import { Effect, Console } from 'effect'; +import { Duration, Effect, Schedule } from 'effect'; import { NodeContext, NodeRuntime } from '@effect/platform-node'; -import { FileSystem, Path } from '@effect/platform'; import { - checkGitStatus, + assertCleanGitStatus, + assertChangesetsExist, + versionSnapshotPackages, + buildPackages, startLocalRegistry, - runChangesetsSnapshot, + publishToLocalRegistry, restoreGitFiles, - publishPackages, - buildPackages, } from './commands/commands'; - -const checkForChangesets = Effect.gen(function* () { - yield* Console.log('Checking for changeset files...'); - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const changesetDir = path.join(process.cwd(), '.changeset'); - - const files = yield* fs.readDirectory(changesetDir).pipe( - Effect.catchTag('SystemError', (e) => { - if (e.reason === 'NotFound') { - return Effect.fail('No changesets found. Please add a changeset before releasing.'); - } - // Otherwise, propagate the error - return Effect.fail(`An unexpected error occured ${e}`); +import { RegistryNotReadyError } from './errors'; + +const REGISTRY_URL = 'http://localhost:4873'; + +/** + * Polls the local Verdaccio registry until it responds with a successful status. + * Uses exponential backoff starting at 500ms, capped at 10 retries and 30s elapsed. + * Fails with `RegistryNotReadyError` if the registry never becomes available. + */ +const waitForRegistry = Effect.tryPromise({ + try: async () => { + const response = await fetch(REGISTRY_URL); + if (!response.ok) { + throw new Error(`Registry responded with status ${response.status}`); + } + }, + catch: (error) => + new RegistryNotReadyError({ + message: `Registry at ${REGISTRY_URL} is not ready`, + cause: String(error), }), - ); - - const hasChangesetFiles = files - .filter((file) => file !== 'README.md') - .filter((file) => file !== 'config.json') - .every((file) => file.endsWith('.md')); - - if (!hasChangesetFiles) { - yield* Effect.fail('No changesets found. Please add a changeset before releasing.'); - } - - yield* Console.log('Changeset files found.'); -}).pipe(Effect.tapError((error) => Console.error(`Changeset check failed: ${error}`))); - -const program = Effect.gen(function* () { - yield* Console.log('Starting release script...'); - - yield* Console.log('Checking Git status for staged files...'); - yield* checkGitStatus; - - yield* Console.log('Git status OK (no staged files found).'); - yield* checkForChangesets; - - yield* Console.log('Running Changesets snapshot version...'); - const exitCode = yield* runChangesetsSnapshot; - - if (exitCode.valueOf() == 1) { - return yield* Effect.fail('Failed to version all snapshots'); - } - - yield* Console.log('Building packages'); - yield* buildPackages; - - yield* Console.log('Starting Verdaccio'); - yield* startLocalRegistry; - yield* Console.log('Waiting for local registry to initialize... (10 seconds)'); - yield* Effect.sleep('10 seconds'); - - yield* Console.log('Publishing packages to local registry...'); - yield* publishPackages; +}).pipe( + Effect.retry( + Schedule.exponential('500 millis').pipe( + Schedule.intersect(Schedule.recurs(10)), + Schedule.compose(Schedule.elapsed), + Schedule.whileOutput(Duration.lessThanOrEqualTo(Duration.seconds(30))), + ), + ), + Effect.tap(() => Effect.log(`Registry at ${REGISTRY_URL} is ready.`)), +); - yield* Console.log( - 'Release script finished. Local registry should still be running in the background.', - ); +const pipeline = assertCleanGitStatus.pipe( + Effect.andThen(assertChangesetsExist), + // Finalizer placed after pre-flight checks so cleanup only runs + // when we've actually modified the working tree + Effect.andThen( + Effect.addFinalizer(() => + restoreGitFiles.pipe( + Effect.tap(() => Effect.log('Restored modified files via git restore.')), + Effect.catchAll((error) => + Effect.logError(`Failed to restore git files: ${error.message}`), + ), + ), + ), + ), + Effect.andThen(versionSnapshotPackages), + Effect.andThen(buildPackages), + Effect.andThen(startLocalRegistry), + Effect.andThen(waitForRegistry), + Effect.andThen(publishToLocalRegistry), + Effect.andThen(Effect.log(`Local release complete. Registry running at ${REGISTRY_URL}`)), + Effect.andThen(Effect.never), + Effect.scoped, +); - yield* Console.log('Registry Url: -> http://localhost:4873'); - yield* restoreGitFiles; - yield* Effect.never; // Keep script running if needed, e.g., for background process -}).pipe( - Effect.catchAll((error) => { - if (typeof error === 'string') { - return Console.error(`Error: ${error}`); - } - return Console.error(`An unexpected error occurred: ${JSON.stringify(error)}`); - }), +const program = pipeline.pipe( Effect.provide(NodeContext.layer), + Effect.tapErrorTag('GitStatusError', (e) => Effect.logError(`Git check failed: ${e.message}`)), + Effect.tapErrorTag('ChangesetError', (e) => + Effect.logError(`Changeset check failed: ${e.message}`), + ), + Effect.tapErrorTag('CommandExitError', (e) => Effect.logError(`Command failed: ${e.message}`)), + Effect.tapErrorTag('ChangesetConfigError', (e) => Effect.logError(`Config error: ${e.message}`)), + Effect.tapErrorTag('RegistryNotReadyError', (e) => + Effect.logError(`Registry unavailable: ${e.message}`), + ), ); -NodeRuntime.runMain(Effect.scoped(program)); +NodeRuntime.runMain(program);