diff --git a/__snapshots__/generic.js b/__snapshots__/generic.js new file mode 100644 index 000000000..58cc5a460 --- /dev/null +++ b/__snapshots__/generic.js @@ -0,0 +1,42 @@ +exports['Generic updateContent updates generic version markers 1'] = ` +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.cloud.example; + +public final class Version { + // {x-release-please-start-version} + public static String VERSION = "2.3.4"; + // {x-release-please-end} + + // {x-release-please-start-major} + public static String MAJOR = "2"; + // {x-release-please-end} + + // {x-release-please-start-minor} + public static String MINOR = "3"; + // {x-release-please-end} + + // {x-release-please-start-patch} + public static String PATCH = "4"; + // {x-release-please-end} + + public static String INLINE_VERSION = "2.3.4"; // {x-release-please-version} + public static String INLINE_MAJOR = "2"; // {x-release-please-major} + public static String INLINE_MINOR = "3"; // {x-release-please-minor} + public static String INLINE_PATCH = "4"; // {x-release-please-patch} +} + +` diff --git a/docs/customizing.md b/docs/customizing.md index 9f265b67e..e81c95eb0 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -105,3 +105,32 @@ option or the `label` option in the manifest configuration. You can customize the "tagged" pull request label(s) via the `--release-label` CLI option or the `release-label` option in the manifest configuration. + +## Updating arbitrary files + +For most release strategies, you can provide additional files to update +using the [Generic](src/updaters/generic.ts) updater. You can specify +a comma separated list of file paths with the `--extra-files` CLI option +or the `extra-files` option in the manifest configuration. + +To mark versions needing update in those files, you will add annotations +(usually in comments). + +You can annotate a line (inline) via: + +* `x-release-please-version` +* `x-release-please-major` +* `x-release-please-minor` +* `x-release-please-patch` + +For these annotations, we will try to replace the value on that line only. + +You can annotate a block by starting with a line containing: + +* `x-release-please-start-version` +* `x-release-please-start-major` +* `x-release-please-start-minor` +* `x-release-please-start-patch` + +and close the block with a line containing `x-release-please-end`. Within +the block, we will attempt to replace version values. diff --git a/src/factory.ts b/src/factory.ts index 460d921b2..60021e963 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -77,6 +77,7 @@ type Releasers = Record; const releasers: Releasers = { go: options => new Go(options), 'go-yoshi': options => new GoYoshi(options), + 'java-yoshi': options => new JavaYoshi(options), 'krm-blueprint': options => new KRMBlueprint(options), node: options => new Node(options), ocaml: options => new OCaml(options), @@ -140,6 +141,7 @@ export async function buildStrategy( includeComponentInTag: options.includeComponentInTag, changelogNotes, pullRequestTitlePattern: options.pullRequestTitlePattern, + extraFiles: options.extraFiles, }; switch (options.releaseType) { case 'ruby': { @@ -154,23 +156,15 @@ export async function buildStrategy( versionFile: options.versionFile, }); } - case 'java-yoshi': { - return new JavaYoshi({ - ...strategyOptions, - extraFiles: options.extraFiles, - }); - } case 'java-backport': { return new JavaYoshi({ ...strategyOptions, - extraFiles: options.extraFiles, versioningStrategy: new AlwaysBumpPatch(), }); } case 'java-bom': { return new JavaYoshi({ ...strategyOptions, - extraFiles: options.extraFiles, versioningStrategy: new DependencyManifest({ bumpMinorPreMajor: options.bumpMinorPreMajor, bumpPatchForMinorPreMajor: options.bumpPatchForMinorPreMajor, @@ -180,7 +174,6 @@ export async function buildStrategy( case 'java-lts': { return new JavaYoshi({ ...strategyOptions, - extraFiles: options.extraFiles, versioningStrategy: new ServicePackVersioningStrategy(), }); } diff --git a/src/plugins/merge.ts b/src/plugins/merge.ts index 6507d43c6..a9d2f12ea 100644 --- a/src/plugins/merge.ts +++ b/src/plugins/merge.ts @@ -22,7 +22,7 @@ import {PullRequestTitle} from '../util/pull-request-title'; import {PullRequestBody, ReleaseData} from '../util/pull-request-body'; import {BranchName} from '../util/branch-name'; import {Update} from '../update'; -import {CompositeUpdater} from '../updaters/composite'; +import {mergeUpdates} from '../updaters/composite'; /** * This plugin merges multiple pull requests into a single @@ -38,37 +38,18 @@ export class Merge extends ManifestPlugin { return candidates; } - const updatesByPath: Record = {}; const releaseData: ReleaseData[] = []; const labels = new Set(); + let rawUpdates: Update[] = []; for (const candidate of candidates) { const pullRequest = candidate.pullRequest; - for (const update of pullRequest.updates) { - if (updatesByPath[update.path]) { - updatesByPath[update.path].push(update); - } else { - updatesByPath[update.path] = [update]; - } - } + rawUpdates = rawUpdates.concat(...pullRequest.updates); for (const label of pullRequest.labels) { labels.add(label); } releaseData.push(...pullRequest.body.releaseData); } - - const updates: Update[] = []; - for (const path in updatesByPath) { - const update = updatesByPath[path]; - const updaters = update.map(u => u.updater); - updates.push({ - path, - createIfMissing: update[0].createIfMissing, - updater: - updaters.length === 1 - ? updaters[0] - : new CompositeUpdater(...updaters), - }); - } + const updates = mergeUpdates(rawUpdates); const pullRequest = { title: PullRequestTitle.ofTargetBranch( diff --git a/src/strategies/base.ts b/src/strategies/base.ts index 229260ed6..f3455ba8e 100644 --- a/src/strategies/base.ts +++ b/src/strategies/base.ts @@ -34,6 +34,8 @@ import {PullRequestTitle} from '../util/pull-request-title'; import {BranchName} from '../util/branch-name'; import {PullRequestBody} from '../util/pull-request-body'; import {PullRequest} from '../pull-request'; +import {mergeUpdates} from '../updaters/composite'; +import {Generic} from '../updaters/generic'; const DEFAULT_CHANGELOG_PATH = 'CHANGELOG.md'; @@ -63,6 +65,7 @@ export interface BaseStrategyOptions { changelogNotes?: ChangelogNotes; includeComponentInTag?: boolean; pullRequestTitlePattern?: string; + extraFiles?: string[]; } /** @@ -83,6 +86,7 @@ export abstract class BaseStrategy implements Strategy { private releaseAs?: string; private includeComponentInTag: boolean; private pullRequestTitlePattern?: string; + readonly extraFiles: string[]; readonly changelogNotes: ChangelogNotes; @@ -108,6 +112,7 @@ export abstract class BaseStrategy implements Strategy { options.changelogNotes || new DefaultChangelogNotes(options); this.includeComponentInTag = options.includeComponentInTag ?? true; this.pullRequestTitlePattern = options.pullRequestTitlePattern; + this.extraFiles = options.extraFiles || []; } /** @@ -239,6 +244,9 @@ export abstract class BaseStrategy implements Strategy { versionsMap, latestVersion: latestRelease?.tag.version, }); + const updatesWithExtras = mergeUpdates( + updates.concat(...this.extraFileUpdates(newVersion)) + ); const pullRequestBody = new PullRequestBody([ { component, @@ -250,7 +258,7 @@ export abstract class BaseStrategy implements Strategy { return { title: pullRequestTitle, body: pullRequestBody, - updates, + updates: updatesWithExtras, labels, headRefName: branchName.toString(), version: newVersion, @@ -258,6 +266,17 @@ export abstract class BaseStrategy implements Strategy { }; } + private extraFileUpdates(version: Version): Update[] { + const genericUpdater = new Generic({version}); + return this.extraFiles.map(path => { + return { + path, + createIfMissing: false, + updater: genericUpdater, + }; + }); + } + protected changelogEmpty(changelogEntry: string): boolean { return changelogEntry.split('\n').length <= 1; } diff --git a/src/strategies/java-yoshi.ts b/src/strategies/java-yoshi.ts index 9e7a6afd5..c35e337af 100644 --- a/src/strategies/java-yoshi.ts +++ b/src/strategies/java-yoshi.ts @@ -47,23 +47,17 @@ const CHANGELOG_SECTIONS = [ {type: 'ci', section: 'Continuous Integration', hidden: true}, ]; -interface JavaStrategyOptions extends BaseStrategyOptions { - extraFiles?: string[]; -} - export class JavaYoshi extends BaseStrategy { - readonly extraFiles: string[]; private versionsContent?: GitHubFileContents; private snapshotVersioning: VersioningStrategy; - constructor(options: JavaStrategyOptions) { + constructor(options: BaseStrategyOptions) { options.changelogSections = options.changelogSections ?? CHANGELOG_SECTIONS; // wrap the configured versioning strategy with snapshotting const parentVersioningStrategy = options.versioningStrategy || new DefaultVersioningStrategy(); options.versioningStrategy = new JavaSnapshot(parentVersioningStrategy); super(options); - this.extraFiles = options.extraFiles || []; this.snapshotVersioning = new JavaAddSnapshot(parentVersioningStrategy); } diff --git a/src/updaters/composite.ts b/src/updaters/composite.ts index b3dd02f75..d70eaeca2 100644 --- a/src/updaters/composite.ts +++ b/src/updaters/composite.ts @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Updater} from '../update'; +import {Updater, Update} from '../update'; /** * The CompositeUpdater chains 0...n updaters and updates * the content in order. */ export class CompositeUpdater implements Updater { - updaters: Updater[]; + readonly updaters: Updater[]; /** * Instantiate a new CompositeUpdater * @param {Updater[]} updaters The updaters to chain together @@ -40,3 +40,27 @@ export class CompositeUpdater implements Updater { return content || ''; } } + +export function mergeUpdates(updates: Update[]): Update[] { + const updatesByPath: Record = {}; + for (const update of updates) { + if (updatesByPath[update.path]) { + updatesByPath[update.path].push(update); + } else { + updatesByPath[update.path] = [update]; + } + } + + const newUpdates: Update[] = []; + for (const path in updatesByPath) { + const update = updatesByPath[path]; + const updaters = update.map(u => u.updater); + newUpdates.push({ + path, + createIfMissing: update[0].createIfMissing, + updater: + updaters.length === 1 ? updaters[0] : new CompositeUpdater(...updaters), + }); + } + return newUpdates; +} diff --git a/src/updaters/generic.ts b/src/updaters/generic.ts new file mode 100644 index 000000000..16b4c99f6 --- /dev/null +++ b/src/updaters/generic.ts @@ -0,0 +1,118 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {DefaultUpdater} from './default'; +import {Version} from '../version'; +import {logger} from '../util/logger'; + +const VERSION_REGEX = + /(?\d+)\.(?\d+)\.(?\d+)(-(?[\w.]+))?(\+(?[-\w.]+))?/; +const SINGLE_VERSION_REGEX = /\b\d+\b/; +const INLINE_UPDATE_REGEX = + /x-release-please-(?major|minor|patch|version)/; +const BLOCK_START_REGEX = + /x-release-please-start-(?major|minor|patch|version)/; +const BLOCK_END_REGEX = /x-release-please-end/; + +type BlockScope = 'major' | 'minor' | 'patch' | 'version'; + +/** + * The Generic updater looks for well known patterns and replaces + * content. The well known patterns are: + * + * 1. `x-release-please-version` if this string is found on the line, + * then replace a semver-looking string on that line with the next + * version + * 2. `x-release-please-major` if this string is found on the line, + * then replace an integer looking value with the the next version's + * major + * 3. `x-release-please-minor` if this string is found on the line, + * then replace an integer looking value with the the next version's + * minor + * 4. `x-release-please-patch` if this string is found on the line, + * then replace an integer looking value with the the next version's + * patch + * + * You can also use a block-based replacement. Content between the + * opening `x-release-please-start-version` and `x-release-please-end` will + * be considered for version replacement. You can also open these blocks + * with `x-release-please-start-` to replace single + * numbers + */ +export class Generic extends DefaultUpdater { + /** + * Given initial file contents, return updated contents. + * @param {string} content The initial content + * @returns {string} The updated content + */ + updateContent(content: string | undefined): string { + if (!content) { + return ''; + } + + const newLines: string[] = []; + let blockScope: BlockScope | undefined; + + function replaceVersion(line: string, scope: BlockScope, version: Version) { + switch (scope) { + case 'major': + newLines.push(line.replace(SINGLE_VERSION_REGEX, `${version.major}`)); + return; + case 'minor': + newLines.push(line.replace(SINGLE_VERSION_REGEX, `${version.minor}`)); + return; + case 'patch': + newLines.push(line.replace(SINGLE_VERSION_REGEX, `${version.patch}`)); + return; + case 'version': + newLines.push(line.replace(VERSION_REGEX, version.toString())); + return; + default: + logger.warn(`unknown block scope: ${scope}`); + newLines.push(line); + } + } + + content.split(/\r?\n/).forEach(line => { + let match = line.match(INLINE_UPDATE_REGEX); + if (match) { + // replace inline versions + replaceVersion( + line, + (match.groups?.scope || 'version') as BlockScope, + this.version + ); + } else if (blockScope) { + // in a block, so try to replace versions + replaceVersion(line, blockScope, this.version); + if (line.match(BLOCK_END_REGEX)) { + blockScope = undefined; + } + } else { + // look for block start line + match = line.match(BLOCK_START_REGEX); + if (match) { + if (match.groups?.scope) { + blockScope = match.groups.scope as BlockScope; + } else { + blockScope = 'version'; + } + } + newLines.push(line); + } + }); + + return newLines.join('\n'); + } +} diff --git a/test/factory.ts b/test/factory.ts index 6a3706cbb..0e431c19b 100644 --- a/test/factory.ts +++ b/test/factory.ts @@ -180,6 +180,18 @@ describe('factory', () => { expect(innerVersioningStrategy.bumpMinorPreMajor).to.be.true; expect(innerVersioningStrategy.bumpPatchForMinorPreMajor).to.be.true; }); + it('should handle extra-files', async () => { + const strategy = await buildStrategy({ + github, + releaseType: 'simple', + extraFiles: ['path1/foo1.java', 'path2/foo2.java'], + }); + expect(strategy).instanceof(Simple); + expect((strategy as Simple).extraFiles).to.eql([ + 'path1/foo1.java', + 'path2/foo2.java', + ]); + }); for (const releaseType of getReleaserTypes()) { it(`should build a default ${releaseType}`, async () => { const strategy = await buildStrategy({github, releaseType}); diff --git a/test/strategies/java-yoshi.ts b/test/strategies/java-yoshi.ts index d8fd62bd2..89c347874 100644 --- a/test/strategies/java-yoshi.ts +++ b/test/strategies/java-yoshi.ts @@ -24,6 +24,7 @@ import {Version} from '../../src/version'; import {Changelog} from '../../src/updaters/changelog'; import {JavaUpdate} from '../../src/updaters/java/java-update'; import {VersionsManifest} from '../../src/updaters/java/versions-manifest'; +import {CompositeUpdater} from '../../src/updaters/composite'; const sandbox = sinon.createSandbox(); const fixturesPath = './test/fixtures/strategies/java-yoshi'; @@ -259,8 +260,8 @@ describe('JavaYoshi', () => { ); const updates = release!.updates; assertHasUpdate(updates, 'CHANGELOG.md', Changelog); - assertHasUpdate(updates, 'foo/bar.java', JavaUpdate); - assertHasUpdate(updates, 'src/version.java', JavaUpdate); + assertHasUpdate(updates, 'foo/bar.java', CompositeUpdater); + assertHasUpdate(updates, 'src/version.java', CompositeUpdater); assertHasUpdate(updates, 'versions.txt', VersionsManifest); }); }); diff --git a/test/updaters/composite.ts b/test/updaters/composite.ts index ccfe7c3fa..78e1478de 100644 --- a/test/updaters/composite.ts +++ b/test/updaters/composite.ts @@ -12,11 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {CompositeUpdater} from '../../src/updaters/composite'; +import {CompositeUpdater, mergeUpdates} from '../../src/updaters/composite'; import {JavaUpdate} from '../../src/updaters/java/java-update'; import {Version} from '../../src/version'; import {describe, it} from 'mocha'; import {expect} from 'chai'; +import {Updater} from '../../src/update'; +import {assertHasUpdate} from '../helpers'; + +class FakeUpdater implements Updater { + updateContent(_content: string | undefined): string { + return ''; + } +} describe('CompositeUpdater', () => { describe('updateContent', () => { @@ -44,4 +52,64 @@ describe('CompositeUpdater', () => { ); }); }); + describe('mergeUpdates', () => { + it('can merge multiple updates', () => { + const input = [ + { + path: 'path1', + createIfMissing: false, + updater: new FakeUpdater(), + }, + { + path: 'path2', + createIfMissing: false, + updater: new FakeUpdater(), + }, + { + path: 'path1', + createIfMissing: false, + updater: new FakeUpdater(), + }, + { + path: 'path1', + createIfMissing: false, + updater: new FakeUpdater(), + }, + ]; + const merged = mergeUpdates(input); + expect(merged).lengthOf(2); + assertHasUpdate(merged, 'path1', CompositeUpdater); + assertHasUpdate(merged, 'path2', FakeUpdater); + }); + it('ignores disjoint paths', () => { + const input = [ + { + path: 'path1', + createIfMissing: false, + updater: new FakeUpdater(), + }, + { + path: 'path2', + createIfMissing: false, + updater: new FakeUpdater(), + }, + { + path: 'path3', + createIfMissing: false, + updater: new FakeUpdater(), + }, + { + path: 'path4', + createIfMissing: false, + updater: new FakeUpdater(), + }, + ]; + const merged = mergeUpdates(input); + expect(merged).lengthOf(4); + assertHasUpdate(merged, 'path1', FakeUpdater); + assertHasUpdate(merged, 'path2', FakeUpdater); + assertHasUpdate(merged, 'path3', FakeUpdater); + assertHasUpdate(merged, 'path4', FakeUpdater); + }); + }); }); diff --git a/test/updaters/fixtures/Version.java b/test/updaters/fixtures/Version.java new file mode 100644 index 000000000..ffee3d46f --- /dev/null +++ b/test/updaters/fixtures/Version.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.cloud.example; + +public final class Version { + // {x-release-please-start-version} + public static String VERSION = "1.2.3-SNAPSHOT"; + // {x-release-please-end} + + // {x-release-please-start-major} + public static String MAJOR = "1"; + // {x-release-please-end} + + // {x-release-please-start-minor} + public static String MINOR = "2"; + // {x-release-please-end} + + // {x-release-please-start-patch} + public static String PATCH = "3"; + // {x-release-please-end} + + public static String INLINE_VERSION = "1.2.3-SNAPSHOT"; // {x-release-please-version} + public static String INLINE_MAJOR = "1"; // {x-release-please-major} + public static String INLINE_MINOR = "2"; // {x-release-please-minor} + public static String INLINE_PATCH = "3"; // {x-release-please-patch} +} diff --git a/test/updaters/generic.ts b/test/updaters/generic.ts new file mode 100644 index 000000000..49ba68ce9 --- /dev/null +++ b/test/updaters/generic.ts @@ -0,0 +1,40 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {readFileSync} from 'fs'; +import {resolve} from 'path'; +import * as snapshot from 'snap-shot-it'; +import {describe, it} from 'mocha'; +import {Version} from '../../src/version'; +import {Generic} from '../../src/updaters/generic'; + +const fixturesPath = './test/updaters/fixtures'; + +describe('Generic', () => { + describe('updateContent', () => { + it('updates generic version markers', async () => { + const oldContent = readFileSync( + resolve(fixturesPath, './Version.java'), + 'utf8' + ).replace(/\r\n/g, '\n'); + const versions = new Map(); + const pom = new Generic({ + versionsMap: versions, + version: Version.parse('v2.3.4'), + }); + const newContent = pom.updateContent(oldContent); + snapshot(newContent); + }); + }); +});