Skip to content

Commit a3909a1

Browse files
authored
Merge pull request #538 from ForgeRock/fix-release-script
chore: fix-local-release
2 parents e644572 + 01182c7 commit a3909a1

3 files changed

Lines changed: 317 additions & 141 deletions

File tree

tools/release/commands/commands.ts

Lines changed: 199 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,201 @@
1-
import { Effect, Stream, Console } from 'effect';
1+
import { Effect } from 'effect';
22
import { Command } from '@effect/platform';
3+
import { FileSystem, Path } from '@effect/platform';
4+
import {
5+
CommandExitError,
6+
GitStatusError,
7+
ChangesetError,
8+
ChangesetConfigError,
9+
GitRestoreError,
10+
} from '../errors';
311

4-
export const buildPackages = Command.make('pnpm', 'build').pipe(
5-
Command.string,
6-
Stream.tap((line) => Console.log(`Build: ${line}`)),
7-
Stream.runDrain,
8-
);
9-
10-
// Effect to check git status for staged files
11-
export const checkGitStatus = Command.make('git', 'status', '--porcelain').pipe(
12-
Command.string,
13-
Effect.flatMap((output) => {
14-
// Check if the output contains lines indicating staged changes (e.g., starting with M, A, D, R, C, U followed by a space)
15-
const stagedChanges = output.split('\n').some((line) => /^[MADRCU] /.test(line.trim()));
16-
if (stagedChanges) {
17-
return Effect.fail(
18-
'Git repository has staged changes. Please commit or stash them before releasing.',
19-
);
20-
}
21-
return Effect.void; // No staged changes
22-
}),
23-
// If the command fails (e.g., not a git repo), treat it as an error too.
24-
Effect.catchAll((error) => Effect.fail(`Git status check command failed: ${error}`)),
25-
Effect.tapError((error) => Console.error(error)), // Log the specific error message
26-
Effect.asVoid, // Don't need the output on success
27-
);
28-
29-
// Effect to run changesets snapshot
30-
export const runChangesetsSnapshot = Command.make(
31-
'pnpm',
32-
'changeset',
33-
'version',
34-
'--snapshot',
35-
'beta',
36-
).pipe(Command.exitCode);
37-
38-
// Effect to start local registry (run in background)
39-
export const startLocalRegistry = Command.make('pnpm', 'nx', 'local-registry').pipe(
40-
Command.start, // Starts the process and returns immediately
41-
Effect.tap(() =>
42-
Console.log('Attempting to start local registry (Verdaccio) in the background...'),
43-
),
44-
Effect.tapError((error) => Console.error(`Failed to start local registry: ${error}`)),
45-
Effect.asVoid, // We don't need the Process handle for this script's logic
46-
);
47-
48-
export const restoreGitFiles = Command.make('git', 'restore', '.').pipe(Command.start);
49-
50-
export const publishPackages = Command.make(
51-
'pnpm',
52-
'publish',
53-
'-r',
54-
'--tag',
55-
'beta',
56-
'--registry=http://localhost:4873',
57-
'--no-git-checks',
58-
).pipe(
59-
Command.string,
60-
Stream.tap((line) => Console.log(`Publish: ${line}`)),
61-
Stream.runDrain,
62-
Effect.tapBoth({
63-
onFailure: (error) => Effect.fail(() => Console.error(`Publishing failed: ${error}`)),
64-
onSuccess: () => Console.log('Packages were published successfully to the local registry.'),
65-
}),
66-
Effect.asVoid,
67-
);
12+
const SNAPSHOT_TAG = 'beta';
13+
const LOCAL_REGISTRY_URL = 'http://localhost:4873';
14+
15+
/**
16+
* Runs a command with fully inherited stdio and `CI=true` so that tools
17+
* like Nx use non-interactive output. Fails with `CommandExitError` if
18+
* the exit code is non-zero.
19+
*
20+
* @param description - Human-readable label for the command (e.g. "pnpm build")
21+
* @param cmd - The Effect Command to execute
22+
*/
23+
const runInherited = (description: string, cmd: Command.Command) =>
24+
cmd.pipe(
25+
Command.env({ CI: 'true' }),
26+
Command.stdin('inherit'),
27+
Command.stdout('inherit'),
28+
Command.stderr('inherit'),
29+
Command.exitCode,
30+
Effect.flatMap((code) =>
31+
code === 0
32+
? Effect.void
33+
: Effect.fail(
34+
new CommandExitError({
35+
message: `${description} exited with code ${code}`,
36+
cause: `Non-zero exit code: ${code}`,
37+
command: description,
38+
exitCode: code,
39+
}),
40+
),
41+
),
42+
);
43+
44+
/** Fails with `GitStatusError` if the git working tree has staged changes. */
45+
export const assertCleanGitStatus = Effect.gen(function* () {
46+
const output = yield* Command.make('git', 'status', '--porcelain').pipe(Command.string);
47+
48+
const hasStagedChanges = output.split('\n').some((line) => /^[MADRCU] /.test(line));
49+
50+
if (hasStagedChanges) {
51+
yield* Effect.fail(
52+
new GitStatusError({
53+
message: 'Git has staged changes. Commit or stash them before releasing.',
54+
cause: 'Staged changes detected in git working tree',
55+
}),
56+
);
57+
}
58+
59+
yield* Effect.log('Git status clean — no staged changes.');
60+
});
61+
62+
/** Fails with `ChangesetError` if the `.changeset/` directory contains no changeset markdown files. */
63+
export const assertChangesetsExist = Effect.gen(function* () {
64+
const fs = yield* FileSystem.FileSystem;
65+
const path = yield* Path.Path;
66+
67+
const changesetDir = path.join(process.cwd(), '.changeset');
68+
69+
const files = yield* fs.readDirectory(changesetDir).pipe(
70+
Effect.catchTag('SystemError', (e) =>
71+
Effect.fail(
72+
new ChangesetError({
73+
message:
74+
e.reason === 'NotFound'
75+
? 'No .changeset directory found.'
76+
: `Failed to read .changeset directory: ${e.message}`,
77+
cause: e.reason,
78+
}),
79+
),
80+
),
81+
);
82+
83+
const hasChangesets = files
84+
.filter((f) => f !== 'README.md' && f !== 'config.json')
85+
.some((f) => f.endsWith('.md'));
86+
87+
if (!hasChangesets) {
88+
yield* Effect.fail(
89+
new ChangesetError({
90+
message: 'No changeset files found. Add a changeset before releasing.',
91+
cause: 'No markdown files in .changeset directory',
92+
}),
93+
);
94+
}
95+
96+
yield* Effect.log('Changeset files found.');
97+
});
98+
99+
/**
100+
* Versions all packages as snapshot releases via `changeset version --snapshot`.
101+
* Temporarily disables the GitHub changelog in `.changeset/config.json` to avoid
102+
* requiring a GITHUB_TOKEN for local releases. The modified config is reverted
103+
* by `restoreGitFiles`.
104+
*/
105+
export const versionSnapshotPackages = Effect.gen(function* () {
106+
const fs = yield* FileSystem.FileSystem;
107+
const path = yield* Path.Path;
108+
109+
const configPath = path.join(process.cwd(), '.changeset', 'config.json');
110+
111+
const raw = yield* fs.readFileString(configPath).pipe(
112+
Effect.catchTag('SystemError', (e) =>
113+
Effect.fail(
114+
new ChangesetConfigError({
115+
message: `Failed to read .changeset/config.json: ${e.message}`,
116+
cause: e.reason,
117+
}),
118+
),
119+
),
120+
);
121+
122+
const config: Record<string, unknown> = yield* Effect.try({
123+
try: () => JSON.parse(raw) as Record<string, unknown>,
124+
catch: (e) =>
125+
new ChangesetConfigError({
126+
message: 'Invalid JSON in .changeset/config.json',
127+
cause: String(e),
128+
}),
129+
});
130+
131+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
132+
yield* Effect.fail(
133+
new ChangesetConfigError({
134+
message: '.changeset/config.json must be a JSON object',
135+
cause: `Unexpected shape: ${typeof config}`,
136+
}),
137+
);
138+
}
139+
140+
config.changelog = false;
141+
yield* fs.writeFileString(configPath, JSON.stringify(config, null, 2) + '\n').pipe(
142+
Effect.catchTag('SystemError', (e) =>
143+
Effect.fail(
144+
new ChangesetConfigError({
145+
message: `Failed to write .changeset/config.json: ${e.message}`,
146+
cause: e.reason,
147+
}),
148+
),
149+
),
150+
);
151+
152+
yield* Effect.log('Running changeset version --snapshot...');
153+
yield* runInherited(
154+
'changeset version --snapshot',
155+
Command.make('pnpm', 'changeset', 'version', '--snapshot', SNAPSHOT_TAG),
156+
);
157+
yield* Effect.log('Snapshot versioning complete.');
158+
});
159+
160+
/** Runs `pnpm build` with output visible in the terminal. */
161+
export const buildPackages = Effect.gen(function* () {
162+
yield* runInherited('pnpm build', Command.make('pnpm', 'build'));
163+
yield* Effect.log('Build complete.');
164+
});
165+
166+
/** Starts the Verdaccio local registry as a background process. */
167+
export const startLocalRegistry = Effect.gen(function* () {
168+
yield* Command.make('pnpm', 'nx', 'local-registry').pipe(Command.start, Effect.asVoid);
169+
yield* Effect.log('Verdaccio local registry starting...');
170+
});
171+
172+
/** Publishes all packages to the local Verdaccio registry. */
173+
export const publishToLocalRegistry = Effect.gen(function* () {
174+
yield* runInherited(
175+
'pnpm publish',
176+
Command.make(
177+
'pnpm',
178+
'publish',
179+
'-r',
180+
'--tag',
181+
SNAPSHOT_TAG,
182+
`--registry=${LOCAL_REGISTRY_URL}`,
183+
'--no-git-checks',
184+
),
185+
);
186+
yield* Effect.log('Packages published to local registry.');
187+
});
188+
189+
/** Restores all modified files in the working tree via `git restore .`. */
190+
export const restoreGitFiles = Effect.gen(function* () {
191+
const code = yield* Command.make('git', 'restore', '.').pipe(Command.exitCode);
192+
193+
if (code !== 0) {
194+
yield* Effect.fail(
195+
new GitRestoreError({
196+
message: `git restore exited with code ${code}`,
197+
cause: `Non-zero exit code: ${code}`,
198+
}),
199+
);
200+
}
201+
});

