UNPKG

release-please

Version:

generate release PRs based on the conventionalcommits.org spec

985 lines 58.8 kB
"use strict"; // 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. Object.defineProperty(exports, "__esModule", { value: true }); exports.Manifest = exports.MANIFEST_PULL_REQUEST_TITLE_PATTERN = exports.SNOOZE_LABEL = exports.DEFAULT_SNAPSHOT_LABELS = exports.DEFAULT_RELEASE_LABELS = exports.DEFAULT_LABELS = exports.DEFAULT_COMPONENT_NAME = exports.ROOT_PROJECT_PATH = exports.DEFAULT_RELEASE_PLEASE_MANIFEST = exports.DEFAULT_RELEASE_PLEASE_CONFIG = void 0; const version_1 = require("./version"); const commit_1 = require("./commit"); const logger_1 = require("./util/logger"); const commit_split_1 = require("./util/commit-split"); const tag_name_1 = require("./util/tag-name"); const branch_name_1 = require("./util/branch-name"); const pull_request_title_1 = require("./util/pull-request-title"); const factory_1 = require("./factory"); const merge_1 = require("./plugins/merge"); const release_please_manifest_1 = require("./updaters/release-please-manifest"); const errors_1 = require("./errors"); const pull_request_overflow_handler_1 = require("./util/pull-request-overflow-handler"); const signoff_commit_message_1 = require("./util/signoff-commit-message"); const commit_exclude_1 = require("./util/commit-exclude"); exports.DEFAULT_RELEASE_PLEASE_CONFIG = 'release-please-config.json'; exports.DEFAULT_RELEASE_PLEASE_MANIFEST = '.release-please-manifest.json'; exports.ROOT_PROJECT_PATH = '.'; exports.DEFAULT_COMPONENT_NAME = ''; exports.DEFAULT_LABELS = ['autorelease: pending']; exports.DEFAULT_RELEASE_LABELS = ['autorelease: tagged']; exports.DEFAULT_SNAPSHOT_LABELS = ['autorelease: snapshot']; exports.SNOOZE_LABEL = 'autorelease: snooze'; const DEFAULT_RELEASE_SEARCH_DEPTH = 400; const DEFAULT_COMMIT_SEARCH_DEPTH = 500; exports.MANIFEST_PULL_REQUEST_TITLE_PATTERN = 'chore: release ${branch}'; class Manifest { /** * Create a Manifest from explicit config in code. This assumes that the * repository has a single component at the root path. * * @param {GitHub} github GitHub client * @param {string} targetBranch The releaseable base branch * @param {RepositoryConfig} repositoryConfig Parsed configuration of path => release configuration * @param {ReleasedVersions} releasedVersions Parsed versions of path => latest release version * @param {ManifestOptions} manifestOptions Optional. Manifest options * @param {string} manifestOptions.bootstrapSha If provided, use this SHA * as the point to consider commits after * @param {boolean} manifestOptions.alwaysLinkLocal Option for the node-workspace * plugin * @param {boolean} manifestOptions.updatePeerDependencies Option for the node-workspace * plugin * @param {boolean} manifestOptions.separatePullRequests If true, create separate pull * requests instead of a single manifest release pull request * @param {boolean} manifestOptions.alwaysUpdate If true, always updates pull requests instead of * only when the release notes change * @param {PluginType[]} manifestOptions.plugins Any plugins to use for this repository * @param {boolean} manifestOptions.fork If true, create pull requests from a fork. Defaults * to `false` * @param {string} manifestOptions.signoff Add a Signed-off-by annotation to the commit * @param {string} manifestOptions.manifestPath Path to the versions manifest * @param {string[]} manifestOptions.labels Labels that denote a pending, untagged release * pull request. Defaults to `[autorelease: pending]` * @param {string[]} manifestOptions.releaseLabels Labels to apply to a tagged release * pull request. Defaults to `[autorelease: tagged]` */ constructor(github, targetBranch, repositoryConfig, releasedVersions, manifestOptions) { var _a, _b; this.repository = github.repository; this.github = github; this.targetBranch = targetBranch; this.repositoryConfig = repositoryConfig; this.releasedVersions = releasedVersions; this.manifestPath = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.manifestPath) || exports.DEFAULT_RELEASE_PLEASE_MANIFEST; this.separatePullRequests = (_a = manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.separatePullRequests) !== null && _a !== void 0 ? _a : Object.keys(repositoryConfig).length === 1; this.alwaysUpdate = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.alwaysUpdate) || false; this.fork = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.fork) || false; this.signoffUser = manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.signoff; this.releaseLabels = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.releaseLabels) || exports.DEFAULT_RELEASE_LABELS; this.labels = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.labels) || exports.DEFAULT_LABELS; this.skipLabeling = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.skipLabeling) || false; this.sequentialCalls = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.sequentialCalls) || false; this.snapshotLabels = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.snapshotLabels) || exports.DEFAULT_SNAPSHOT_LABELS; this.bootstrapSha = manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.bootstrapSha; this.lastReleaseSha = manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.lastReleaseSha; this.draft = manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.draft; this.draftPullRequest = manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.draftPullRequest; this.groupPullRequestTitlePattern = manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.groupPullRequestTitlePattern; this.releaseSearchDepth = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.releaseSearchDepth) || DEFAULT_RELEASE_SEARCH_DEPTH; this.commitSearchDepth = (manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.commitSearchDepth) || DEFAULT_COMMIT_SEARCH_DEPTH; this.logger = (_b = manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.logger) !== null && _b !== void 0 ? _b : logger_1.logger; this.plugins = ((manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.plugins) || []).map(pluginType => (0, factory_1.buildPlugin)({ type: pluginType, github: this.github, targetBranch: this.targetBranch, repositoryConfig: this.repositoryConfig, manifestPath: this.manifestPath, separatePullRequests: this.separatePullRequests, })); this.pullRequestOverflowHandler = new pull_request_overflow_handler_1.FilePullRequestOverflowHandler(this.github, this.logger); } /** * Create a Manifest from config files in the repository. * * @param {GitHub} github GitHub client * @param {string} targetBranch The releaseable base branch * @param {string} configFile Optional. The path to the manifest config file * @param {string} manifestFile Optional. The path to the manifest versions file * @param {string} path The single path to check. Optional * @returns {Manifest} */ static async fromManifest(github, targetBranch, configFile = exports.DEFAULT_RELEASE_PLEASE_CONFIG, manifestFile = exports.DEFAULT_RELEASE_PLEASE_MANIFEST, manifestOptionOverrides = {}, path, releaseAs) { const [{ config: repositoryConfig, options: manifestOptions }, releasedVersions,] = await Promise.all([ parseConfig(github, configFile, targetBranch, path, releaseAs), parseReleasedVersions(github, manifestFile, targetBranch), ]); return new Manifest(github, targetBranch, repositoryConfig, releasedVersions, { manifestPath: manifestFile, ...manifestOptions, ...manifestOptionOverrides, }); } /** * Create a Manifest from explicit config in code. This assumes that the * repository has a single component at the root path. * * @param {GitHub} github GitHub client * @param {string} targetBranch The releaseable base branch * @param {ReleaserConfig} config Release strategy options * @param {ManifestOptions} manifestOptions Optional. Manifest options * @param {string} manifestOptions.bootstrapSha If provided, use this SHA * as the point to consider commits after * @param {boolean} manifestOptions.alwaysLinkLocal Option for the node-workspace * plugin * @param {boolean} manifestOptions.updatePeerDependencies Option for the node-workspace * plugin * @param {boolean} manifestOptions.separatePullRequests If true, create separate pull * requests instead of a single manifest release pull request * @param {PluginType[]} manifestOptions.plugins Any plugins to use for this repository * @param {boolean} manifestOptions.fork If true, create pull requests from a fork. Defaults * to `false` * @param {string} manifestOptions.signoff Add a Signed-off-by annotation to the commit * @param {string} manifestOptions.manifestPath Path to the versions manifest * @param {string[]} manifestOptions.labels Labels that denote a pending, untagged release * pull request. Defaults to `[autorelease: pending]` * @param {string[]} manifestOptions.releaseLabels Labels to apply to a tagged release * pull request. Defaults to `[autorelease: tagged]` * @returns {Manifest} */ static async fromConfig(github, targetBranch, config, manifestOptions, path = exports.ROOT_PROJECT_PATH) { const repositoryConfig = {}; repositoryConfig[path] = config; const strategy = await (0, factory_1.buildStrategy)({ github, ...config, }); const component = await strategy.getBranchComponent(); const releasedVersions = {}; const latestVersion = await latestReleaseVersion(github, targetBranch, version => isPublishedVersion(strategy, version), config, component, manifestOptions === null || manifestOptions === void 0 ? void 0 : manifestOptions.logger); if (latestVersion) { releasedVersions[path] = latestVersion; } return new Manifest(github, targetBranch, repositoryConfig, releasedVersions, { separatePullRequests: true, ...manifestOptions, }); } /** * Build all candidate pull requests for this repository. * * Iterates through each path and builds a candidate pull request for component. * Applies any configured plugins. * * @returns {ReleasePullRequest[]} The candidate pull requests to open or update. */ async buildPullRequests() { var _a; this.logger.info('Building pull requests'); const pathsByComponent = await this.getPathsByComponent(); const strategiesByPath = await this.getStrategiesByPath(); // Collect all the SHAs of the latest release packages this.logger.info('Collecting release commit SHAs'); let releasesFound = 0; const expectedReleases = Object.keys(strategiesByPath).length; // SHAs by path const releaseShasByPath = {}; // Releases by path const releasesByPath = {}; this.logger.debug(`release search depth: ${this.releaseSearchDepth}`); for await (const release of this.github.releaseIterator({ maxResults: this.releaseSearchDepth, })) { const tagName = tag_name_1.TagName.parse(release.tagName); if (!tagName) { this.logger.warn(`Unable to parse release name: ${release.name}`); continue; } const component = tagName.component || exports.DEFAULT_COMPONENT_NAME; const path = pathsByComponent[component]; if (!path) { this.logger.warn(`Found release tag with component '${component}', but not configured in manifest`); continue; } const expectedVersion = this.releasedVersions[path]; if (!expectedVersion) { this.logger.warn(`Unable to find expected version for path '${path}' in manifest`); continue; } if (expectedVersion.toString() === tagName.version.toString()) { this.logger.debug(`Found release for path ${path}, ${release.tagName}`); releaseShasByPath[path] = release.sha; releasesByPath[path] = { name: release.name, tag: tagName, sha: release.sha, notes: release.notes || '', }; releasesFound += 1; } if (releasesFound >= expectedReleases) { break; } } if (releasesFound < expectedReleases) { this.logger.warn(`Expected ${expectedReleases} releases, only found ${releasesFound}`); // Fall back to looking for missing releases using expected tags const missingPaths = Object.keys(strategiesByPath).filter(path => !releasesByPath[path]); this.logger.warn(`Missing ${missingPaths.length} paths: ${missingPaths}`); const missingReleases = await this.backfillReleasesFromTags(missingPaths, strategiesByPath); for (const path in missingReleases) { releaseShasByPath[path] = missingReleases[path].sha; releasesByPath[path] = missingReleases[path]; releasesFound++; } } const needsBootstrap = releasesFound < expectedReleases; if (releasesFound < expectedReleases) { this.logger.warn(`Expected ${expectedReleases} releases, only found ${releasesFound}`); } for (const path in releasesByPath) { const release = releasesByPath[path]; this.logger.debug(`release for path: ${path}, version: ${release.tag.version.toString()}, sha: ${release.sha}`); } // iterate through commits and collect commits until we have // seen all release commits this.logger.info('Collecting commits since all latest releases'); const commits = []; this.logger.debug(`commit search depth: ${this.commitSearchDepth}`); const commitGenerator = this.github.mergeCommitIterator(this.targetBranch, { maxResults: this.commitSearchDepth, backfillFiles: true, }); const releaseShas = new Set(Object.values(releaseShasByPath)); this.logger.debug(releaseShas); const expectedShas = releaseShas.size; // sha => release pull request const releasePullRequestsBySha = {}; let releaseCommitsFound = 0; for await (const commit of commitGenerator) { if (releaseShas.has(commit.sha)) { if (commit.pullRequest) { releasePullRequestsBySha[commit.sha] = commit.pullRequest; } else { this.logger.warn(`Release SHA ${commit.sha} did not have an associated pull request`); } releaseCommitsFound += 1; } if (this.lastReleaseSha && this.lastReleaseSha === commit.sha) { this.logger.info(`Using configured lastReleaseSha ${this.lastReleaseSha} as last commit.`); break; } else if (needsBootstrap && commit.sha === this.bootstrapSha) { this.logger.info(`Needed bootstrapping, found configured bootstrapSha ${this.bootstrapSha}`); break; } else if (!needsBootstrap && releaseCommitsFound >= expectedShas) { // found enough commits break; } commits.push({ sha: commit.sha, message: commit.message, files: commit.files, pullRequest: commit.pullRequest, }); } if (releaseCommitsFound < expectedShas) { this.logger.warn(`Expected ${expectedShas} commits, only found ${releaseCommitsFound}`); } // split commits by path this.logger.info(`Splitting ${commits.length} commits by path`); const cs = new commit_split_1.CommitSplit({ includeEmpty: true, packagePaths: Object.keys(this.repositoryConfig), }); const splitCommits = cs.split(commits); // limit paths to ones since the last release let commitsPerPath = {}; for (const path in this.repositoryConfig) { commitsPerPath[path] = commitsAfterSha(path === exports.ROOT_PROJECT_PATH ? commits : splitCommits[path], releaseShasByPath[path]); } const commitExclude = new commit_exclude_1.CommitExclude(this.repositoryConfig); commitsPerPath = commitExclude.excludeCommits(commitsPerPath); // backfill latest release tags from manifest for (const path in this.repositoryConfig) { const latestRelease = releasesByPath[path]; if (!latestRelease && this.releasedVersions[path] && this.releasedVersions[path].toString() !== '0.0.0') { const version = this.releasedVersions[path]; const strategy = strategiesByPath[path]; const component = await strategy.getComponent(); this.logger.info(`No latest release found for path: ${path}, component: ${component}, but a previous version (${version.toString()}) was specified in the manifest.`); releasesByPath[path] = { tag: new tag_name_1.TagName(version, component, this.repositoryConfig[path].tagSeparator, this.repositoryConfig[path].includeVInTag), sha: '', notes: '', }; } } let strategies = strategiesByPath; for (const plugin of this.plugins) { strategies = await plugin.preconfigure(strategies, commitsPerPath, releasesByPath); } let newReleasePullRequests = []; for (const path in this.repositoryConfig) { const config = this.repositoryConfig[path]; this.logger.info(`Building candidate release pull request for path: ${path}`); this.logger.debug(`type: ${config.releaseType}`); this.logger.debug(`targetBranch: ${this.targetBranch}`); let pathCommits = (0, commit_1.parseConventionalCommits)(commitsPerPath[path], this.logger); // The processCommits hook can be implemented by plugins to // post-process commits. This can be used to perform cleanup, e.g,, sentence // casing all commit messages: for (const plugin of this.plugins) { pathCommits = plugin.processCommits(pathCommits); } this.logger.debug(`commits: ${pathCommits.length}`); const latestReleasePullRequest = releasePullRequestsBySha[releaseShasByPath[path]]; if (!latestReleasePullRequest) { this.logger.warn('No latest release pull request found.'); } const strategy = strategies[path]; const latestRelease = releasesByPath[path]; const releasePullRequest = await strategy.buildReleasePullRequest(pathCommits, latestRelease, (_a = config.draftPullRequest) !== null && _a !== void 0 ? _a : this.draftPullRequest, this.labels); if (releasePullRequest) { // Update manifest, but only for valid release version - this will skip SNAPSHOT from java strategy if (releasePullRequest.version && isPublishedVersion(strategy, releasePullRequest.version)) { const versionsMap = new Map(); versionsMap.set(path, releasePullRequest.version); releasePullRequest.updates.push({ path: this.manifestPath, createIfMissing: false, updater: new release_please_manifest_1.ReleasePleaseManifest({ version: releasePullRequest.version, versionsMap, }), }); } newReleasePullRequests.push({ path, config, pullRequest: releasePullRequest, }); } } // Combine pull requests into 1 unless configured for separate // pull requests if (!this.separatePullRequests) { const mergeOptions = { pullRequestTitlePattern: this.groupPullRequestTitlePattern, }; // Find the first repositoryConfig item that has a set value // for the options that can be passed to the merge plugin for (const path in this.repositoryConfig) { const config = this.repositoryConfig[path]; if ('pullRequestHeader' in config && !('pullRequestHeader' in mergeOptions)) { mergeOptions.pullRequestHeader = config.pullRequestHeader; } if ('pullRequestFooter' in config && !('pullRequestFooter' in mergeOptions)) { mergeOptions.pullRequestFooter = config.pullRequestFooter; } if ('componentNoSpace' in config && !('componentNoSpace' in mergeOptions)) { mergeOptions.componentNoSpace = config.componentNoSpace; } } this.plugins.push(new merge_1.Merge(this.github, this.targetBranch, this.repositoryConfig, mergeOptions)); } for (const plugin of this.plugins) { this.logger.debug(`running plugin: ${plugin.constructor.name}`); newReleasePullRequests = await plugin.run(newReleasePullRequests); } return newReleasePullRequests.map(pullRequestWithConfig => pullRequestWithConfig.pullRequest); } async backfillReleasesFromTags(missingPaths, strategiesByPath) { const releasesByPath = {}; const allTags = await this.getAllTags(); for (const path of missingPaths) { const expectedVersion = this.releasedVersions[path]; if (!expectedVersion) { this.logger.warn(`No version for path ${path}`); continue; } const component = await strategiesByPath[path].getComponent(); const expectedTag = new tag_name_1.TagName(expectedVersion, component, this.repositoryConfig[path].tagSeparator, this.repositoryConfig[path].includeVInTag); this.logger.debug(`looking for tagName: ${expectedTag.toString()}`); const foundTag = allTags[expectedTag.toString()]; if (foundTag) { this.logger.debug(`found: ${foundTag.name} ${foundTag.sha}`); releasesByPath[path] = { name: foundTag.name, tag: expectedTag, sha: foundTag.sha, notes: '', }; } else { if (strategiesByPath[exports.ROOT_PROJECT_PATH] && this.repositoryConfig[path].skipGithubRelease) { this.logger.debug('could not find release, checking root package'); const rootComponent = await strategiesByPath[exports.ROOT_PROJECT_PATH].getComponent(); const rootTag = new tag_name_1.TagName(expectedVersion, rootComponent, this.repositoryConfig[exports.ROOT_PROJECT_PATH].tagSeparator, this.repositoryConfig[exports.ROOT_PROJECT_PATH].includeVInTag); const foundTag = allTags[rootTag.toString()]; if (foundTag) { this.logger.debug(`found rootTag: ${foundTag.name} ${foundTag.sha}`); releasesByPath[path] = { name: foundTag.name, tag: rootTag, sha: foundTag.sha, notes: '', }; } } } } return releasesByPath; } async getAllTags() { const allTags = {}; for await (const tag of this.github.tagIterator()) { allTags[tag.name] = tag; } return allTags; } /** * Opens/updates all candidate release pull requests for this repository. * * @returns {PullRequest[]} Pull request numbers of release pull requests */ async createPullRequests() { const candidatePullRequests = await this.buildPullRequests(); if (candidatePullRequests.length === 0) { return []; } // if there are any merged, pending release pull requests, don't open // any new release PRs const mergedPullRequestsGenerator = this.findMergedReleasePullRequests(); for await (const _ of mergedPullRequestsGenerator) { this.logger.warn('There are untagged, merged release PRs outstanding - aborting'); return []; } // collect open and snoozed release pull requests const openPullRequests = await this.findOpenReleasePullRequests(); const snoozedPullRequests = await this.findSnoozedReleasePullRequests(); if (this.sequentialCalls) { const pullRequests = []; for (const pullRequest of candidatePullRequests) { const resultPullRequest = await this.createOrUpdatePullRequest(pullRequest, openPullRequests, snoozedPullRequests); if (resultPullRequest) pullRequests.push(resultPullRequest); } return pullRequests; } else { const promises = []; for (const pullRequest of candidatePullRequests) { promises.push(this.createOrUpdatePullRequest(pullRequest, openPullRequests, snoozedPullRequests)); } const pullNumbers = await Promise.all(promises); // reject any pull numbers that were not created or updated return pullNumbers.filter(number => !!number); } } async findOpenReleasePullRequests() { this.logger.info('Looking for open release pull requests'); const openPullRequests = []; const generator = this.github.pullRequestIterator(this.targetBranch, 'OPEN', Number.MAX_SAFE_INTEGER, false); for await (const openPullRequest of generator) { if (hasAllLabels(this.labels, openPullRequest.labels) || hasAllLabels(this.snapshotLabels, openPullRequest.labels)) { const body = await this.pullRequestOverflowHandler.parseOverflow(openPullRequest); if (body) { // maybe replace with overflow body openPullRequests.push({ ...openPullRequest, body: body.toString(), }); } } } this.logger.info(`found ${openPullRequests.length} open release pull requests.`); return openPullRequests; } async findSnoozedReleasePullRequests() { this.logger.info('Looking for snoozed release pull requests'); const snoozedPullRequests = []; const closedGenerator = this.github.pullRequestIterator(this.targetBranch, 'CLOSED', 200, false); for await (const closedPullRequest of closedGenerator) { if (hasAllLabels([exports.SNOOZE_LABEL], closedPullRequest.labels) && branch_name_1.BranchName.parse(closedPullRequest.headBranchName, this.logger)) { const body = await this.pullRequestOverflowHandler.parseOverflow(closedPullRequest); if (body) { // maybe replace with overflow body snoozedPullRequests.push({ ...closedPullRequest, body: body.toString(), }); } } } this.logger.info(`found ${snoozedPullRequests.length} snoozed release pull requests.`); return snoozedPullRequests; } async createOrUpdatePullRequest(pullRequest, openPullRequests, snoozedPullRequests) { // look for existing, open pull request const existing = openPullRequests.find(openPullRequest => openPullRequest.headBranchName === pullRequest.headRefName); if (existing) { return this.alwaysUpdate ? await this.updateExistingPullRequest(existing, pullRequest) : await this.maybeUpdateExistingPullRequest(existing, pullRequest); } // look for closed, snoozed pull request const snoozed = snoozedPullRequests.find(openPullRequest => openPullRequest.headBranchName === pullRequest.headRefName); if (snoozed) { return this.alwaysUpdate ? await this.updateExistingPullRequest(snoozed, pullRequest) : await this.maybeUpdateSnoozedPullRequest(snoozed, pullRequest); } const body = await this.pullRequestOverflowHandler.handleOverflow(pullRequest); const message = this.signoffUser ? (0, signoff_commit_message_1.signoffCommitMessage)(pullRequest.title.toString(), this.signoffUser) : pullRequest.title.toString(); const newPullRequest = await this.github.createPullRequest({ headBranchName: pullRequest.headRefName, baseBranchName: this.targetBranch, number: -1, title: pullRequest.title.toString(), body, labels: this.skipLabeling ? [] : pullRequest.labels, files: [], }, this.targetBranch, message, pullRequest.updates, { fork: this.fork, draft: pullRequest.draft, }); return newPullRequest; } /// only update an existing pull request if it has release note changes async maybeUpdateExistingPullRequest(existing, pullRequest) { // If unchanged, no need to push updates if (existing.body === pullRequest.body.toString()) { this.logger.info(`PR https://github.com/${this.repository.owner}/${this.repository.repo}/pull/${existing.number} remained the same`); return undefined; } return await this.updateExistingPullRequest(existing, pullRequest); } /// only update a snoozed pull request if it has release note changes async maybeUpdateSnoozedPullRequest(snoozed, pullRequest) { // If unchanged, no need to push updates if (snoozed.body === pullRequest.body.toString()) { this.logger.info(`PR https://github.com/${this.repository.owner}/${this.repository.repo}/pull/${snoozed.number} remained the same`); return undefined; } const updatedPullRequest = await this.updateExistingPullRequest(snoozed, pullRequest); // TODO: consider leaving the snooze label await this.github.removeIssueLabels([exports.SNOOZE_LABEL], snoozed.number); return updatedPullRequest; } /// force an update to an existing pull request async updateExistingPullRequest(existing, pullRequest) { return await this.github.updatePullRequest(existing.number, pullRequest, this.targetBranch, { fork: this.fork, signoffUser: this.signoffUser, pullRequestOverflowHandler: this.pullRequestOverflowHandler, }); } async *findMergedReleasePullRequests() { // Find merged release pull requests const pullRequestGenerator = this.github.pullRequestIterator(this.targetBranch, 'MERGED', 200, false); for await (const pullRequest of pullRequestGenerator) { if (!hasAllLabels(this.labels, pullRequest.labels)) { continue; } this.logger.debug(`Found pull request #${pullRequest.number}: '${pullRequest.title}'`); // if the pull request body overflows, handle it const pullRequestBody = await this.pullRequestOverflowHandler.parseOverflow(pullRequest); if (!pullRequestBody) { this.logger.debug('could not parse pull request body as a release PR'); continue; } // replace with the complete fetched body yield { ...pullRequest, body: pullRequestBody.toString(), }; } } /** * Find merged, untagged releases and build candidate releases to tag. * * @returns {CandidateRelease[]} List of release candidates */ async buildReleases() { var _a; this.logger.info('Building releases'); const strategiesByPath = await this.getStrategiesByPath(); // Find merged release pull requests const generator = await this.findMergedReleasePullRequests(); const candidateReleases = []; for await (const pullRequest of generator) { for (const path in this.repositoryConfig) { const config = this.repositoryConfig[path]; this.logger.info(`Building release for path: ${path}`); this.logger.debug(`type: ${config.releaseType}`); this.logger.debug(`targetBranch: ${this.targetBranch}`); const strategy = strategiesByPath[path]; const releases = await strategy.buildReleases(pullRequest, { groupPullRequestTitlePattern: this.groupPullRequestTitlePattern, }); for (const release of releases) { candidateReleases.push({ ...release, path, pullRequest, draft: (_a = config.draft) !== null && _a !== void 0 ? _a : this.draft, prerelease: config.prerelease && (!!release.tag.version.preRelease || release.tag.version.major === 0), }); } } } return candidateReleases; } /** * Find merged, untagged releases. For each release, create a GitHub release, * comment on the pull request used to generated it and update the pull request * labels. * * @returns {GitHubRelease[]} List of created GitHub releases */ async createReleases() { const releasesByPullRequest = {}; const pullRequestsByNumber = {}; for (const release of await this.buildReleases()) { pullRequestsByNumber[release.pullRequest.number] = release.pullRequest; if (releasesByPullRequest[release.pullRequest.number]) { releasesByPullRequest[release.pullRequest.number].push(release); } else { releasesByPullRequest[release.pullRequest.number] = [release]; } } if (this.sequentialCalls) { const resultReleases = []; for (const pullNumber in releasesByPullRequest) { const releases = await this.createReleasesForPullRequest(releasesByPullRequest[pullNumber], pullRequestsByNumber[pullNumber]); resultReleases.push(...releases); } return resultReleases; } else { const promises = []; for (const pullNumber in releasesByPullRequest) { promises.push(this.createReleasesForPullRequest(releasesByPullRequest[pullNumber], pullRequestsByNumber[pullNumber])); } const releases = await Promise.all(promises); return releases.reduce((collection, r) => collection.concat(r), []); } } async createReleasesForPullRequest(releases, pullRequest) { this.logger.info(`Creating ${releases.length} releases for pull #${pullRequest.number}`); const duplicateReleases = []; const githubReleases = []; let error; for (const release of releases) { // stop releasing once we hit an error if (error) continue; try { githubReleases.push(await this.createRelease(release, pullRequest)); } catch (err) { if (err instanceof errors_1.DuplicateReleaseError) { this.logger.warn(`Duplicate release tag: ${release.tag.toString()}`); duplicateReleases.push(err); } else { error = err; } } } if (githubReleases.length > 0) { // comment on pull request about the successful releases const releaseList = githubReleases .map(({ tagName, url }) => `- [${tagName}](${url})`) .join('\n'); const comment = `🤖 Created releases:\n\n${releaseList}\n\n:sunflower:`; await this.github.commentOnIssue(comment, pullRequest.number); } if (error) { throw error; } if (duplicateReleases.length > 0) { if (duplicateReleases.length + githubReleases.length === releases.length) { // we've either tagged all releases or they were duplicates: // adjust tags on pullRequest await this.github.removeIssueLabels(this.labels, pullRequest.number); await this.github.addIssueLabels(this.releaseLabels, pullRequest.number); } if (githubReleases.length === 0) { // If all releases were duplicate, throw a duplicate error throw duplicateReleases[0]; } } else { // adjust tags on pullRequest await this.github.removeIssueLabels(this.labels, pullRequest.number); await this.github.addIssueLabels(this.releaseLabels, pullRequest.number); } return githubReleases; } async createRelease(release, pullRequest) { const githubRelease = await this.github.createRelease(release, { draft: release.draft, prerelease: release.prerelease, }); return { ...githubRelease, path: release.path, version: release.tag.version.toString(), major: release.tag.version.major, minor: release.tag.version.minor, patch: release.tag.version.patch, prNumber: pullRequest.number, }; } async getStrategiesByPath() { if (!this._strategiesByPath) { this.logger.info('Building strategies by path'); this._strategiesByPath = {}; for (const path in this.repositoryConfig) { const config = this.repositoryConfig[path]; this.logger.debug(`${path}: ${config.releaseType}`); const strategy = await (0, factory_1.buildStrategy)({ ...config, github: this.github, path, targetBranch: this.targetBranch, }); this._strategiesByPath[path] = strategy; } } return this._strategiesByPath; } async getPathsByComponent() { if (!this._pathsByComponent) { this._pathsByComponent = {}; const strategiesByPath = await this.getStrategiesByPath(); for (const path in this.repositoryConfig) { const strategy = strategiesByPath[path]; const component = (await strategy.getComponent()) || ''; if (this._pathsByComponent[component]) { this.logger.warn(`Multiple paths for ${component}: ${this._pathsByComponent[component]}, ${path}`); } this._pathsByComponent[component] = path; } } return this._pathsByComponent; } } exports.Manifest = Manifest; /** * Helper to convert parsed JSON releaser config into ReleaserConfig for * the Manifest. * * @param {ReleaserPackageConfig} config Parsed configuration from JSON file. * @returns {ReleaserConfig} */ function extractReleaserConfig(config) { var _a, _b, _c; return { releaseType: config['release-type'], bumpMinorPreMajor: config['bump-minor-pre-major'], bumpPatchForMinorPreMajor: config['bump-patch-for-minor-pre-major'], prereleaseType: config['prerelease-type'], versioning: config['versioning'], changelogSections: config['changelog-sections'], changelogPath: config['changelog-path'], changelogHost: config['changelog-host'], releaseAs: config['release-as'], skipGithubRelease: config['skip-github-release'], draft: config.draft, prerelease: config.prerelease, draftPullRequest: config['draft-pull-request'], component: config['component'], packageName: config['package-name'], versionFile: config['version-file'], extraFiles: config['extra-files'], includeComponentInTag: config['include-component-in-tag'], includeVInTag: config['include-v-in-tag'], changelogType: config['changelog-type'], pullRequestTitlePattern: config['pull-request-title-pattern'], pullRequestHeader: config['pull-request-header'], pullRequestFooter: config['pull-request-footer'], componentNoSpace: config['component-no-space'], tagSeparator: config['tag-separator'], separatePullRequests: config['separate-pull-requests'], labels: (_a = config['label']) === null || _a === void 0 ? void 0 : _a.split(','), releaseLabels: (_b = config['release-label']) === null || _b === void 0 ? void 0 : _b.split(','), extraLabels: (_c = config['extra-label']) === null || _c === void 0 ? void 0 : _c.split(','), skipSnapshot: config['skip-snapshot'], initialVersion: config['initial-version'], excludePaths: config['exclude-paths'], dateFormat: config['date-format'], }; } /** * Helper to convert fetch the manifest config from the repository and * parse into configuration for the Manifest. * * @param {GitHub} github GitHub client * @param {string} configFile Path in the repository to the manifest config * @param {string} branch Branch to fetch the config file from * @param {string} onlyPath Optional. Use only the given package * @param {string} releaseAs Optional. Override release-as and use the given version */ async function parseConfig(github, configFile, branch, onlyPath, releaseAs) { const config = await fetchManifestConfig(github, configFile, branch); const defaultConfig = extractReleaserConfig(config); const repositoryConfig = {}; for (const path in config.packages) { if (onlyPath && onlyPath !== path) continue; repositoryConfig[path] = mergeReleaserConfig(defaultConfig, extractReleaserConfig(config.packages[path])); if (releaseAs) { repositoryConfig[path].releaseAs = releaseAs; } } const configLabel = config['label']; const configReleaseLabel = config['release-label']; const configSnapshotLabel = config['snapshot-label']; const configExtraLabel = config['extra-label']; const manifestOptions = { bootstrapSha: config['bootstrap-sha'], lastReleaseSha: config['last-release-sha'], alwaysLinkLocal: config['always-link-local'], separatePullRequests: config['separate-pull-requests'], alwaysUpdate: config['always-update'], groupPullRequestTitlePattern: config['group-pull-request-title-pattern'], plugins: config['plugins'], signoff: config['signoff'], labels: configLabel === null || configLabel === void 0 ? void 0 : configLabel.split(','), releaseLabels: configReleaseLabel === null || configReleaseLabel === void 0 ? void 0 : configReleaseLabel.split(','), snapshotLabels: configSnapshotLabel === null || configSnapshotLabel === void 0 ? void 0 : configSnapshotLabel.split(','), extraLabels: configExtraLabel === null || configExtraLabel === void 0 ? void 0 : configExtraLabel.split(','), releaseSearchDepth: config['release-search-depth'], commitSearchDepth: config['commit-search-depth'], sequentialCalls: config['sequential-calls'], }; return { config: repositoryConfig, options: manifestOptions }; } /** * Helper to fetch manifest config * * @param {GitHub} github * @param {string} configFile * @param {string} branch * @returns {ManifestConfig} * @throws {ConfigurationError} if missing the manifest config file */ async function fetchManifestConfig(github, configFile, branch) { try { return await github.getFileJson(configFile, branch); } catch (e) { if (e instanceof errors_1.FileNotFoundError) { throw new errors_1.ConfigurationError(`Missing required manifest config: ${configFile}`, 'base', `${github.repository.owner}/${github.repository.repo}`); } else if (e instanceof SyntaxError) { throw new errors_1.ConfigurationError(`Failed to parse manifest config JSON: ${configFile}\n${e.message}`, 'base', `${github.repository.owner}/${github.repository.repo}`); } throw e; } } /** * Helper to parse the manifest versions file. * * @param {GitHub} github GitHub client * @param {string} manifestFile Path in the repository to the versions file * @param {string} branch Branch to fetch the versions file from * @returns {Record<string, string>} */ async function parseReleasedVersions(github, manifestFile, branch) { const manifestJson = await fetchReleasedVersions(github, manifestFile, branch); const releasedVersions = {}; for (const path in manifestJson) { releasedVersions[path] = version_1.Version.parse(manifestJson[path]); } return releasedVersions; } /** * Helper to fetch manifest config * * @param {GitHub} github * @param {string} manifestFile * @param {string} branch * @throws {ConfigurationError} if missing the manifest config file */ async function fetchReleasedVersions(github, manifestFile, branch) { try { return await github.getFileJson(manifestFile, branch); } catch (e) { if (e instanceof errors_1.FileNotFoundError) { throw new errors_1.ConfigurationError(`Missing required manifest versions: ${manifestFile}`, 'base', `${github.repository.owner}/${github.repository.repo}`); } else if (e instanceof SyntaxError) { throw new errors_1.ConfigurationError(`Failed to parse manifest versions JSON: ${manifestFile}\n${e.message}`, 'base', `${github.repository.owner}/${github.repository.repo}`); } throw e; } } function isPublishedVersion(strategy, version) { return strategy.isPublishedVersion ? strategy.isPublishedVersion(version) : true; } /** * Find the most recent matching release tag on the branch we're * configured for. * * @param github GitHub client instance. * @param {string} targetBranch Name of the scanned branch. * @param releaseFilter Validator function for release version. Used to filter-out SNAPSHOT releases for Java strategy. * @param {string} prefix Limit the release to a specific component. */ async function latestReleaseVersion(github, targetBranch, releaseFilter, config, prefix, logger = logger_1.logger) { const branchPrefix = prefix ? prefix.endsWith('-') ? prefix.replace(/-$/, '') : prefix : undefined; logger.info(`Looking for latest release on branch: ${targetBranch} with prefix: ${prefix}`); // collect set of recent commit SHAs seen to verify that the release // is in the current branch const commitShas = new Set(); const candidateReleaseVersions = []; // only look at the last 250 or so commits to find the latest tag - we // don't want to scan the entire repository history if this repo has never // been released const generator = github.mergeCommitIterator(targetBranch, { maxResults: 250, }); for await (const commitWithPullRequest of generator) { commitShas.add(commitWithPullRequest.sha); const mergedPullRequest = commitWithPullRequest.pullRequest; if (!(mergedPullRequest === null || mergedPu