diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e6f87756..411256bc 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.0.0" + ".": "4.1.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815f0f6..87c679c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [4.1.0](https://github.com/npm/template-oss/compare/v4.0.0...v4.1.0) (2022-09-13) + +### Features + +* [`352d332`](https://github.com/npm/template-oss/commit/352d33210a89deee6b85ce6e8d9650054177e10f) [#187](https://github.com/npm/template-oss/pull/187) add release branches config to release-please workflow (#187) (@lukekarrys) + +### Bug Fixes + +* [`b58d86a`](https://github.com/npm/template-oss/commit/b58d86adc26d3d6fc07c682391a597398dd3a5b3) [#183](https://github.com/npm/template-oss/pull/183) use conventional commits from release-please for changelog (#183) (@lukekarrys) + ## [4.0.0](https://github.com/npm/template-oss/compare/v3.8.1...v4.0.0) (2022-09-08) ### ⚠ BREAKING CHANGES diff --git a/bin/release-please.js b/bin/release-please.js index 10dd1930..3ee33f75 100755 --- a/bin/release-please.js +++ b/bin/release-please.js @@ -9,7 +9,18 @@ const [branch] = process.argv.slice(2) const setOutput = (key, val) => { if (val && (!Array.isArray(val) || val.length)) { if (dryRun) { - console.log(key, JSON.stringify(val, null, 2)) + if (key === 'pr') { + console.log('PR:', val.title.toString()) + console.log('='.repeat(40)) + console.log(val.body.toString()) + console.log('='.repeat(40)) + for (const update of val.updates.filter(u => u.updater.changelogEntry)) { + console.log('CHANGELOG:', update.path) + console.log('-'.repeat(40)) + console.log(update.updater.changelogEntry) + console.log('-'.repeat(40)) + } + } } else { core.setOutput(key, JSON.stringify(val)) } @@ -27,5 +38,9 @@ main({ setOutput('release', release) return null }).catch(err => { - core.setFailed(`failed: ${err}`) + if (dryRun) { + console.error(err) + } else { + core.setFailed(`failed: ${err}`) + } }) diff --git a/lib/content/index.js b/lib/content/index.js index ddaef336..ac5a4874 100644 --- a/lib/content/index.js +++ b/lib/content/index.js @@ -96,6 +96,7 @@ module.exports = { workspaceModule, windowsCI: true, branches: ['main', 'latest'], + releaseBranches: [], defaultBranch: 'main', // Escape hatch since we write a release test file but the // CLI has a very custom one we dont want to overwrite. This diff --git a/lib/content/release-please-config.json b/lib/content/release-please-config.json index 66209f2f..6976fd32 100644 --- a/lib/content/release-please-config.json +++ b/lib/content/release-please-config.json @@ -1,6 +1,6 @@ { "separate-pull-requests": {{{del}}}, - "plugins": {{#if isMono}}["node-workspace", "workspace-deps"]{{else}}{{{del}}}{{/if}}, + "plugins": {{#if isMono}}["node-workspace"]{{else}}{{{del}}}{{/if}}, "exclude-packages-from-root": true, "group-pull-request-title-pattern": "chore: release ${version}", "pull-request-title-pattern": "chore: release${component} ${version}", diff --git a/lib/content/release-please.yml b/lib/content/release-please.yml index e80e6122..5dab67d0 100644 --- a/lib/content/release-please.yml +++ b/lib/content/release-please.yml @@ -6,6 +6,9 @@ on: {{#each branches}} - {{.}} {{/each}} + {{#each releaseBranches}} + - {{.}} + {{/each}} permissions: contents: write diff --git a/lib/release-please/changelog.js b/lib/release-please/changelog.js index 8cac2931..766abeaf 100644 --- a/lib/release-please/changelog.js +++ b/lib/release-please/changelog.js @@ -1,225 +1,83 @@ -const RP = require('release-please/build/src/changelog-notes/default') +const makeGh = require('./github.js') +const { link, code, specRe, list, dateFmt } = require('./util') -module.exports = class DefaultChangelogNotes extends RP.DefaultChangelogNotes { +module.exports = class ChangelogNotes { constructor (options) { - super(options) - this.github = options.github + this.gh = makeGh(options.github) } - async buildDefaultNotes (commits, options) { - // The default generator has a title with the version and date - // and a link to the diff between the last two versions - const notes = await super.buildNotes(commits, options) - const lines = notes.split('\n') - - let foundBreakingHeader = false - let foundNextHeader = false - const breaking = lines.reduce((acc, line) => { - if (line.match(/^### .* BREAKING CHANGES$/)) { - foundBreakingHeader = true - } else if (!foundNextHeader && foundBreakingHeader && line.match(/^### /)) { - foundNextHeader = true - } - if (foundBreakingHeader && !foundNextHeader) { - acc.push(line) - } - return acc - }, []).join('\n') + buildEntry (commit, authors = []) { + const breaking = commit.notes + .filter(n => n.title === 'BREAKING CHANGE') + .map(n => n.text) - return { - title: lines[0], - breaking: breaking.trim(), - } - } - - async buildNotes (commits, options) { - const { title, breaking } = await this.buildDefaultNotes(commits, options) - const body = await generateChangelogBody(commits, { github: this.github, ...options }) - return [title, breaking, body].filter(Boolean).join('\n\n') - } -} + const entry = [] -// a naive implementation of console.log/group for indenting console -// output but keeping it in a buffer to be output to a file or console -const logger = (init) => { - let indent = 0 - const step = 2 - const buffer = [init] - return { - toString () { - return buffer.join('\n').trim() - }, - group (s) { - this.log(s) - indent += step - }, - groupEnd () { - indent -= step - }, - log (s) { - if (!s) { - buffer.push('') - } else { - buffer.push(s.split('\n').map((l) => ' '.repeat(indent) + l).join('\n')) - } - }, - } -} - -const generateChangelogBody = async (_commits, { github, changelogSections }) => { - const changelogMap = new Map( - changelogSections.filter(c => !c.hidden).map((c) => [c.type, c.section]) - ) - - const { repository } = await github.graphql( - `fragment commitCredit on GitObject { - ... on Commit { - message - url - abbreviatedOid - authors (first:10) { - nodes { - user { - login - url - } - email - name - } - } - associatedPullRequests (first:10) { - nodes { - number - url - merged - } - } - } + if (commit.sha) { + // A link to the commit + entry.push(link(code(commit.sha.slice(0, 7)), this.gh.commit(commit.sha))) } - query { - repository (owner:"${github.repository.owner}", name:"${github.repository.repo}") { - ${_commits.map(({ sha: s }) => `_${s}: object (expression: "${s}") { ...commitCredit }`)} - } - }` - ) - - // collect commits by valid changelog type - const commits = [...changelogMap.values()].reduce((acc, type) => { - acc[type] = [] - return acc - }, {}) - - const allCommits = Object.values(repository) - - for (const commit of allCommits) { - // get changelog type of commit or bail if there is not a valid one - const [, type] = /(^\w+)[\s(:]?/.exec(commit.message) || [] - const changelogType = changelogMap.get(type) - if (!changelogType) { - continue + // A link to the pull request if the commit has one + const prNumber = commit.pullRequest && commit.pullRequest.number + if (prNumber) { + entry.push(link(`#${prNumber}`, this.gh.pull(prNumber))) } - const message = commit.message - .trim() // remove leading/trailing spaces - .replace(/(\r?\n)+/gm, '\n') // replace multiple newlines with one - .replace(/([^\s]+@\d+\.\d+\.\d+.*)/gm, '`$1`') // wrap package@version in backticks - - // the title is the first line of the commit, 'let' because we change it later - let [title, ...body] = message.split('\n') - - const prs = commit.associatedPullRequests.nodes.filter((pull) => pull.merged) - - // external squashed PRs dont get the associated pr node set - // so we try to grab it from the end of the commit title - // since thats where it goes by default - const [, titleNumber] = title.match(/\s+\(#(\d+)\)$/) || [] - if (titleNumber && !prs.find((pr) => pr.number === +titleNumber)) { - try { - // it could also reference an issue so we do one extra check - // to make sure it is really a pr that has been merged - const { data: realPr } = await github.octokit.pulls.get({ - owner: github.repository.owner, - repo: github.repository.repo, - pull_number: titleNumber, - }) - if (realPr.state === 'MERGED') { - prs.push(realPr) - } - } catch { - // maybe an issue or something else went wrong - // not super important so keep going - } + // The title of the commit, with the optional scope as a prefix + const scope = commit.scope && `${commit.scope}:` + const subject = commit.bareMessage.replace(specRe, code('$1')) + entry.push([scope, subject].filter(Boolean).join(' ')) + + // A list og the authors github handles or names + if (authors.length && commit.type !== 'deps') { + entry.push(`(${authors.join(', ')})`) } - for (const pr of prs) { - title = title.replace(new RegExp(`\\s*\\(#${pr.number}\\)`, 'g'), '') + return { + entry: entry.join(' '), + breaking, } + } - body = body - .map((line) => line.trim()) // remove artificial line breaks - .filter(Boolean) // remove blank lines - .join('\n') // rejoin on new lines - .split(/^[*-]/gm) // split on lines starting with bullets - .map((line) => line.trim()) // remove spaces around bullets - .filter((line) => !title.includes(line)) // rm lines that exist in the title - // replace new lines for this bullet with spaces and re-bullet it - .map((line) => `* ${line.trim().replace(/\n/gm, ' ')}`) - .join('\n') // re-join with new lines - - commits[changelogType].push({ - hash: commit.abbreviatedOid, - url: commit.url, - title, - type: changelogType, - body, - prs, - credit: commit.authors.nodes.map((author) => { - if (author.user && author.user.login) { - return { - name: `@${author.user.login}`, - url: author.user.url, - } - } - // if the commit used an email that's not associated with a github account - // then the user field will be empty, so we fall back to using the committer's - // name and email as specified by git - return { - name: author.name, - url: `mailto:${author.email}`, + async buildNotes (rawCommits, { version, previousTag, currentTag, changelogSections }) { + const changelog = changelogSections.reduce((acc, c) => { + if (!c.hidden) { + acc[c.type] = { + title: c.section, + entries: [], } - }), + } + return acc + }, { + breaking: { + title: '⚠️ BREAKING CHANGES', + entries: [], + }, }) - } - const output = logger() + // Only continue with commits that will make it to our changelog + const commits = rawCommits.filter(c => changelog[c.type]) - for (const key of Object.keys(commits)) { - if (commits[key].length > 0) { - output.group(`### ${key}\n`) + const authorsByCommit = await this.gh.authors(commits) - for (const commit of commits[key]) { - let groupCommit = `* [\`${commit.hash}\`](${commit.url})` + // Group commits by type + for (const commit of commits) { + const { entry, breaking } = this.buildEntry(commit, authorsByCommit[commit.sha]) - for (const pr of commit.prs) { - groupCommit += ` [#${pr.number}](${pr.url})` - } + // Collect commits by type + changelog[commit.type].entries.push(entry) - groupCommit += ` ${commit.title}` - if (key !== 'Dependencies') { - for (const user of commit.credit) { - groupCommit += ` (${user.name})` - } - } + // And push breaking changes to its own section + changelog.breaking.entries.push(...breaking) + } - output.group(groupCommit) - output.groupEnd() - } + const sections = Object.values(changelog) + .filter((s) => s.entries.length) + .map(({ title, entries }) => [`### ${title}`, entries.map(list).join('\n')].join('\n\n')) - output.log() - output.groupEnd() - } - } + const title = `## ${link(version, this.gh.compare(previousTag, currentTag))} (${dateFmt()})` - return output.toString() + return [title, ...sections].join('\n\n').trim() + } } diff --git a/lib/release-please/github.js b/lib/release-please/github.js new file mode 100644 index 00000000..18c033fa --- /dev/null +++ b/lib/release-please/github.js @@ -0,0 +1,52 @@ +module.exports = (gh) => { + const { owner, repo } = gh.repository + + const authors = async (commits) => { + const response = {} + + const shas = commits.map(c => c.sha).filter(Boolean) + + if (!shas.length) { + return response + } + + const { repository } = await gh.graphql( + `fragment CommitAuthors on GitObject { + ... on Commit { + authors (first:10) { + nodes { + user { login } + name + } + } + } + } + query { + repository (owner:"${owner}", name:"${repo}") { + ${shas.map((s) => { + return `_${s}: object (expression: "${s}") { ...CommitAuthors }` + })} + } + }` + ) + + for (const [key, commit] of Object.entries(repository)) { + if (commit) { + response[key.slice(1)] = commit.authors.nodes + .map((a) => a.user && a.user.login ? `@${a.user.login}` : a.name) + .filter(Boolean) + } + } + + return response + } + + const url = (...p) => `https://github.com/${owner}/${repo}/${p.join('/')}` + + return { + authors, + pull: (number) => url('pull', number), + commit: (sha) => url('commit', sha), + compare: (a, b) => a ? url('compare', `${a.toString()}...${b.toString()}`) : null, + } +} diff --git a/lib/release-please/index.js b/lib/release-please/index.js index 19f38fa6..2464143c 100644 --- a/lib/release-please/index.js +++ b/lib/release-please/index.js @@ -1,17 +1,13 @@ const RP = require('release-please') -const logger = require('./logger.js') +const { CheckpointLogger } = require('release-please/build/src/util/logger.js') const ChangelogNotes = require('./changelog.js') const Version = require('./version.js') -const WorkspaceDeps = require('./workspace-deps.js') -const NodeWorkspace = require('./node-workspace.js') +const NodeWs = require('./node-workspace.js') -RP.setLogger(logger) -RP.registerChangelogNotes('default', (options) => new ChangelogNotes(options)) -RP.registerVersioningStrategy('default', (options) => new Version(options)) -RP.registerPlugin('workspace-deps', (o) => - new WorkspaceDeps(o.github, o.targetBranch, o.repositoryConfig)) -RP.registerPlugin('node-workspace', (o) => - new NodeWorkspace(o.github, o.targetBranch, o.repositoryConfig)) +RP.setLogger(new CheckpointLogger(true, true)) +RP.registerChangelogNotes('default', (o) => new ChangelogNotes(o)) +RP.registerVersioningStrategy('default', (o) => new Version(o)) +RP.registerPlugin('node-workspace', (o) => new NodeWs(o.github, o.targetBranch, o.repositoryConfig)) const main = async ({ repo: fullRepo, token, dryRun, branch }) => { if (!token) { diff --git a/lib/release-please/logger.js b/lib/release-please/logger.js deleted file mode 100644 index 3c30bc37..00000000 --- a/lib/release-please/logger.js +++ /dev/null @@ -1,3 +0,0 @@ -const { CheckpointLogger } = require('release-please/build/src/util/logger') - -module.exports = new CheckpointLogger(true, true) diff --git a/lib/release-please/node-workspace.js b/lib/release-please/node-workspace.js index d06c7da1..a43b0345 100644 --- a/lib/release-please/node-workspace.js +++ b/lib/release-please/node-workspace.js @@ -1,11 +1,183 @@ -const Version = require('./version.js') -const RP = require('release-please/build/src/plugins/node-workspace') - -module.exports = class NodeWorkspace extends RP.NodeWorkspace { - bumpVersion (pkg) { - // The default release please node-workspace plugin forces a patch - // bump for the root if it only includes workspace dep updates. - // This does the same thing except it respects the prerelease config. - return new Version(pkg).bump(pkg.version, [{ type: 'fix' }]) +const { NodeWorkspace } = require('release-please/build/src/plugins/node-workspace.js') +const { RawContent } = require('release-please/build/src/updaters/raw-content.js') +const { jsonStringify } = require('release-please/build/src/util/json-stringify.js') +const { addPath } = require('release-please/build/src/plugins/workspace.js') +const { TagName } = require('release-please/build/src/util/tag-name.js') +const { ROOT_PROJECT_PATH } = require('release-please/build/src/manifest.js') +const makeGh = require('./github.js') +const { link, code } = require('./util.js') + +const SCOPE = '__REPLACE_WORKSPACE_DEP__' +const WORKSPACE_DEP = new RegExp(`${SCOPE}: (\\S+) (\\S+)`, 'gm') + +module.exports = class extends NodeWorkspace { + constructor (github, ...args) { + super(github, ...args) + this.gh = makeGh(github) + } + + async preconfigure (strategiesByPath, commitsByPath, releasesByPath) { + // First build a list of all releases that will happen based on + // the conventional commits + const candidates = [] + for (const path in strategiesByPath) { + const pullRequest = await strategiesByPath[path].buildReleasePullRequest( + commitsByPath[path], + releasesByPath[path] + ) + if (pullRequest?.version) { + candidates.push({ path, pullRequest }) + } + } + + // Then build the graph of all those releases + any other connected workspaces + const { allPackages, candidatesByPackage } = await this.buildAllPackages(candidates) + const orderedPackages = this.buildGraphOrder( + await this.buildGraph(allPackages), + Object.keys(candidatesByPackage) + ) + + // Then build a list of all the updated versions + const updatedVersions = new Map() + for (const pkg of orderedPackages) { + const path = this.pathFromPackage(pkg) + const packageName = this.packageNameFromPackage(pkg) + + let version = null + const existingCandidate = candidatesByPackage[packageName] + if (existingCandidate) { + // If there is an existing pull request use that version + version = existingCandidate.pullRequest.version + } else { + // Otherwise build another pull request (that will be discarded) just + // to see what the version would be if it only contained a deps commit. + // This is to make sure we use any custom versioning or release strategy. + const strategy = strategiesByPath[path] + const depsSection = strategy.changelogSections.find(c => c.section === 'Dependencies') + const releasePullRequest = await strategiesByPath[path].buildReleasePullRequest( + [{ message: `${depsSection.type}:` }], + releasesByPath[path] + ) + version = releasePullRequest.version + } + + updatedVersions.set(packageName, version) + } + + // Save some data about the preconfiugred releases so we can look it up later + // when rewriting the changelogs + this.releasesByPackage = new Map() + this.pathsByComponent = new Map() + + // Then go through all the packages again and add deps commits + // for each updated workspace + for (const pkg of orderedPackages) { + const path = this.pathFromPackage(pkg) + const packageName = this.packageNameFromPackage(pkg) + const graphPackage = this.packageGraph.get(pkg.name) + + // Update dependency versions + for (const [depName, resolved] of graphPackage.localDependencies) { + const depVersion = updatedVersions.get(depName) + const isNotDir = resolved.type !== 'directory' + // Changelog entries are only added for dependencies and not any other type + const isDep = Object.prototype.hasOwnProperty.call(pkg.dependencies, depName) + if (depVersion && isNotDir && isDep) { + commitsByPath[path].push({ + message: `deps(${SCOPE}): ${depName} ${depVersion.toString()}`, + }) + } + } + + const component = await strategiesByPath[path].getComponent() + this.pathsByComponent.set(component, path) + this.releasesByPackage.set(packageName, { + path, + component, + currentTag: releasesByPath[path]?.tag, + }) + } + + return strategiesByPath + } + + // This is copied from the release-please node-workspace plugin + // except it only updates the package.json instead of appending + // anything to changelogs since we've already done that in preconfigure. + updateCandidate (candidate, pkg, updatedVersions) { + const graphPackage = this.packageGraph.get(pkg.name) + const updatedPackage = pkg.clone() + + for (const [depName, resolved] of graphPackage.localDependencies) { + const depVersion = updatedVersions.get(depName) + if (depVersion && resolved.type !== 'directory') { + updatedPackage.updateLocalDependency(resolved, depVersion.toString(), '^') + } + } + + for (const update of candidate.pullRequest.updates) { + if (update.path === addPath(candidate.path, 'package.json')) { + update.updater = new RawContent( + jsonStringify(updatedPackage.toJSON(), updatedPackage.rawContent) + ) + } + } + + return candidate + } + + postProcessCandidates (candidates) { + for (const candidate of candidates) { + for (const release of candidate.pullRequest.body.releaseData) { + // Update notes with a link to each workspaces release notes + // now that we have all of the releases in a single pull request + release.notes = release.notes.replace(WORKSPACE_DEP, (_, depName, depVersion) => { + const { currentTag, path, component } = this.releasesByPackage.get(depName) + + const url = this.gh.compare(currentTag, new TagName( + depVersion, + component, + this.repositoryConfig[path].tagSeparator, + this.repositoryConfig[path].includeVInTag + )) + + return `${link('Workspace', url)}: ${code(`${depName}@${depVersion}`)}` + }) + + // Find the associated changelog and update that too + const path = this.pathsByComponent.get(release.component) + for (const update of candidate.pullRequest.updates) { + if (update.path === addPath(path, 'CHANGELOG.md')) { + update.updater.changelogEntry = release.notes + } + } + } + + // Sort root release to the top of the pull request + candidate.pullRequest.body.releaseData.sort((a, b) => { + const aPath = this.pathsByComponent.get(a.component) + const bPath = this.pathsByComponent.get(b.component) + if (aPath === ROOT_PROJECT_PATH) { + return -1 + } + if (bPath === ROOT_PROJECT_PATH) { + return 1 + } + return 0 + }) + } + + return candidates + } + + // Stub these methods with errors since the preconfigure method should negate these + // ever being called from the release please base class. If they are called then + // something has changed that would likely break us in other ways. + bumpVersion () { + throw new Error('Should not bump packages. This should be done in preconfigure.') + } + + newCandidate () { + throw new Error('Should not create new candidates. This should be done in preconfigure.') } } diff --git a/lib/release-please/util.js b/lib/release-please/util.js new file mode 100644 index 00000000..7fd527e5 --- /dev/null +++ b/lib/release-please/util.js @@ -0,0 +1,14 @@ +const semver = require('semver') + +module.exports.specRe = new RegExp(`([^\\s]+@${semver.src[semver.tokens.FULLPLAIN]})`, 'g') + +module.exports.code = (c) => `\`${c}\`` +module.exports.link = (text, url) => url ? `[${text}](${url})` : text +module.exports.list = (text) => `* ${text}` + +module.exports.dateFmt = (date = new Date()) => { + const year = date.getFullYear() + const month = (date.getMonth() + 1).toString().padStart(2, '0') + const day = date.getDate().toString().padStart(2, '0') + return [year, month, day].join('-') +} diff --git a/lib/release-please/version.js b/lib/release-please/version.js index b08bc28e..29960ee7 100644 --- a/lib/release-please/version.js +++ b/lib/release-please/version.js @@ -1,5 +1,5 @@ const semver = require('semver') -const RP = require('release-please/build/src/version.js') +const { Version } = require('release-please/build/src/version.js') // A way to compare the "level" of a release since we ignore some things during prereleases const LEVELS = new Map([['prerelease', 4], ['major', 3], ['minor', 2], ['patch', 1]] @@ -7,37 +7,62 @@ const LEVELS = new Map([['prerelease', 4], ['major', 3], ['minor', 2], ['patch', const parseVersion = (v) => { const { prerelease, minor, patch, version } = semver.parse(v) + + // This looks at whether there are 0s in certain positions of the version + // 1.0.0 => major + // 1.5.0 => minor + // 1.5.6 => patch + const release = !patch + ? (minor ? LEVELS.get('minor') : LEVELS.get('major')) + : LEVELS.get('patch') + + // Keep track of whether the version has any prerelease identifier const hasPre = prerelease.length > 0 + // Even if it is a prerelease version, this might be an empty string const preId = prerelease.filter(p => typeof p === 'string').join('.') - const release = !patch ? (minor ? LEVELS.get('minor') : LEVELS.get('major')) : LEVELS.get('patch') - return { version, release, prerelease: hasPre, preId } + + return { + version, + release, + prerelease: hasPre, + preId, + } } const parseCommits = (commits, prerelease) => { + // Default is a patch level change let release = LEVELS.get('patch') + for (const commit of commits) { if (commit.breaking) { + // If any breaking commit is present, its a major release = LEVELS.get('major') break } else if (['feat', 'feature'].includes(commit.type)) { + // Otherwise a feature is a minor release release = LEVELS.get('minor') } } - return { release, prerelease: !!prerelease } + + return { + release, + prerelease: !!prerelease, + } } const preInc = ({ version, prerelease, preId }, release) => { if (!release.startsWith('pre')) { release = `pre${release}` } + // `pre` is the default prerelease identifier when creating a new // prerelease version return semver.inc(version, release, prerelease ? preId : 'pre') } -const releasePleaseVersion = (v) => { +const semverToVersion = (v) => { const { major, minor, patch, prerelease } = semver.parse(v) - return new RP.Version(major, minor, patch, prerelease.join('.')) + return new Version(major, minor, patch, prerelease.join('.')) } // This does not account for pre v1 semantics since we don't publish those @@ -71,6 +96,8 @@ module.exports = class DefaultVersioningStrategy { const releaseVersion = next.prerelease ? preInc(current, release) : semver.inc(current.version, release) - return releasePleaseVersion(releaseVersion) + return semverToVersion(releaseVersion) } } + +module.exports.semverToVersion = semverToVersion diff --git a/lib/release-please/workspace-deps.js b/lib/release-please/workspace-deps.js deleted file mode 100644 index fd33b64c..00000000 --- a/lib/release-please/workspace-deps.js +++ /dev/null @@ -1,99 +0,0 @@ -const { ManifestPlugin } = require('release-please/build/src/plugin') -const { Changelog } = require('release-please/build/src/updaters/changelog.js') -const { PackageJson } = require('release-please/build/src/updaters/node/package-json.js') - -const matchLine = (line, re) => { - const trimmed = line.trim().replace(/^[*\s]+/, '') - if (typeof re === 'string') { - return trimmed === re - } - return trimmed.match(re) -} - -module.exports = class WorkspaceDeps extends ManifestPlugin { - run (pullRequests) { - try { - for (const { pullRequest } of pullRequests) { - const getChangelog = (release) => pullRequest.updates.find((u) => { - const isChangelog = u.updater instanceof Changelog - const isComponent = release.component && u.path.startsWith(release.component) - const isRoot = !release.component && !u.path.includes('/') - return isChangelog && (isComponent || isRoot) - }) - - const getComponent = (pkgName) => pullRequest.updates.find((u) => { - const isPkg = u.updater instanceof PackageJson - return isPkg && JSON.parse(u.updater.rawContent).name === pkgName - }).path.replace(/\/package\.json$/, '') - - const depLinksByComponent = pullRequest.body.releaseData.reduce((acc, release) => { - if (release.component) { - const path = [ - this.github.repository.owner, - this.github.repository.repo, - 'releases', - 'tag', - release.tag.toString(), - ] - acc[release.component] = `https://github.com/${path.join('/')}` - } - return acc - }, {}) - - for (const release of pullRequest.body.releaseData) { - const lines = release.notes.split('\n') - const newLines = [] - - let inWorkspaceDeps = false - let collectWorkspaceDeps = false - - for (const line of lines) { - if (matchLine(line, 'The following workspace dependencies were updated')) { - // We are in the section with our workspace deps - // Set the flag and discard this line since we dont want it in the final output - inWorkspaceDeps = true - } else if (inWorkspaceDeps) { - if (collectWorkspaceDeps) { - const depMatch = matchLine(line, /^(\S+) bumped from \S+ to (\S+)$/) - if (depMatch) { - // If we have a line that is a workspace dep update, then reformat - // it and save it to the new lines - const [, depName, newVersion] = depMatch - const depSpec = `\`${depName}@${newVersion}\`` - const url = depLinksByComponent[getComponent(depName)] - newLines.push(` * deps: [${depSpec}](${url})`) - } else { - // Anything else means we are done with dependencies so ignore - // this line and dont look for any more - collectWorkspaceDeps = false - } - } else if (matchLine(line, 'dependencies')) { - // Only collect dependencies discard dev deps and everything else - collectWorkspaceDeps = true - } else if (matchLine(line, '') || matchLine(line, /^#/)) { - inWorkspaceDeps = false - newLines.push(line) - } - } else { - newLines.push(line) - } - } - - let newNotes = newLines.join('\n').trim() - const emptyDeps = newNotes.match(/### Dependencies[\n]+(### .*)/m) - if (emptyDeps) { - newNotes = newNotes.replace(emptyDeps[0], emptyDeps[1]) - } - - release.notes = newNotes - getChangelog(release).updater.changelogEntry = newNotes - } - } - } catch { - // Always return pull requests even if we failed so - // we dont fail the release - } - - return pullRequests - } -} diff --git a/package.json b/package.json index 4b58bd06..5973ca30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@npmcli/template-oss", - "version": "4.0.0", + "version": "4.1.0", "description": "templated files used in npm CLI team oss projects", "main": "lib/content/index.js", "bin": { diff --git a/tap-snapshots/test/apply/full-content.js.test.cjs b/tap-snapshots/test/apply/full-content.js.test.cjs index fddf69da..5ad8fac3 100644 --- a/tap-snapshots/test/apply/full-content.js.test.cjs +++ b/tap-snapshots/test/apply/full-content.js.test.cjs @@ -1634,8 +1634,7 @@ release-please-config.json ======================================== { "plugins": [ - "node-workspace", - "workspace-deps" + "node-workspace" ], "exclude-packages-from-root": true, "group-pull-request-title-pattern": "chore: release \${version}", @@ -2274,8 +2273,7 @@ release-please-config.json ======================================== { "plugins": [ - "node-workspace", - "workspace-deps" + "node-workspace" ], "exclude-packages-from-root": true, "group-pull-request-title-pattern": "chore: release \${version}", diff --git a/tap-snapshots/test/check/diffs.js.test.cjs b/tap-snapshots/test/check/diffs.js.test.cjs index 8da852c1..87b168ec 100644 --- a/tap-snapshots/test/check/diffs.js.test.cjs +++ b/tap-snapshots/test/check/diffs.js.test.cjs @@ -24,7 +24,6 @@ To correct it: npx template-oss-apply --force exports[`test/check/diffs.js TAP different headers > source after apply 1`] = ` content/index.js ======================================== - module.exports = { rootRepo: { add: { diff --git a/test/fixtures/header.js b/test/fixtures/header.js index 335c0bbe..317dc4f2 100644 --- a/test/fixtures/header.js +++ b/test/fixtures/header.js @@ -1,4 +1,3 @@ - module.exports = { rootRepo: { add: { diff --git a/test/release-please/changelog.js b/test/release-please/changelog.js new file mode 100644 index 00000000..4350cfcb --- /dev/null +++ b/test/release-please/changelog.js @@ -0,0 +1,98 @@ +const t = require('tap') +const ChangelogNotes = require('../../lib/release-please/changelog.js') + +const mockChangelog = async ({ shas = true, authors = true, previousTag = true } = {}) => { + const commits = [{ + sha: 'a', + type: 'feat', + notes: [], + bareMessage: 'Hey now', + scope: 'bin', + }, { + sha: 'b', + type: 'feat', + notes: [{ title: 'BREAKING CHANGE', text: 'breaking' }], + bareMessage: 'b', + pullRequest: { + number: '100', + }, + }, { + sha: 'c', + type: 'deps', + bareMessage: 'test@1.2.3', + notes: [], + }, { + sha: 'd', + type: 'fix', + bareMessage: 'this fixes it', + notes: [], + }].map(({ sha, ...rest }) => shas ? { sha, ...rest } : rest) + + const github = { + repository: { owner: 'npm', repo: 'cli' }, + graphql: () => ({ + repository: commits.reduce((acc, c, i) => { + if (c.sha) { + if (c.sha === 'd') { + // simulate a bad sha passed in that doesnt return a commit + acc[`_${c.sha}`] = null + } else { + const author = i % 2 + ? { user: { login: 'username' } } + : { name: 'Name' } + acc[`_${c.sha}`] = { authors: { nodes: authors ? [author] : [] } } + } + } + return acc + }, {}), + }), + + } + + const changelog = new ChangelogNotes({ github }) + + const notes = await changelog.buildNotes(commits, { + version: '1.0.0', + previousTag: previousTag ? 'v0.1.0' : null, + currentTag: 'v1.0.0', + changelogSections: require('../../release-please-config.json')['changelog-sections'], + }) + + return notes + .split('\n') + .map((l) => l.replace(/\d{4}-\d{2}-\d{2}/g, 'DATE')) + .filter(Boolean) +} + +t.test('changelog', async t => { + const changelog = await mockChangelog() + t.strictSame(changelog, [ + '## [1.0.0](https://github.com/npm/cli/compare/v0.1.0...v1.0.0) (DATE)', + '### ⚠️ BREAKING CHANGES', + '* breaking', + '### Features', + '* [`a`](https://github.com/npm/cli/commit/a) bin: Hey now (Name)', + // eslint-disable-next-line max-len + '* [`b`](https://github.com/npm/cli/commit/b) [#100](https://github.com/npm/cli/pull/100) b (@username)', + '### Bug Fixes', + '* [`d`](https://github.com/npm/cli/commit/d) this fixes it', + '### Dependencies', + '* [`c`](https://github.com/npm/cli/commit/c) `test@1.2.3`', + ]) +}) + +t.test('no tag/authors/shas', async t => { + const changelog = await mockChangelog({ authors: false, previousTag: false, shas: false }) + t.strictSame(changelog, [ + '## 1.0.0 (DATE)', + '### ⚠️ BREAKING CHANGES', + '* breaking', + '### Features', + '* bin: Hey now', + '* [#100](https://github.com/npm/cli/pull/100) b', + '### Bug Fixes', + '* this fixes it', + '### Dependencies', + '* `test@1.2.3`', + ]) +}) diff --git a/test/release-please/workspace-deps.js b/test/release-please/workspace-deps.js deleted file mode 100644 index 8159bb1d..00000000 --- a/test/release-please/workspace-deps.js +++ /dev/null @@ -1,132 +0,0 @@ -const t = require('tap') -const WorkspaceDeps = require('../../lib/release-please/workspace-deps.js') -const { Changelog } = require('release-please/build/src/updaters/changelog.js') -const { PackageJson } = require('release-please/build/src/updaters/node/package-json.js') -const { TagName } = require('release-please/build/src/util/tag-name.js') -const { Version } = require('release-please/build/src/version.js') - -const mockWorkspaceDeps = (notes) => { - const releases = [{ - component: '', - notes: notes.join('\n'), - }, { - component: 'pkg2', - notes: '### no link', - tag: new TagName(new Version(2, 0, 0), 'pkg2'), - }, { - component: 'pkg1', - notes: '### [sdsfsdf](http://url1)', - tag: new TagName(new Version(2, 0, 0), 'pkg1'), - }, { - component: 'pkg3', - notes: '### [sdsfsdf](http://url3)', - tag: new TagName(new Version(2, 0, 0), 'pkg3'), - }] - - const options = { github: { repository: { owner: 'npm', repo: 'cli' } } } - - const pullRequests = new WorkspaceDeps(options.github).run([{ - pullRequest: { - body: { - releaseData: releases, - }, - updates: [ - ...releases.map(r => ({ - path: `${r.component ? `${r.component}/` : ''}CHANGELOG.md`, - updater: new Changelog({ changelogEntry: notes.join('\n') }), - })), - ...releases.map(r => { - const pkg = new PackageJson({ version: '1.0.0' }) - pkg.rawContent = JSON.stringify({ - name: r.component === 'pkg1' ? '@scope/pkg1' : (r.component || 'root'), - }) - return { - path: `${r.component ? `${r.component}/` : ''}package.json`, - updater: pkg, - } - }), - ], - }, - }]) - return { - pr: pullRequests[0].pullRequest.body.releaseData[0].notes.split('\n'), - changelog: pullRequests[0].pullRequest.updates[0].updater.changelogEntry.split('\n'), - } -} - -const fixtures = { - wsDeps: [ - '### Feat', - '', - ' * xyz', - '', - '### Dependencies', - '', - '* The following workspace dependencies were updated', - ' * peerDependencies', - ' * pkgA bumped from ^1.0.0 to ^2.0.0', - ' * pkgB bumped from ^1.0.0 to ^2.0.0', - ' * dependencies', - ' * @scope/pkg1 bumped from ^1.0.0 to ^2.0.0', - ' * pkg2 bumped from ^1.0.0 to ^2.0.0', - ' * pkg3 bumped from ^1.0.0 to ^2.0.0', - ' * devDependencies', - ' * pkgC bumped from ^1.0.0 to ^2.0.0', - ' * pkgD bumped from ^1.0.0 to ^2.0.0', - '', - '### Next', - '', - ' * xyz', - ], - empty: [ - '### Feat', - '', - ' * xyz', - '', - '### Dependencies', - '', - '* The following workspace dependencies were updated', - ' * peerDependencies', - ' * pkgA bumped from ^1.0.0 to ^2.0.0', - ' * pkgB bumped from ^1.0.0 to ^2.0.0', - ' * devDependencies', - ' * pkgC bumped from ^1.0.0 to ^2.0.0', - ' * pkgD bumped from ^1.0.0 to ^2.0.0', - '', - '### Other', - '', - ' * xyz', - ], -} - -t.test('rewrite deps', async (t) => { - const mockWs = mockWorkspaceDeps(fixtures.wsDeps) - t.strictSame(mockWs.pr, [ - '### Feat', - '', - ' * xyz', - '', - '### Dependencies', - '', - ' * deps: [`@scope/pkg1@^2.0.0`](https://github.com/npm/cli/releases/tag/pkg1-v2.0.0)', - ' * deps: [`pkg2@^2.0.0`](https://github.com/npm/cli/releases/tag/pkg2-v2.0.0)', - ' * deps: [`pkg3@^2.0.0`](https://github.com/npm/cli/releases/tag/pkg3-v2.0.0)', - '', - '### Next', - '', - ' * xyz', - ]) - t.strictSame(mockWs.pr, mockWs.changelog) - - const mockEmpty = mockWorkspaceDeps(fixtures.empty) - t.strictSame(mockEmpty.pr, [ - '### Feat', - '', - ' * xyz', - '', - '### Other', - '', - ' * xyz', - ]) - t.strictSame(mockEmpty.pr, mockEmpty.changelog) -})