@shivaedev/changesets 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +68 -1
- package/dist/applyRelease.d.ts +16 -0
- package/dist/applyRelease.d.ts.map +1 -0
- package/dist/applyRelease.js +55 -0
- package/dist/applyRelease.js.map +1 -0
- package/dist/check.d.ts +6 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +69 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +29 -0
- package/dist/cli.js.map +1 -0
- package/dist/collectChangelog.d.ts +3 -0
- package/dist/collectChangelog.d.ts.map +1 -0
- package/dist/collectChangelog.js +17 -0
- package/dist/collectChangelog.js.map +1 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -0
- package/dist/error.d.ts +13 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +15 -0
- package/dist/error.js.map +1 -0
- package/dist/model.d.ts +68 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +22 -0
- package/dist/model.js.map +1 -0
- package/dist/parser.d.ts +8 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +30 -0
- package/dist/parser.js.map +1 -0
- package/dist/planner.d.ts +20 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +71 -0
- package/dist/planner.js.map +1 -0
- package/dist/readHistory.d.ts +27 -0
- package/dist/readHistory.d.ts.map +1 -0
- package/dist/readHistory.js +40 -0
- package/dist/readHistory.js.map +1 -0
- package/dist/sections.d.ts +9 -0
- package/dist/sections.d.ts.map +1 -0
- package/dist/sections.js +36 -0
- package/dist/sections.js.map +1 -0
- package/dist/workspace.d.ts +37 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +87 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +70 -4
- package/src/applyRelease.ts +64 -0
- package/src/check.ts +106 -0
- package/src/cli.ts +36 -0
- package/src/collectChangelog.ts +27 -0
- package/src/config.ts +16 -0
- package/src/error.ts +20 -0
- package/src/model.ts +38 -0
- package/src/parser.ts +33 -0
- package/src/planner.ts +73 -0
- package/src/readHistory.ts +41 -0
- package/src/sections.ts +40 -0
- package/src/workspace.ts +109 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { NodeFileSystem, NodeRuntime } from "@effect/platform-node";
|
|
4
|
+
import { Console, Effect } from "effect";
|
|
5
|
+
import { applyRelease } from "#applyRelease.ts";
|
|
6
|
+
import { checkRepository } from "#check.ts";
|
|
7
|
+
import { ChangesetError } from "#error.ts";
|
|
8
|
+
import { planRelease, summarizePlan } from "#planner.ts";
|
|
9
|
+
import { loadConfig } from "#workspace.ts";
|
|
10
|
+
|
|
11
|
+
const program = Effect.gen(function* () {
|
|
12
|
+
const [command, argument, ...extra] = process.argv.slice(2);
|
|
13
|
+
if (
|
|
14
|
+
!["plan", "apply", "check"].includes(command ?? "")
|
|
15
|
+
|| extra.length > 0
|
|
16
|
+
|| (argument !== undefined && command !== "check" && !(command === "plan" && argument === "--json"))
|
|
17
|
+
) {
|
|
18
|
+
return yield* new ChangesetError({ message: "Use changesets plan [--json], changesets apply, or changesets check [base-ref]." });
|
|
19
|
+
}
|
|
20
|
+
const root = process.cwd();
|
|
21
|
+
const config = yield* loadConfig(root);
|
|
22
|
+
if (command === "check") {
|
|
23
|
+
yield* checkRepository(root, config, argument);
|
|
24
|
+
yield* Console.log("Changesets and release notes are valid.");
|
|
25
|
+
} else {
|
|
26
|
+
const plan = yield* command === "apply" ? applyRelease(root, config) : planRelease(root, config);
|
|
27
|
+
yield* Console.log(argument === "--json" ? JSON.stringify(plan, null, 2) : summarizePlan(plan));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
NodeRuntime.runMain(
|
|
31
|
+
program.pipe(
|
|
32
|
+
Effect.tapError((error) => Console.error(error.message)),
|
|
33
|
+
Effect.provide(NodeFileSystem.layer),
|
|
34
|
+
),
|
|
35
|
+
{ disableErrorReporting: true },
|
|
36
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { requireThat } from "#error.ts";
|
|
2
|
+
import type { Release, ReleasePlan } from "#model.ts";
|
|
3
|
+
import { parseChangeset } from "#parser.ts";
|
|
4
|
+
import { renderEntries } from "#sections.ts";
|
|
5
|
+
|
|
6
|
+
export function collectChangelog(previous: string, release: Release, plan: ReleasePlan, date: string): string {
|
|
7
|
+
const timestamp = Date.parse(`${date}T00:00:00Z`);
|
|
8
|
+
requireThat(
|
|
9
|
+
/^\d{4}-\d{2}-\d{2}$/u.test(date) && Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === date,
|
|
10
|
+
"Use a valid UTC YYYY-MM-DD release date in .changeset/release.json; rerun changesets apply.",
|
|
11
|
+
);
|
|
12
|
+
requireThat(
|
|
13
|
+
previous.startsWith("# Changelog\n") && !/^## Unreleased\s*$/mu.test(previous),
|
|
14
|
+
`${release.name}: use a '# Changelog' title and move Unreleased notes into .changeset/*.md.`,
|
|
15
|
+
);
|
|
16
|
+
requireThat(
|
|
17
|
+
!new RegExp(`^## ${release.to.replaceAll(".", "\\.")}(?: - |$)`, "mu").test(previous),
|
|
18
|
+
`${release.name}: version ${release.to} already has notes; restore the package version and rerun changesets apply.`,
|
|
19
|
+
);
|
|
20
|
+
const entries = plan.changesets.flatMap((source) => {
|
|
21
|
+
const changeset = parseChangeset(source);
|
|
22
|
+
return Object.hasOwn(changeset.packages, release.name) ? changeset.entries : [];
|
|
23
|
+
});
|
|
24
|
+
const dependencyNotes = entries.length === 0 ? [{ section: "Changed" as const, text: "- Release with updated workspace dependencies." }] : [];
|
|
25
|
+
const history = previous.slice("# Changelog\n".length).trimStart();
|
|
26
|
+
return `# Changelog\n\n## ${release.to} - ${date}\n\n${renderEntries([...entries, ...dependencyNotes])}${history === "" ? "" : `\n${history}`}`;
|
|
27
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Effect, FileSystem, PlatformError } from "effect";
|
|
2
|
+
import type { ChangesetError } from "#error.ts";
|
|
3
|
+
import type { ReleasePlan } from "#model.ts";
|
|
4
|
+
|
|
5
|
+
export interface HookContext {
|
|
6
|
+
readonly plan: ReleasePlan;
|
|
7
|
+
readonly root: string;
|
|
8
|
+
}
|
|
9
|
+
export type ReleaseHook = (context: HookContext) => Effect.Effect<void, ChangesetError | PlatformError.PlatformError, FileSystem.FileSystem>;
|
|
10
|
+
export interface ChangesetConfig {
|
|
11
|
+
readonly hooks?: readonly ReleaseHook[];
|
|
12
|
+
readonly packages?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
export function defineConfig(config: ChangesetConfig): ChangesetConfig {
|
|
15
|
+
return config;
|
|
16
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export class ChangesetError extends Data.TaggedError("ChangesetError")<{
|
|
4
|
+
readonly message: string;
|
|
5
|
+
readonly cause?: unknown;
|
|
6
|
+
}> {}
|
|
7
|
+
|
|
8
|
+
export function attempt<A>(evaluate: () => A) {
|
|
9
|
+
return Effect.try({
|
|
10
|
+
catch: (cause) =>
|
|
11
|
+
cause instanceof ChangesetError ? cause : new ChangesetError({ cause, message: cause instanceof Error ? cause.message : String(cause) }),
|
|
12
|
+
try: evaluate,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function requireThat(condition: boolean, message: string): void {
|
|
17
|
+
if (!condition) {
|
|
18
|
+
throw new ChangesetError({ message });
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export const Bump = Schema.Literals(["patch", "minor", "major"]);
|
|
3
|
+
export type Bump = typeof Bump.Type;
|
|
4
|
+
export const Source = Schema.Struct({ name: Schema.String, text: Schema.String });
|
|
5
|
+
export type Source = typeof Source.Type;
|
|
6
|
+
const Versions = Schema.Record(Schema.String, Schema.String);
|
|
7
|
+
export const PackageManifest = Schema.Struct({
|
|
8
|
+
dependencies: Schema.optional(Versions),
|
|
9
|
+
name: Schema.String,
|
|
10
|
+
optionalDependencies: Schema.optional(Versions),
|
|
11
|
+
peerDependencies: Schema.optional(Versions),
|
|
12
|
+
private: Schema.optional(Schema.Boolean),
|
|
13
|
+
version: Schema.String,
|
|
14
|
+
});
|
|
15
|
+
export type PackageManifest = typeof PackageManifest.Type;
|
|
16
|
+
export interface WorkspacePackage {
|
|
17
|
+
readonly changelog: string;
|
|
18
|
+
readonly directory: string;
|
|
19
|
+
readonly json: Readonly<Record<string, unknown>>;
|
|
20
|
+
readonly manifest: PackageManifest;
|
|
21
|
+
}
|
|
22
|
+
export const Release = Schema.Struct({
|
|
23
|
+
bump: Bump,
|
|
24
|
+
directory: Schema.String,
|
|
25
|
+
from: Schema.String,
|
|
26
|
+
name: Schema.String,
|
|
27
|
+
to: Schema.String,
|
|
28
|
+
});
|
|
29
|
+
export type Release = typeof Release.Type;
|
|
30
|
+
export const ReleasePlan = Schema.Struct({ changesets: Schema.Array(Source), releases: Schema.Array(Release) });
|
|
31
|
+
export type ReleasePlan = typeof ReleasePlan.Type;
|
|
32
|
+
export const ReleaseManifest = Schema.Struct({ date: Schema.String, plan: ReleasePlan });
|
|
33
|
+
export type ReleaseManifest = typeof ReleaseManifest.Type;
|
|
34
|
+
export interface Workspace {
|
|
35
|
+
readonly changesets: readonly Source[];
|
|
36
|
+
readonly packages: readonly WorkspacePackage[];
|
|
37
|
+
readonly release: ReleaseManifest | undefined;
|
|
38
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { requireThat } from "#error.ts";
|
|
3
|
+
import { Bump, type Source } from "#model.ts";
|
|
4
|
+
import { type Entry, parseBody } from "#sections.ts";
|
|
5
|
+
export interface Changeset extends Source {
|
|
6
|
+
readonly entries: readonly Entry[];
|
|
7
|
+
readonly packages: Readonly<Record<string, Bump>>;
|
|
8
|
+
}
|
|
9
|
+
function scalar(value: string): string {
|
|
10
|
+
if (value.startsWith('"')) {
|
|
11
|
+
return Schema.decodeUnknownSync(Schema.fromJsonString(Schema.String))(value);
|
|
12
|
+
}
|
|
13
|
+
return value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1).replaceAll("''", "'") : value;
|
|
14
|
+
}
|
|
15
|
+
export function parseChangeset(source: Source): Changeset {
|
|
16
|
+
const match = /^---\n(?<header>[\s\S]*?)\n---\n(?<body>[\s\S]*)$/u.exec(source.text.replaceAll("\r\n", "\n"));
|
|
17
|
+
requireThat(match !== null, `${source.name}: add YAML frontmatter between --- lines, mapping package names to patch, minor or major.`);
|
|
18
|
+
const packages: Record<string, Bump> = {};
|
|
19
|
+
for (const line of (match?.groups?.header ?? "").split("\n")) {
|
|
20
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const mapping = /^\s*(?<name>"(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^\s:'"{}[\],#]+)\s*:\s*(?<bump>"[^"]*"|'[^']*'|[^\s#]+)\s*(?:#.*)?$/u.exec(line);
|
|
24
|
+
requireThat(mapping !== null, `${source.name}: use one YAML package: bump mapping per line, for example '"@scope/pkg": minor'.`);
|
|
25
|
+
const name = scalar(mapping?.groups?.name ?? "");
|
|
26
|
+
const bump = scalar(mapping?.groups?.bump ?? "");
|
|
27
|
+
requireThat(name !== "" && !Object.hasOwn(packages, name), `${source.name}: name each package once in frontmatter.`);
|
|
28
|
+
requireThat(Schema.is(Bump)(bump), `${source.name}: ${name} needs an explicit patch, minor or major bump.`);
|
|
29
|
+
packages[name] = Schema.decodeUnknownSync(Bump)(bump);
|
|
30
|
+
}
|
|
31
|
+
requireThat(Object.keys(packages).length > 0, `${source.name}: name at least one package and its explicit bump in frontmatter.`);
|
|
32
|
+
return { ...source, entries: parseBody(source.name, match?.groups?.body ?? ""), packages };
|
|
33
|
+
}
|
package/src/planner.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { ChangesetConfig } from "#config.ts";
|
|
3
|
+
import { attempt, requireThat } from "#error.ts";
|
|
4
|
+
import type { Bump, PackageManifest, ReleasePlan, Source, WorkspacePackage } from "#model.ts";
|
|
5
|
+
import { parseChangeset } from "#parser.ts";
|
|
6
|
+
import { readWorkspace } from "#workspace.ts";
|
|
7
|
+
|
|
8
|
+
const rank = { major: 2, minor: 1, patch: 0 };
|
|
9
|
+
export function publishedDependencies(manifest: PackageManifest): readonly string[] {
|
|
10
|
+
return Object.keys({ ...manifest.dependencies, ...manifest.optionalDependencies, ...manifest.peerDependencies });
|
|
11
|
+
}
|
|
12
|
+
function nextVersion(name: string, version: string, bump: Bump): string {
|
|
13
|
+
const match = /^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)$/u.exec(version);
|
|
14
|
+
requireThat(match !== null, `${name}: use a stable major.minor.patch version before planning a release; found ${version}.`);
|
|
15
|
+
const major = Number(match?.groups?.major);
|
|
16
|
+
const minor = Number(match?.groups?.minor);
|
|
17
|
+
const patch = Number(match?.groups?.patch);
|
|
18
|
+
requireThat(major !== 0 || bump !== "major", `${name}: major is not allowed on a 0.x package; use minor for breaking changes.`);
|
|
19
|
+
if (bump === "major") {
|
|
20
|
+
return `${major + 1}.0.0`;
|
|
21
|
+
}
|
|
22
|
+
return bump === "minor" ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}`;
|
|
23
|
+
}
|
|
24
|
+
function requestedBumps(packages: readonly WorkspacePackage[], sources: readonly Source[]): Map<string, Bump> {
|
|
25
|
+
const bumps = new Map<string, Bump>();
|
|
26
|
+
for (const source of sources) {
|
|
27
|
+
for (const [name, bump] of Object.entries(parseChangeset(source).packages)) {
|
|
28
|
+
const pkg = packages.find((candidate) => candidate.manifest.name === name);
|
|
29
|
+
requireThat(pkg !== undefined, `${source.name}: unknown package ${name}; fix the name or include its directory in config.packages.`);
|
|
30
|
+
requireThat(!pkg?.manifest.private, `${source.name}: ${name} is private; remove it from release frontmatter.`);
|
|
31
|
+
if (pkg !== undefined) {
|
|
32
|
+
nextVersion(name, pkg.manifest.version, bump);
|
|
33
|
+
}
|
|
34
|
+
const previous = bumps.get(name);
|
|
35
|
+
if (previous === undefined || rank[bump] > rank[previous]) {
|
|
36
|
+
bumps.set(name, bump);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return bumps;
|
|
41
|
+
}
|
|
42
|
+
export function computePlan(packages: readonly WorkspacePackage[], sources: readonly Source[]): ReleasePlan {
|
|
43
|
+
const bumps = requestedBumps(packages, sources);
|
|
44
|
+
let changed = true;
|
|
45
|
+
while (changed) {
|
|
46
|
+
changed = false;
|
|
47
|
+
for (const { manifest } of packages) {
|
|
48
|
+
if (!(manifest.private || bumps.has(manifest.name)) && publishedDependencies(manifest).some((name) => bumps.has(name))) {
|
|
49
|
+
bumps.set(manifest.name, "patch");
|
|
50
|
+
changed = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const releases = [...bumps]
|
|
55
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
56
|
+
.map(([name, bump]) => {
|
|
57
|
+
const pkg = packages.find((candidate) => candidate.manifest.name === name);
|
|
58
|
+
if (pkg === undefined) {
|
|
59
|
+
throw new Error(`Package ${name} disappeared while planning.`);
|
|
60
|
+
}
|
|
61
|
+
return { bump, directory: pkg.directory, from: pkg.manifest.version, name, to: nextVersion(name, pkg.manifest.version, bump) };
|
|
62
|
+
});
|
|
63
|
+
return { changesets: [...sources].sort((left, right) => left.name.localeCompare(right.name)), releases };
|
|
64
|
+
}
|
|
65
|
+
export function summarizePlan(plan: ReleasePlan): string {
|
|
66
|
+
return plan.releases.length === 0
|
|
67
|
+
? "No pending releases."
|
|
68
|
+
: plan.releases.map((release) => `${release.name}: ${release.from} -> ${release.to} (${release.bump})`).join("\n");
|
|
69
|
+
}
|
|
70
|
+
export const planRelease = Effect.fn("Changesets.planRelease")(function* (root: string, config: ChangesetConfig = {}) {
|
|
71
|
+
const workspace = yield* readWorkspace(root, config);
|
|
72
|
+
return yield* attempt(() => computePlan(workspace.packages, workspace.changesets));
|
|
73
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { posix } from "node:path";
|
|
4
|
+
import { Effect, Schema } from "effect";
|
|
5
|
+
import type { ChangesetConfig } from "#config.ts";
|
|
6
|
+
import { attempt, ChangesetError } from "#error.ts";
|
|
7
|
+
import { ReleaseManifest } from "#model.ts";
|
|
8
|
+
import { decodePackage, isChangeset, packagePatterns } from "#workspace.ts";
|
|
9
|
+
|
|
10
|
+
function git(root: string, args: readonly string[]): string {
|
|
11
|
+
return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
12
|
+
}
|
|
13
|
+
export const readHistory = Effect.fn("Changesets.readHistory")(function* (root: string, config: ChangesetConfig, reference?: string) {
|
|
14
|
+
const base = yield* attempt(() => {
|
|
15
|
+
const mergeHead = git(root, ["rev-parse", "--path-format=absolute", "--git-path", "MERGE_HEAD"]).trim();
|
|
16
|
+
const heads = ["HEAD", ...(existsSync(mergeHead) ? readFileSync(mergeHead, "utf8").trim().split("\n") : [])];
|
|
17
|
+
for (const candidate of reference === undefined ? ["origin/main", "main"] : [reference]) {
|
|
18
|
+
const result = spawnSync("git", ["-C", root, "merge-base", candidate, ...heads], { encoding: "utf8" });
|
|
19
|
+
if (result.status === 0) {
|
|
20
|
+
return result.stdout.trim();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw new ChangesetError({
|
|
24
|
+
message: `Cannot resolve changeset base ${reference ?? "origin/main or main"}; run git fetch origin main or pass an existing base-ref with full history.`,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
return yield* attempt(() => {
|
|
28
|
+
const paths = git(root, ["ls-tree", "-r", "--name-only", base]).trim().split("\n");
|
|
29
|
+
function read(path: string, fallback: string) {
|
|
30
|
+
return paths.includes(path) ? git(root, ["show", `${base}:${path}`]) : fallback;
|
|
31
|
+
}
|
|
32
|
+
const packages = paths
|
|
33
|
+
.filter((path) => path.endsWith("/package.json") && packagePatterns(config).some((pattern) => posix.matchesGlob(posix.dirname(path), pattern)))
|
|
34
|
+
.map((path) => decodePackage(posix.dirname(path), read(path, ""), read(`${posix.dirname(path)}/CHANGELOG.md`, "# Changelog\n")));
|
|
35
|
+
const changesets = paths
|
|
36
|
+
.filter((path) => path.startsWith(".changeset/") && isChangeset(path.slice(".changeset/".length)))
|
|
37
|
+
.map((path) => ({ name: path.slice(".changeset/".length), text: read(path, "") }));
|
|
38
|
+
const text = read(".changeset/release.json", "");
|
|
39
|
+
return { changesets, packages, release: text === "" ? undefined : Schema.decodeUnknownSync(Schema.fromJsonString(ReleaseManifest))(text) };
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/sections.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { requireThat } from "#error.ts";
|
|
2
|
+
|
|
3
|
+
export const sections = ["Added", "Changed", "Fixed", "Breaking"] as const;
|
|
4
|
+
export type Section = (typeof sections)[number];
|
|
5
|
+
export interface Entry {
|
|
6
|
+
readonly section: Section;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseBody(name: string, body: string): readonly Entry[] {
|
|
11
|
+
const entries: Entry[] = [];
|
|
12
|
+
let section: Section | undefined;
|
|
13
|
+
for (const line of body.split("\n")) {
|
|
14
|
+
if (line.startsWith("### ")) {
|
|
15
|
+
section = sections.find((value) => line === `### ${value}`);
|
|
16
|
+
requireThat(section !== undefined, `${name}: use ### Added, ### Changed, ### Fixed or ### Breaking.`);
|
|
17
|
+
} else if (line.startsWith("- ") && section !== undefined) {
|
|
18
|
+
requireThat(line.slice(2).trim() !== "", `${name}: write text after '- '.`);
|
|
19
|
+
entries.push({ section, text: line });
|
|
20
|
+
} else if (line.trim() !== "") {
|
|
21
|
+
const previous = entries.at(-1);
|
|
22
|
+
if (previous === undefined || previous.section !== section || !/^\s+\S/u.test(line)) {
|
|
23
|
+
requireThat(false, `${name}: start each entry with '- ' under a ### section heading.`);
|
|
24
|
+
} else {
|
|
25
|
+
entries[entries.length - 1] = { ...previous, text: `${previous.text}\n${line}` };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
requireThat(entries.length > 0, `${name}: add at least one release-note entry under a ### section heading.`);
|
|
30
|
+
return entries;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderEntries(entries: readonly Entry[]): string {
|
|
34
|
+
return sections
|
|
35
|
+
.flatMap((section) => {
|
|
36
|
+
const selected = entries.filter((entry) => entry.section === section);
|
|
37
|
+
return selected.length === 0 ? [] : [`### ${section}\n\n${selected.map((entry) => entry.text).join("\n")}\n`];
|
|
38
|
+
})
|
|
39
|
+
.join("\n");
|
|
40
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { Effect, FileSystem, Schema } from "effect";
|
|
4
|
+
import type { ChangesetConfig } from "#config.ts";
|
|
5
|
+
import { attempt, ChangesetError, requireThat } from "#error.ts";
|
|
6
|
+
import { PackageManifest, ReleaseManifest, type Source, type WorkspacePackage } from "#model.ts";
|
|
7
|
+
export function packagePatterns(config: ChangesetConfig): readonly string[] {
|
|
8
|
+
return config.packages ?? ["packages/*", "examples/*"];
|
|
9
|
+
}
|
|
10
|
+
const Json = Schema.Record(Schema.String, Schema.Unknown);
|
|
11
|
+
export function decodePackage(directory: string, text: string, changelog: string): WorkspacePackage {
|
|
12
|
+
return {
|
|
13
|
+
changelog,
|
|
14
|
+
directory,
|
|
15
|
+
json: Schema.decodeUnknownSync(Schema.fromJsonString(Json))(text),
|
|
16
|
+
manifest: Schema.decodeUnknownSync(Schema.fromJsonString(PackageManifest))(text),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export const readOptional = Effect.fn("Changesets.readOptional")(function* (path: string, fallback: string) {
|
|
20
|
+
const fs = yield* FileSystem.FileSystem;
|
|
21
|
+
return (yield* fs.exists(path)) ? yield* fs.readFileString(path) : fallback;
|
|
22
|
+
});
|
|
23
|
+
export const loadConfig = Effect.fn("Changesets.loadConfig")(function* (root: string) {
|
|
24
|
+
const fs = yield* FileSystem.FileSystem;
|
|
25
|
+
const path = join(root, ".changeset/changesets.config.ts");
|
|
26
|
+
if (!(yield* fs.exists(path))) {
|
|
27
|
+
return {} satisfies ChangesetConfig;
|
|
28
|
+
}
|
|
29
|
+
const module: {
|
|
30
|
+
default: ChangesetConfig;
|
|
31
|
+
} = yield* Effect.tryPromise({
|
|
32
|
+
catch: (cause) =>
|
|
33
|
+
new ChangesetError({
|
|
34
|
+
cause,
|
|
35
|
+
message: ".changeset/changesets.config.ts: export default defineConfig({ packages, hooks }); hooks must be typed Effect functions.",
|
|
36
|
+
}),
|
|
37
|
+
try: () => import(pathToFileURL(path).href),
|
|
38
|
+
});
|
|
39
|
+
yield* attempt(() => {
|
|
40
|
+
requireThat(
|
|
41
|
+
typeof module.default === "object" && module.default !== null,
|
|
42
|
+
".changeset/changesets.config.ts: export a config object with defineConfig.",
|
|
43
|
+
);
|
|
44
|
+
requireThat(
|
|
45
|
+
module.default.hooks === undefined || (Array.isArray(module.default.hooks) && module.default.hooks.every((hook) => typeof hook === "function")),
|
|
46
|
+
".changeset/changesets.config.ts: hooks must be Effect functions, not shell strings.",
|
|
47
|
+
);
|
|
48
|
+
requireThat(
|
|
49
|
+
module.default.packages === undefined
|
|
50
|
+
|| (Array.isArray(module.default.packages) && module.default.packages.every((pattern) => typeof pattern === "string")),
|
|
51
|
+
".changeset/changesets.config.ts: packages must be directory paths or parent/* patterns.",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
return module.default;
|
|
55
|
+
});
|
|
56
|
+
const directories = Effect.fn("Changesets.directories")(function* (root: string, pattern: string) {
|
|
57
|
+
const fs = yield* FileSystem.FileSystem;
|
|
58
|
+
yield* attempt(() =>
|
|
59
|
+
requireThat(
|
|
60
|
+
/^(?:[\w.-]+\/)*[\w.*-]+$/u.test(pattern) && !pattern.split("/").includes("..") && (!pattern.includes("*") || pattern.endsWith("/*")),
|
|
61
|
+
`Invalid package pattern ${pattern}; use a relative directory or parent/*. `,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
if (!pattern.endsWith("/*")) {
|
|
65
|
+
return [pattern];
|
|
66
|
+
}
|
|
67
|
+
const parent = pattern.slice(0, -2);
|
|
68
|
+
return (yield* fs.exists(join(root, parent))) ? (yield* fs.readDirectory(join(root, parent))).map((name) => `${parent}/${name}`) : [];
|
|
69
|
+
});
|
|
70
|
+
export const readChangesets = Effect.fn("Changesets.readChangesets")(function* (root: string) {
|
|
71
|
+
const fs = yield* FileSystem.FileSystem;
|
|
72
|
+
const directory = join(root, ".changeset");
|
|
73
|
+
const names = (yield* fs.exists(directory)) ? yield* fs.readDirectory(directory) : [];
|
|
74
|
+
return yield* Effect.forEach(names.filter(isChangeset).sort(), (name) =>
|
|
75
|
+
Effect.map(fs.readFileString(join(directory, name)), (text): Source => ({ name, text })),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
export function isChangeset(name: string): boolean {
|
|
79
|
+
return name !== "README.md" && /^[^/\\]+\.md$/u.test(name);
|
|
80
|
+
}
|
|
81
|
+
export const readWorkspace = Effect.fn("Changesets.readWorkspace")(function* (root: string, config: ChangesetConfig) {
|
|
82
|
+
const fs = yield* FileSystem.FileSystem;
|
|
83
|
+
const paths = [...new Set((yield* Effect.forEach(packagePatterns(config), (pattern) => directories(root, pattern))).flat())].sort();
|
|
84
|
+
const packages: WorkspacePackage[] = [];
|
|
85
|
+
for (const directory of paths) {
|
|
86
|
+
if (yield* fs.exists(join(root, directory, "package.json"))) {
|
|
87
|
+
const text = yield* fs.readFileString(join(root, directory, "package.json"));
|
|
88
|
+
const changelog = yield* readOptional(join(root, directory, "CHANGELOG.md"), "# Changelog\n");
|
|
89
|
+
packages.push(yield* attempt(() => decodePackage(directory, text, changelog)));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
yield* attempt(() =>
|
|
93
|
+
requireThat(
|
|
94
|
+
new Set(packages.map((pkg) => pkg.manifest.name)).size === packages.length,
|
|
95
|
+
"Workspace package names must be unique; fix package.json names.",
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
const releaseText = yield* readOptional(join(root, ".changeset/release.json"), "");
|
|
99
|
+
const release =
|
|
100
|
+
releaseText === ""
|
|
101
|
+
? undefined
|
|
102
|
+
: yield* attempt(() => Schema.decodeUnknownSync(Schema.fromJsonString(ReleaseManifest))(releaseText)).pipe(
|
|
103
|
+
Effect.mapError(
|
|
104
|
+
(cause) =>
|
|
105
|
+
new ChangesetError({ cause, message: ".changeset/release.json is invalid; restore the manifest generated by changesets apply." }),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
return { changesets: yield* readChangesets(root), packages, release };
|
|
109
|
+
});
|