Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export const fakeWorkspaceStore = (members: ReadonlyArray<Member>) =>
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')),
})
26 changes: 22 additions & 4 deletions packages/github-release-engine/src/cycle.ts
Original file line number Diff line number Diff line change
@@ -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<Member>,
remoteTags: ReadonlyArray<ReleaseTag>,
changelogDir: RelativePath,
storage: ChangelogStorage,
): ReadonlyArray<CycleEntry> =>
members
.filter((member) => member.publishable)
Expand All @@ -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),
})
)

Expand Down
10 changes: 7 additions & 3 deletions packages/github-release-engine/src/github-release.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Cell, Wire } from '@systemfsoftware/effect-cell-types'
import type {
ChangelogStorage,
CycleEntry,
GithubReleaseRefusal,
MemberRefusal,
Expand All @@ -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,
Expand All @@ -46,14 +47,15 @@ const readCycle = (
git: GitPort,
capturedPath: FsPath | undefined,
changelogDir: RelativePath,
storage: ChangelogStorage,
): Effect.Effect<ReadonlyArray<CycleEntry>, PlanRefusal | MemberRefusal | TagRefusal, never> => {
if (capturedPath !== undefined) {
return cycles.readCaptured(capturedPath)
}
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)
})
}

Expand All @@ -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 }),
))
Expand Down
3 changes: 2 additions & 1 deletion packages/github-release-engine/src/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion packages/github-release-engine/src/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Member>
readonly files: Record<string, string | undefined>
readonly storage?: ChangelogStorage
}

export const makeFakeWorkspaceStore = (state: FakeWorkspaceState) =>
Expand All @@ -25,4 +26,5 @@ export const makeFakeWorkspaceStore = (state: FakeWorkspaceState) =>
}
return Effect.succeed({ path, text })
},
changelogStorage: () => Effect.succeed(state.storage ?? 'registry'),
})
113 changes: 113 additions & 0 deletions packages/github-release-engine/tests/release.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
3 changes: 3 additions & 0 deletions packages/release-language/src/Workspace.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ export const Member = S.Struct({
})
export type Member = S.Schema.Type<typeof Member>

export const ChangelogStorage = S.Literals(['registry', 'repository'])
export type ChangelogStorage = S.Schema.Type<typeof ChangelogStorage>

export const RootFile = S.Struct({
path: RelativePath,
text: S.String,
Expand Down
11 changes: 10 additions & 1 deletion packages/release-language/src/WorkspaceStore.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,6 +22,7 @@ export interface WorkspaceStore {
readonly readFileFromRoot: (
path: RelativePath,
) => Effect.Effect<RootFile, MemberRefusal, never>
readonly changelogStorage: () => Effect.Effect<ChangelogStorage, MemberRefusal, never>
}

export const WorkspaceStore: Context.Service<WorkspaceStore, WorkspaceStore> = Context.Service<
Expand Down
1 change: 1 addition & 0 deletions packages/release-language/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export {
VersionUnknownPackage,
} from './Version.schema.js'
export {
ChangelogStorage,
Count,
FsPath,
GitRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
}
42 changes: 35 additions & 7 deletions packages/workspace-adapter/src/WorkspaceStoreLive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type ChangelogStorage,
FsPath,
ManifestInvalid,
ManifestUnreadable,
Expand All @@ -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 })
Expand All @@ -45,14 +48,24 @@ export const WorkspaceStoreLive = (
)
})

const runPnpm = (
args: ReadonlyArray<string>,
): 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<ReadonlyArray<Member>, 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))
}
Expand Down Expand Up @@ -83,6 +96,21 @@ export const WorkspaceStoreLive = (
)
}

return { root, listMembers, readManifest, readFileFromRoot }
const changelogStorage = (): Effect.Effect<ChangelogStorage, MemberRefusal> =>
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 }
}),
)
Loading