diff --git a/docs/customizing.md b/docs/customizing.md index 6591848d2..ecc23367e 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -80,6 +80,32 @@ release notes appear. To add a new changelog type, create a new class that implements the [`ChangelogNotes` interface](/src/changelog-notes.ts). +### Custom changelog templates + +If you want to override the Handlebars templates used by the default changelog +renderer, set one or more of these manifest options: + +* `commit-partial` +* `header-partial` +* `main-template` + +Each value should be a path to a template file in the repository. Paths are +resolved relative to the configured package `path` when using manifest-based +releases. + +```json +{ + "packages": { + ".": { + "release-type": "simple", + "main-template": ".release-please/main-template.hbs", + "header-partial": ".release-please/header.hbs", + "commit-partial": ".release-please/commit.hbs" + } + } +} +``` + ## Pull Requests ### Opening as a draft pull request diff --git a/schemas/config.json b/schemas/config.json index 2a751312b..d1070d4c4 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -103,6 +103,18 @@ "description": "Generate changelog links to this GitHub host. Useful for running against GitHub Enterprise.", "type": "string" }, + "commit-partial": { + "description": "Path to a custom changelog commit partial Handlebars template in the repository.", + "type": "string" + }, + "header-partial": { + "description": "Path to a custom changelog header partial Handlebars template in the repository.", + "type": "string" + }, + "main-template": { + "description": "Path to a custom changelog main Handlebars template in the repository.", + "type": "string" + }, "changelog-path": { "description": "Path to the file that tracks release note changes. Defaults to `CHANGELOG.md`.", "type": "string" diff --git a/src/factory.ts b/src/factory.ts index 025cf1e48..796bd59c6 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -66,6 +66,57 @@ export interface StrategyFactoryOptions extends ReleaserConfig { targetBranch?: string; } +function resolveReleaserPath(path: string | undefined, file: string): string { + if (!path || path === '.' || file.startsWith('/')) { + file = file.replace(/^\/+/, ''); + } else { + file = `${path.replace(/\/+$/, '')}/${file}`; + } + if (/((^|\/)\.{1,2}|^~|^\/*)+\//.test(file)) { + throw new Error(`illegal pathing characters in path: ${file}`); + } + return file.replace(/\/+$/, ''); +} + +async function buildTemplateOptions( + options: StrategyFactoryOptions, + targetBranch: string +): Promise< + Pick +> { + const [commitPartial, headerPartial, mainTemplate] = await Promise.all([ + options.commitPartialPath + ? options.github + .getFileContentsOnBranch( + resolveReleaserPath(options.path, options.commitPartialPath), + targetBranch + ) + .then(contents => contents.parsedContent) + : undefined, + options.headerPartialPath + ? options.github + .getFileContentsOnBranch( + resolveReleaserPath(options.path, options.headerPartialPath), + targetBranch + ) + .then(contents => contents.parsedContent) + : undefined, + options.mainTemplatePath + ? options.github + .getFileContentsOnBranch( + resolveReleaserPath(options.path, options.mainTemplatePath), + targetBranch + ) + .then(contents => contents.parsedContent) + : undefined, + ]); + return { + commitPartial, + headerPartial, + mainTemplate, + }; +} + const releasers: Record = { 'dotnet-yoshi': options => new DotnetYoshi(options), go: options => new Go(options), @@ -118,6 +169,7 @@ export async function buildStrategy( ): Promise { const targetBranch = options.targetBranch ?? options.github.repository.defaultBranch; + const templateOptions = await buildTemplateOptions(options, targetBranch); const versioningStrategy = buildVersioningStrategy({ github: options.github, type: options.versioning, @@ -130,11 +182,13 @@ export async function buildStrategy( type: options.changelogType || 'default', github: options.github, changelogSections: options.changelogSections, + ...templateOptions, }); const strategyOptions: BaseStrategyOptions = { skipGitHubRelease: options.skipGithubRelease, // Note the case difference in GitHub skipChangelog: options.skipChangelog, ...options, + ...templateOptions, targetBranch, versioningStrategy, changelogNotes, diff --git a/src/manifest.ts b/src/manifest.ts index f0b46a05b..46929ae9e 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -132,6 +132,9 @@ export interface ReleaserConfig { changelogPath?: string; changelogType?: ChangelogNotesType; changelogHost?: string; + commitPartialPath?: string; + headerPartialPath?: string; + mainTemplatePath?: string; // Ruby-only versionFile?: string; @@ -179,6 +182,9 @@ interface ReleaserConfigJson { 'include-v-in-release-name'?: boolean; 'changelog-type'?: ChangelogNotesType; 'changelog-host'?: string; + 'commit-partial'?: string; + 'header-partial'?: string; + 'main-template'?: string; 'pull-request-title-pattern'?: string; 'pull-request-header'?: string; 'pull-request-footer'?: string; @@ -1395,6 +1401,9 @@ function extractReleaserConfig( changelogSections: config['changelog-sections'], changelogPath: config['changelog-path'], changelogHost: config['changelog-host'], + commitPartialPath: config['commit-partial'], + headerPartialPath: config['header-partial'], + mainTemplatePath: config['main-template'], releaseAs: config['release-as'], skipGithubRelease: config['skip-github-release'], skipChangelog: config['skip-changelog'], diff --git a/src/strategies/ruby-yoshi.ts b/src/strategies/ruby-yoshi.ts index 87d65b2be..c1405d5d1 100644 --- a/src/strategies/ruby-yoshi.ts +++ b/src/strategies/ruby-yoshi.ts @@ -42,24 +42,28 @@ const CHANGELOG_SECTIONS = [ {type: 'ci', section: 'Continuous Integration', hidden: true}, ]; +const DEFAULT_COMMIT_PARTIAL = readFileSync( + resolve(__dirname, '../../../templates/commit.hbs'), + 'utf8' +); +const DEFAULT_HEADER_PARTIAL = readFileSync( + resolve(__dirname, '../../../templates/header.hbs'), + 'utf8' +); +const DEFAULT_MAIN_TEMPLATE = readFileSync( + resolve(__dirname, '../../../templates/template.hbs'), + 'utf8' +); + export class RubyYoshi extends BaseStrategy { readonly versionFile: string; constructor(options: BaseStrategyOptions) { super({ ...options, changelogSections: CHANGELOG_SECTIONS, - commitPartial: readFileSync( - resolve(__dirname, '../../../templates/commit.hbs'), - 'utf8' - ), - headerPartial: readFileSync( - resolve(__dirname, '../../../templates/header.hbs'), - 'utf8' - ), - mainTemplate: readFileSync( - resolve(__dirname, '../../../templates/template.hbs'), - 'utf8' - ), + commitPartial: options.commitPartial ?? DEFAULT_COMMIT_PARTIAL, + headerPartial: options.headerPartial ?? DEFAULT_HEADER_PARTIAL, + mainTemplate: options.mainTemplate ?? DEFAULT_MAIN_TEMPLATE, tagSeparator: '/', }); this.versionFile = options.versionFile ?? ''; diff --git a/test/factory.ts b/test/factory.ts index 1de07c1e4..615a09f69 100644 --- a/test/factory.ts +++ b/test/factory.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {beforeEach, describe, it} from 'mocha'; +import {afterEach, beforeEach, describe, it} from 'mocha'; import { buildStrategy, getReleaserTypes, @@ -33,9 +33,13 @@ import {GitHubChangelogNotes} from '../src/changelog-notes/github'; import {DefaultChangelogNotes} from '../src/changelog-notes/default'; import {Java} from '../src/strategies/java'; import {PrereleaseVersioningStrategy} from '../src/versioning-strategies/prerelease'; +import * as sinon from 'sinon'; +import {buildGitHubFileRaw, buildMockCommit} from './helpers'; +import {parseConventionalCommits} from '../src/commit'; describe('factory', () => { let github: GitHub; + const sandbox = sinon.createSandbox(); beforeEach(async () => { github = await GitHub.create({ owner: 'fake-owner', @@ -44,6 +48,9 @@ describe('factory', () => { token: 'fake-token', }); }); + afterEach(() => { + sandbox.restore(); + }); describe('buildStrategy', () => { it('should build a basic strategy', async () => { const strategy = await buildStrategy({ @@ -135,6 +142,41 @@ describe('factory', () => { expect(strategy).instanceof(Simple); expect(strategy.changelogNotes).instanceof(GitHubChangelogNotes); }); + it('should build with custom changelog templates from the repository', async () => { + sandbox + .stub(github, 'getFileContentsOnBranch') + .withArgs('.release-please/commit.hbs', 'main') + .resolves(buildGitHubFileRaw('* {{subject}}')) + .withArgs('.release-please/header.hbs', 'main') + .resolves(buildGitHubFileRaw('## {{version}}')) + .withArgs('.release-please/template.hbs', 'main') + .resolves( + buildGitHubFileRaw( + '{{> header}}\n{{#each commitGroups}}\n{{#each commits}}\n{{> commit root=@root}}\n{{/each}}\n{{/each}}' + ) + ); + const strategy = await buildStrategy({ + github, + releaseType: 'simple', + commitPartialPath: '.release-please/commit.hbs', + headerPartialPath: '.release-please/header.hbs', + mainTemplatePath: '.release-please/template.hbs', + }); + expect(strategy).instanceof(Simple); + expect(strategy.changelogNotes).instanceof(DefaultChangelogNotes); + const notes = await strategy.changelogNotes.buildNotes( + parseConventionalCommits([buildMockCommit('fix: some bugfix')]), + { + owner: 'fake-owner', + repository: 'fake-repo', + version: '1.2.3', + previousTag: 'v1.2.2', + currentTag: 'v1.2.3', + targetBranch: 'main', + } + ); + expect(notes).to.eql('## 1.2.3* some bugfix'); + }); it('should build a ruby strategy', async () => { const strategy = await buildStrategy({ github, diff --git a/test/strategies/ruby-yoshi.ts b/test/strategies/ruby-yoshi.ts index 691adfd01..d874f6a85 100644 --- a/test/strategies/ruby-yoshi.ts +++ b/test/strategies/ruby-yoshi.ts @@ -84,6 +84,30 @@ describe('RubyYoshi', () => { expect(pullRequest!.version?.toString()).to.eql(expectedVersion); safeSnapshot(pullRequest!.body.toString()); }); + it('uses custom changelog templates when configured', async () => { + const strategy = new RubyYoshi({ + targetBranch: 'main', + github, + component: 'google-cloud-automl', + commitPartial: '* {{subject}}\n', + headerPartial: '## {{version}}\n', + mainTemplate: + '{{> header}}{{#each commitGroups}}{{#each commits}}{{> commit root=@root}}{{/each}}{{/each}}', + }); + const latestRelease = { + tag: new TagName(Version.parse('0.123.4'), 'google-cloud-automl'), + sha: 'abc123', + notes: 'some notes', + }; + const pullRequest = await strategy.buildReleasePullRequest( + COMMITS, + latestRelease + ); + expect(pullRequest!.body.toString()).to.contain('## 0.123.5'); + expect(pullRequest!.body.toString()).to.contain( + '* update dependency com.google.cloud:google-cloud-storage to v1.120.0' + ); + }); }); describe('buildUpdates', () => { it('builds common files', async () => {