tools/release/errors.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Data } from 'effect';
2+
3+
/** Staged changes detected in git working tree. */
4+
export class GitStatusError extends Data.TaggedError('GitStatusError')<{
5+
message: string;
6+
cause: string;
7+
}> {}
8+
9+
/** Missing `.changeset/` directory or no changeset markdown files found. */
10+
export class ChangesetError extends Data.TaggedError('ChangesetError')<{
11+
message: string;
12+
cause: string;
13+
}> {}
14+
15+
/** A shell command exited with a non-zero exit code. */
16+
export class CommandExitError extends Data.TaggedError('CommandExitError')<{
17+
message: string;
18+
cause: string;
19+
command: string;
20+
exitCode: number;
21+
}> {}
22+
23+
/** `.changeset/config.json` is unreadable, contains invalid JSON, or has an unexpected shape. */
24+
export class ChangesetConfigError extends Data.TaggedError('ChangesetConfigError')<{
25+
message: string;
26+
cause: string;
27+
}> {}
28+
29+
/** Verdaccio registry did not respond within the retry timeout. */
30+
export class RegistryNotReadyError extends Data.TaggedError('RegistryNotReadyError')<{
31+
message: string;
32+
cause: string;
33+
}> {}
34+
35+
/** `git restore .` failed during cleanup. */
36+
export class GitRestoreError extends Data.TaggedError('GitRestoreError')<{
37+
message: string;
38+
cause: string;
39+
}> {}
40+
41+
/** Union of all typed errors the release pipeline can produce. */
42+
export type ReleaseError =
43+
| GitStatusError
44+
| ChangesetError
45+
| CommandExitError
46+
| ChangesetConfigError
47+
| RegistryNotReadyError
48+
| GitRestoreError;

0 commit comments

Comments
 (0)