From 1a51d30449b27638a3b72239a90379192eb44662 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 7 Oct 2026 00:25:40 +0000 Subject: [PATCH] fix(release): release from the package changelog under repository storage The GitHub Release step built every release body from the parked file /@.md. Under pnpm's versioning.changelog.storage: repository nothing is parked there: pnpm writes each section into the package's own CHANGELOG.md. A consumer on repository storage, stryker-js-effect, would have failed its next release at that step. WorkspaceStore gains changelogStorage(), which asks pnpm (pnpm config get versioning.changelog.storage; unset means registry). Under repository storage the cycle points each entry at /CHANGELOG.md, and the release body is that file's "## " section up to the next "## " heading. A changelog without that section refuses the release with ReleaseChangelogMissing naming the package, version and file, never an empty body. Registry storage keeps the parked path and the whole file as the body --- .../tests/__fixtures__/FakeWorkspaceStore.ts | 1 + packages/github-release-engine/src/cycle.ts | 26 +++- .../src/github-release.ts | 10 +- packages/github-release-engine/src/plan.ts | 3 +- packages/github-release-engine/src/tag.ts | 3 +- .../tests/__fixtures__/FakeWorkspaceStore.ts | 4 +- .../tests/release.integration.test.ts | 113 ++++++++++++++++++ .../release-language/src/Workspace.schema.ts | 3 + .../release-language/src/WorkspaceStore.ts | 11 +- packages/release-language/src/index.ts | 1 + .../tests/__fixtures__/FakeWorkspaceStore.ts | 2 + .../src/WorkspaceStoreLive.ts | 42 +++++-- 12 files changed, 201 insertions(+), 18 deletions(-) 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 } }), )