diff --git a/packages/changeset-engine/tests/__fixtures__/FakeWorkspaceStore.ts b/packages/changeset-engine/tests/__fixtures__/FakeWorkspaceStore.ts index 64b53fc..8c29b63 100644 --- a/packages/changeset-engine/tests/__fixtures__/FakeWorkspaceStore.ts +++ b/packages/changeset-engine/tests/__fixtures__/FakeWorkspaceStore.ts @@ -8,4 +8,5 @@ export const fakeWorkspaceStore = (members: ReadonlyArray) => listMembers: () => Effect.succeed(members), readManifest: () => Effect.die(new Error('FakeWorkspaceStore.readManifest is not used by these cells')), readFileFromRoot: () => Effect.die(new Error('FakeWorkspaceStore.readFileFromRoot is not used by these cells')), + changelogStorage: () => Effect.die(new Error('FakeWorkspaceStore.changelogStorage is not used by these cells')), }) diff --git a/packages/github-release-engine/src/cycle.ts b/packages/github-release-engine/src/cycle.ts index 705edd1..2656c59 100644 --- a/packages/github-release-engine/src/cycle.ts +++ b/packages/github-release-engine/src/cycle.ts @@ -1,18 +1,36 @@ -import type { Member, PackageName, PackageVersion } from '@systemfsoftware/release-language' +import type { ChangelogStorage, Member, PackageName, PackageVersion } from '@systemfsoftware/release-language' import { CycleEntry, RelativePath, ReleaseTag } from '@systemfsoftware/release-language' export const tagOf = (name: PackageName, version: PackageVersion): ReleaseTag => ReleaseTag.make(`${name}@v${version}`) export const changelogOf = ( + storage: ChangelogStorage, changelogDir: RelativePath, - name: PackageName, + member: Member, +): RelativePath => { + if (storage === 'repository') return RelativePath.make(`${member.dir}/CHANGELOG.md`) + return RelativePath.make(`${changelogDir}/${member.name.replace('/', '!')}@${member.manifest.version}.md`) +} + +export const releaseNotesOf = ( + storage: ChangelogStorage, + changelog: string, version: PackageVersion, -): RelativePath => RelativePath.make(`${changelogDir}/${name.replace('/', '!')}@${version}.md`) +): string | undefined => { + if (storage === 'registry') return changelog + const lines = changelog.split('\n') + const start = lines.findIndex((line) => line.trimEnd() === `## ${version}`) + if (start === -1) return undefined + const next = lines.findIndex((line, index) => index > start && line.startsWith('## ')) + if (next === -1) return lines.slice(start).join('\n').trimEnd() + return lines.slice(start, next).join('\n').trimEnd() +} export const cycleOf = ( members: ReadonlyArray, remoteTags: ReadonlyArray, changelogDir: RelativePath, + storage: ChangelogStorage, ): ReadonlyArray => members .filter((member) => member.publishable) @@ -22,7 +40,7 @@ export const cycleOf = ( name: member.name, version: member.manifest.version, tag: tagOf(member.name, member.manifest.version), - changelog: changelogOf(changelogDir, member.name, member.manifest.version), + changelog: changelogOf(storage, changelogDir, member), }) ) diff --git a/packages/github-release-engine/src/github-release.ts b/packages/github-release-engine/src/github-release.ts index 2857dbb..5005582 100644 --- a/packages/github-release-engine/src/github-release.ts +++ b/packages/github-release-engine/src/github-release.ts @@ -1,5 +1,6 @@ import { Cell, Wire } from '@systemfsoftware/effect-cell-types' import type { + ChangelogStorage, CycleEntry, GithubReleaseRefusal, MemberRefusal, @@ -21,7 +22,7 @@ import { Effect } from 'effect' import * as Match from 'effect/Match' import * as Result from 'effect/Result' import * as S from 'effect/Schema' -import { cycleOf } from './cycle.js' +import { cycleOf, releaseNotesOf } from './cycle.js' import { type CreatedRelease, githubRelease, @@ -46,6 +47,7 @@ const readCycle = ( git: GitPort, capturedPath: FsPath | undefined, changelogDir: RelativePath, + storage: ChangelogStorage, ): Effect.Effect, PlanRefusal | MemberRefusal | TagRefusal, never> => { if (capturedPath !== undefined) { return cycles.readCaptured(capturedPath) @@ -53,7 +55,7 @@ const readCycle = ( return Effect.gen(function*() { const members = yield* workspace.listMembers() const tags = yield* git.remoteTags(RemoteName.make('origin')) - return cycleOf(members, tags, changelogDir) + return cycleOf(members, tags, changelogDir, storage) }) } @@ -69,19 +71,21 @@ const read = ( const git = yield* GitPort const cycles = yield* CycleStore const forge = yield* ForgePort + const storage = yield* workspace.changelogStorage() const cycle = yield* readCycle( cycles, workspace, git, request.captured ?? request.capturedFile, request.changelogDir, + storage, ) const slug = yield* git.repoSlug() const items = yield* Effect.forEach(cycle, (entry) => Effect.map( Effect.match(workspace.readFileFromRoot(entry.changelog), { onFailure: () => undefined, - onSuccess: (file) => file.text, + onSuccess: (file) => releaseNotesOf(storage, file.text, entry.version), }), (body) => ({ entry, body }), )) diff --git a/packages/github-release-engine/src/plan.ts b/packages/github-release-engine/src/plan.ts index 181a902..8f418a8 100644 --- a/packages/github-release-engine/src/plan.ts +++ b/packages/github-release-engine/src/plan.ts @@ -42,9 +42,10 @@ const read = ( const members = yield* workspace.listMembers() const tags = yield* git.remoteTags(remote) const deferred = yield* cycles.readDeferred(request.deferred) + const storage = yield* workspace.changelogStorage() return PlanCommand.make({ pending: Count.make(intents.length), - cycle: dropExcluded(cycleOf(members, tags, request.changelogDir), deferred), + cycle: dropExcluded(cycleOf(members, tags, request.changelogDir, storage), deferred), deferred: [...deferred], unknownDeferred: [], members: members.map((member) => member.name), diff --git a/packages/github-release-engine/src/tag.ts b/packages/github-release-engine/src/tag.ts index 6d89845..6dfeff0 100644 --- a/packages/github-release-engine/src/tag.ts +++ b/packages/github-release-engine/src/tag.ts @@ -87,8 +87,9 @@ const readLive = (input: { Effect.gen(function*() { const members = yield* input.workspace.listMembers() const tags = yield* input.git.remoteTags(input.remote) + const storage = yield* input.workspace.changelogStorage() return { - entries: dropExcluded(cycleOf(members, tags, input.changelogDir), input.excluded), + entries: dropExcluded(cycleOf(members, tags, input.changelogDir, storage), input.excluded), issue: undefined, } }) diff --git a/packages/github-release-engine/tests/__fixtures__/FakeWorkspaceStore.ts b/packages/github-release-engine/tests/__fixtures__/FakeWorkspaceStore.ts index cdf1322..5e69347 100644 --- a/packages/github-release-engine/tests/__fixtures__/FakeWorkspaceStore.ts +++ b/packages/github-release-engine/tests/__fixtures__/FakeWorkspaceStore.ts @@ -1,10 +1,11 @@ -import type { Member, RelativePath } from '@systemfsoftware/release-language' +import type { ChangelogStorage, Member, RelativePath } from '@systemfsoftware/release-language' import { FsPath, ManifestUnreadable, RepoRoot, WorkspaceStore } from '@systemfsoftware/release-language' import { Effect, Layer } from 'effect' export interface FakeWorkspaceState { readonly members: Array readonly files: Record + readonly storage?: ChangelogStorage } export const makeFakeWorkspaceStore = (state: FakeWorkspaceState) => @@ -25,4 +26,5 @@ export const makeFakeWorkspaceStore = (state: FakeWorkspaceState) => } return Effect.succeed({ path, text }) }, + changelogStorage: () => Effect.succeed(state.storage ?? 'registry'), }) diff --git a/packages/github-release-engine/tests/release.integration.test.ts b/packages/github-release-engine/tests/release.integration.test.ts index 1fc956a..ea7b310 100644 --- a/packages/github-release-engine/tests/release.integration.test.ts +++ b/packages/github-release-engine/tests/release.integration.test.ts @@ -602,6 +602,119 @@ Feature('Releasing versions to GitHub').body(({ scenario }) => { ) } + { + const members = [member('alpha', '1.1.0')] + const changelog = { + 'packages/alpha/CHANGELOG.md': '# alpha\n\n## 1.1.0\n\n- new\n\n## 1.0.0\n\n- old\n', + } + const workspace = makeFakeWorkspaceStore({ members, files: changelog, storage: 'repository' }) + const git = makeFakeGit({ tags: [] }) + const cycles = makeFakeCycleStore() + const forge = makeFakeForge() + const live = Layer.mergeAll(workspace, git, cycles.layer, forge.layer) + scenario( + 'Under repository storage the release body is the version section of the package changelog', + { scenarioLayer: live }, + Gherkin.Do.pipe( + Given('a package whose own CHANGELOG.md holds this version and an older one')( + 'input', + () => Effect.succeed({ assert: false, dryRun: false, changelogDir }), + ), + When('creating the releases')( + 'outcome', + (s) => + Effect.match(Cell.run(Cell.provide(githubReleaseCell, live), s.input), { + onFailure: (refusal) => ({ _tag: 'refused' as const, refusal }), + onSuccess: (value) => ({ _tag: 'decided' as const, value }), + }), + ), + Then('the release body is that version section alone')((s) => { + Match.value(s.outcome).pipe( + Match.tag('decided', () => { + expect(forge.calls.created.length).toEqual(1) + expect(forge.calls.created[0]?.body).toEqual('## 1.1.0\n\n- new') + }), + Match.tag('refused', () => failUnexpected('expected a release decision')), + Match.exhaustive, + ) + }), + ), + ) + } + + { + const members = [member('alpha', '1.1.0')] + const changelog = { 'packages/alpha/CHANGELOG.md': '# alpha\n\n## 1.0.0\n\n- old\n' } + const workspace = makeFakeWorkspaceStore({ members, files: changelog, storage: 'repository' }) + const git = makeFakeGit({ tags: [] }) + const cycles = makeFakeCycleStore() + const forge = makeFakeForge() + const live = Layer.mergeAll(workspace, git, cycles.layer, forge.layer) + scenario( + 'Under repository storage a changelog without the version section refuses the release', + { scenarioLayer: live }, + Gherkin.Do.pipe( + Given('a package whose CHANGELOG.md has no section for its version')( + 'input', + () => Effect.succeed({ assert: false, dryRun: false, changelogDir }), + ), + When('creating the releases')( + 'outcome', + (s) => + Effect.match(Cell.run(Cell.provide(githubReleaseCell, live), s.input), { + onFailure: (refusal) => ({ _tag: 'refused' as const, refusal }), + onSuccess: (value) => ({ _tag: 'decided' as const, value }), + }), + ), + Then('the refusal names the package, version and changelog, and nothing is created')((s) => { + Match.value(s.outcome).pipe( + Match.tag('refused', (refused) => { + Match.value(refused.refusal).pipe( + Match.tag('ReleaseChangelogMissing', (missing) => { + expect(missing.package).toEqual(PackageName.make('alpha')) + expect(missing.version).toEqual(PackageVersion.make('1.1.0')) + expect(missing.changelog).toEqual(RelativePath.make('packages/alpha/CHANGELOG.md')) + }), + Match.orElse((other) => failUnexpected(`expected ReleaseChangelogMissing, got ${other._tag}`)), + ) + expect(forge.calls.created.length).toEqual(0) + }), + Match.tag('decided', () => failUnexpected('expected refused')), + Match.exhaustive, + ) + }), + ), + ) + } + + { + const members = [member('alpha', '1.0.0')] + const workspace = makeFakeWorkspaceStore({ members, files: {}, storage: 'repository' }) + const git = makeFakeGit({ tags: [] }) + const cycles = makeFakeCycleStore() + const live = Layer.mergeAll(workspace, git, cycles.layer) + scenario( + 'Under repository storage the captured cycle points at the package changelog', + { scenarioLayer: live }, + Gherkin.Do.pipe( + Given('an owed package with an output file requested')( + 'input', + () => Effect.succeed({ dryRun: false, json: false, changelogDir, output: FsPath.make('out.json') }), + ), + When('tagging the cycle')('outcome', (s) => + Effect.match(Cell.run(Cell.provide(tagCell, live), s.input), { + onFailure: (refusal) => ({ _tag: 'refused' as const, refusal }), + onSuccess: (value) => ({ _tag: 'decided' as const, value }), + })), + Then('the captured entry names packages/alpha/CHANGELOG.md, not a parked file')(() => { + expect(cycles.written.captured[0]?.entries.map((entry) => entry.changelog)).toEqual( + [RelativePath.make('packages/alpha/CHANGELOG.md')], + ) + }), + ), + ) + } + { const members = [member('alpha', '1.0.0')] const changelog = { [changelogFor('alpha', '1.0.0')]: '## 1.0.0\n- fix' } diff --git a/packages/release-language/src/Workspace.schema.ts b/packages/release-language/src/Workspace.schema.ts index 62a0bdb..c0bb472 100644 --- a/packages/release-language/src/Workspace.schema.ts +++ b/packages/release-language/src/Workspace.schema.ts @@ -97,6 +97,9 @@ export const Member = S.Struct({ }) export type Member = S.Schema.Type +export const ChangelogStorage = S.Literals(['registry', 'repository']) +export type ChangelogStorage = S.Schema.Type + export const RootFile = S.Struct({ path: RelativePath, text: S.String, diff --git a/packages/release-language/src/WorkspaceStore.ts b/packages/release-language/src/WorkspaceStore.ts index 724be8a..144917f 100644 --- a/packages/release-language/src/WorkspaceStore.ts +++ b/packages/release-language/src/WorkspaceStore.ts @@ -1,5 +1,13 @@ import { Context, type Effect } from 'effect' -import type { Member, MemberRefusal, PackageManifest, RelativePath, RepoRoot, RootFile } from './Workspace.schema.js' +import type { + ChangelogStorage, + Member, + MemberRefusal, + PackageManifest, + RelativePath, + RepoRoot, + RootFile, +} from './Workspace.schema.js' export interface WorkspaceStore { readonly root: RepoRoot @@ -14,6 +22,7 @@ export interface WorkspaceStore { readonly readFileFromRoot: ( path: RelativePath, ) => Effect.Effect + readonly changelogStorage: () => Effect.Effect } export const WorkspaceStore: Context.Service = Context.Service< diff --git a/packages/release-language/src/index.ts b/packages/release-language/src/index.ts index d1605e1..193e5b7 100644 --- a/packages/release-language/src/index.ts +++ b/packages/release-language/src/index.ts @@ -119,6 +119,7 @@ export { VersionUnknownPackage, } from './Version.schema.js' export { + ChangelogStorage, Count, FsPath, GitRef, diff --git a/packages/version-engine/tests/__fixtures__/FakeWorkspaceStore.ts b/packages/version-engine/tests/__fixtures__/FakeWorkspaceStore.ts index 4f9f56b..7bca682 100644 --- a/packages/version-engine/tests/__fixtures__/FakeWorkspaceStore.ts +++ b/packages/version-engine/tests/__fixtures__/FakeWorkspaceStore.ts @@ -38,6 +38,8 @@ export const makeFakeWorkspaceStore = ( } return Effect.succeed({ path, text }) }), + changelogStorage: () => + Effect.die(new Error('FakeWorkspaceStore.changelogStorage is not used by the version cells')), }) return { layer, state: { members: liveMembers, files: liveFiles } } } diff --git a/packages/workspace-adapter/src/WorkspaceStoreLive.ts b/packages/workspace-adapter/src/WorkspaceStoreLive.ts index ad6c670..db9924d 100644 --- a/packages/workspace-adapter/src/WorkspaceStoreLive.ts +++ b/packages/workspace-adapter/src/WorkspaceStoreLive.ts @@ -1,4 +1,5 @@ import { + type ChangelogStorage, FsPath, ManifestInvalid, ManifestUnreadable, @@ -20,7 +21,9 @@ import { readTextFile } from './StoreFile.js' import { ManifestText, WorkspaceListing } from './Workspace.schema.js' const MANIFEST_FILE = 'package.json' +const WORKSPACE_FILE = 'pnpm-workspace.yaml' const LIST_ARGS = ['ls', '-r', '--json', '--depth=-1'] +const STORAGE_ARGS = ['config', 'get', 'versioning.changelog.storage'] const unreadable = (path: FsPath): MemberRefusal => ManifestUnreadable.make({ path }) const invalid = (path: FsPath, reason: string): MemberRefusal => ManifestInvalid.make({ path, reason }) @@ -45,14 +48,24 @@ export const WorkspaceStoreLive = ( ) }) + const runPnpm = ( + args: ReadonlyArray, + ): Effect.Effect<{ readonly code: number; readonly stdout: string; readonly stderr: string }, MemberRefusal> => + Effect.scoped( + Effect.flatMap( + spawner.spawn(ChildProcess.make('pnpm', args, { cwd: root })), + (handle) => + Effect.all({ + code: handle.exitCode, + stdout: Stream.mkString(Stream.decodeText(handle.stdout)), + stderr: Stream.mkString(Stream.decodeText(handle.stderr)), + }, { concurrency: 'unbounded' }), + ), + ).pipe(Effect.mapError(() => unreadable(rootFs))) + const listMembers = (): Effect.Effect, MemberRefusal> => Effect.gen(function*() { - const [code, stdout] = yield* Effect.scoped( - Effect.flatMap( - spawner.spawn(ChildProcess.make('pnpm', LIST_ARGS, { cwd: root })), - (handle) => Effect.all([handle.exitCode, Stream.mkString(Stream.decodeText(handle.stdout))]), - ), - ).pipe(Effect.mapError(() => unreadable(rootFs))) + const { code, stdout } = yield* runPnpm(LIST_ARGS) if (code !== 0) { return yield* Effect.fail(unreadable(rootFs)) } @@ -83,6 +96,21 @@ export const WorkspaceStoreLive = ( ) } - return { root, listMembers, readManifest, readFileFromRoot } + const changelogStorage = (): Effect.Effect => + Effect.flatMap(runPnpm(STORAGE_ARGS), ({ code, stdout, stderr }) => { + const workspaceFile = FsPath.make(path.join(root, WORKSPACE_FILE)) + const value = stdout.trim() + if (code !== 0) return Effect.fail(invalid(workspaceFile, `${stderr}${stdout}`.trim())) + if (value === 'repository') return Effect.succeed('repository' as const) + if (value === 'registry' || value === 'undefined') return Effect.succeed('registry' as const) + return Effect.fail( + invalid( + workspaceFile, + `versioning.changelog.storage is ${JSON.stringify(value)}; expected "registry" or "repository"`, + ), + ) + }) + + return { root, listMembers, readManifest, readFileFromRoot, changelogStorage } }), )