From e3baee75dd8dea7a4c9ab6f2c419f19f8d381fca Mon Sep 17 00:00:00 2001 From: Lukasz Gornicki Date: Sat, 5 Feb 2022 16:45:44 +0100 Subject: [PATCH] feat!: add base_branch support and improve error handling (#9) --- README.md | 6 +- action.yml | 5 ++ dist/index.js | 179 +++++++++++++++++++++++++++++++++----------------- lib/git.js | 16 +++-- lib/index.js | 161 +++++++++++++++++++++++++++++---------------- 5 files changed, 243 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index eb68e7b..061dd10 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Organization Projects' Dependency Manager GitHub Action that handles automated update of dependencies in package.json between projects from the same GitHub organization. You run this workflow after npm package release. It searches for libraries in your GitHub organization that depend on this package and bump version through a PR flow. +While updating multiple repositories, if there are issues with one of them, the action doesn't fail but continues bumping deps in next repo from the list. + - [Why I Created This Action?](#why-i-created-this-action) @@ -57,6 +59,7 @@ committer_email | The committer's email that will be used in the commit of chang commit_message_prod | It is used as a commit message when bumping dependency from "dependencies" section in package.json. In case dependency is located in both dependencies and devDependencies of dependant, then prod commit message is used. It is also used as a title of the pull request that is created by this action. | false | `fix: update ${dependencyName} to ${dependencyVersion} version` commit_message_dev | It is used as a commit message when bumping dependency from "devDependencies" section in package.json. It is also used as a title of the pull request that is created by this action. | false | `chore: update ${dependencyName} to ${dependencyVersion} version` repos_to_ignore | Comma-separated list of repositories that should not get updates from this action. Action already ignores the repo in which the action is triggered so you do not need to add it explicitly. In the format `repo1,repo2`. | false | - +base_branch | Name of the base branch, where changes in package.json must be applied. It is used in PR creation. Branch where changes are introduced is cut from this base branch. If not provided, default branch is used. In the format: `next-major`. | false | - ## Example @@ -77,10 +80,11 @@ jobs: steps: - uses: actions/checkout@v2 - name: Bumping - uses: derberg/npm-dependency-manager-for-your-github-org@v3 + uses: derberg/npm-dependency-manager-for-your-github-org@v4 with: github_token: ${{ secrets.CUSTOM_TOKEN }} repos_to_ignore: repo1,repo2 + base_branch: next-major packagejson_path: ./custom/path committer_username: pomidor committer_email: pomidor@pomidor.com diff --git a/action.yml b/action.yml index 55deae9..90d77f1 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,11 @@ inputs: Action already ignores the repo in which the action is triggered so you do not need to add it explicitly. In the format: `repo1,repo2`. required: false + base_branch: + description: > + Name of the base branch, where changes in package.json must be applied. It is used in PR creation. If not provided, default branch is used + In the format: `next-major`. + required: false runs: using: node12 main: dist/index.js diff --git a/dist/index.js b/dist/index.js index c870660..14350bb 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5654,16 +5654,18 @@ exports.parseStringResponse = parseStringResponse; /***/ 374: /***/ (function(module) { -module.exports = {createBranch, clone, push}; +module.exports = {createBranch, clone, push, removeRemoteBranch}; + +const remoteName = 'auth'; async function createBranch(branchName, git) { return await git .checkout(`-b${branchName}`); } -async function clone(remote, dir, git) { +async function clone(remote, dir, branchName, git) { return await git - .clone(remote, dir, {'--depth': 1}); + .clone(remote, dir, {'--depth': 1, '--branch': branchName}); } async function push(token, url, branchName, message, committerUsername, committerEmail, git) { @@ -5677,8 +5679,12 @@ async function push(token, url, branchName, message, committerUsername, committe .addConfig('user.name', committerUsername) .addConfig('user.email', committerEmail) .commit(message) - .addRemote('auth', authanticatedUrl(token, url, committerUsername)) - .push(['-u', 'auth', branchName]); + .addRemote(remoteName, authanticatedUrl(token, url, committerUsername)) + .push(['-u', remoteName, branchName]); +} + +async function removeRemoteBranch(branchName, git) { + return await git.push(['-u', remoteName, branchName, '--delete', '--force']); } @@ -11421,7 +11427,7 @@ const simpleGit = __webpack_require__(477); const path = __webpack_require__(622); const {mkdir} = __webpack_require__(747).promises; -const { createBranch, clone, push } = __webpack_require__(374); +const { createBranch, clone, push, removeRemoteBranch } = __webpack_require__(374); const { getReposList, createPr, getRepoDefaultBranch } = __webpack_require__(119); const { readPackageJson, parseCommaList, verifyDependencyType, installDependency } = __webpack_require__(918); @@ -11431,79 +11437,128 @@ const { readPackageJson, parseCommaList, verifyDependencyType, installDependency * It looks complex because of extensive usage of core package to log as much as possible */ async function run() { + const gitHubKey = process.env.GITHUB_TOKEN || core.getInput('github_token', { required: true }); + const committerUsername = core.getInput('committer_username') || 'web-flow'; + const committerEmail = core.getInput('committer_email') || 'noreply@github.com'; + const packageJsonPath = process.env.PACKAGE_JSON_LOC || core.getInput('packagejson_path') || './'; + const { name: dependencyName, version: dependencyVersion} = await readPackageJson(path.join(packageJsonPath, 'package.json')); + core.info(`Identified dependency name as ${dependencyName} with version ${dependencyVersion}. Now it will be bumped in dependent projects.`); + const commitMessageProd = core.getInput('commit_message_prod') || `fix: update ${dependencyName} to ${dependencyVersion} version`; + const commitMessageDev = core.getInput('commit_message_dev') || `chore: update ${dependencyName} to ${dependencyVersion} version`; + const reposToIgnore = core.getInput('repos_to_ignore'); + const baseBranchName = core.getInput('base_branch'); + + const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); + const octokit = github.getOctokit(gitHubKey); + const ignoredRepositories = reposToIgnore ? parseCommaList(reposToIgnore) : []; + //by default repo where workflow runs should always be ignored + let reposList; + try { - const gitHubKey = process.env.GITHUB_TOKEN || core.getInput('github_token', { required: true }); - const committerUsername = core.getInput('committer_username') || 'web-flow'; - const committerEmail = core.getInput('committer_email') || 'noreply@github.com'; - const packageJsonPath = process.env.PACKAGE_JSON_LOC || core.getInput('packagejson_path') || './'; - const { name: dependencyName, version: dependencyVersion} = await readPackageJson(path.join(packageJsonPath, 'package.json')); - core.info(`Identified dependency name as ${dependencyName} with version ${dependencyVersion}. Now it will be bumped in dependent projects.`); - const commitMessageProd = core.getInput('commit_message_prod') || `fix: update ${dependencyName} to ${dependencyVersion} version`; - const commitMessageDev = core.getInput('commit_message_dev') || `chore: update ${dependencyName} to ${dependencyVersion} version`; - const reposToIgnore = core.getInput('repos_to_ignore'); - - const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); - const octokit = github.getOctokit(gitHubKey); - const ignoredRepositories = reposToIgnore ? parseCommaList(reposToIgnore) : []; - //by default repo where workflow runs should always be ignored - ignoredRepositories.push(repo); - - const reposList = await getReposList(octokit, dependencyName, owner); - core.debug('DEBUG: List of all repost returned by search without duplicates:'); - core.debug(JSON.stringify(reposList, null, 2)); + reposList = await getReposList(octokit, dependencyName, owner); + } catch (error) { + core.setFailed(`Action failed while getting list of repos to process: ${ error}`); + } + + core.debug('DEBUG: List of all repost returned by search without duplicates:'); + core.debug(JSON.stringify(reposList, null, 2)); - const foundReposAmount = reposList.length; - if (!foundReposAmount) return core.info(`No dependants found. No version bump performed. Looks like you do not use ${dependencyName} in your organization :man_shrugging:`); + const foundReposAmount = reposList.length; + if (!foundReposAmount) return core.info(`No dependants found. No version bump performed. Looks like you do not use ${dependencyName} in your organization :man_shrugging:`); - core.startGroup(`Iterating over ${foundReposAmount} repos from ${owner} that have ${dependencyName} in their package.json. The following repos will be later ignored: ${ignoredRepositories}`); + core.startGroup(`Iterating over ${foundReposAmount} repos from ${owner} that have ${dependencyName} in their package.json. The following repos will be later ignored: ${ignoredRepositories}`); - for (const {path: filepath, repository: { name, html_url, node_id }} of reposList) { - if (ignoredRepositories.includes(name)) continue; - //Sometimes there might be files like package.json.js or similar as the repository might contain some templated package.json files that cannot be parsed from string to JSON - //Such files must be ignored - if (filepath.substring(filepath.lastIndexOf('/') + 1) !== 'package.json') { - core.info(`Ignoring ${filepath} from ${name} repo as only package.json files are supported`); - continue; - } + for (const {path: filepath, repository: { name, html_url, node_id }} of reposList) { + if (ignoredRepositories.includes(name)) continue; + //Sometimes there might be files like package.json.js or similar as the repository might contain some templated package.json files that cannot be parsed from string to JSON + //Such files must be ignored + if (filepath.substring(filepath.lastIndexOf('/') + 1) !== 'package.json') { + core.info(`Ignoring ${filepath} from ${name} repo as only package.json files are supported`); + continue; + } - const cloneDir = __webpack_require__.ab + "clones/" + name; + const baseBranchWhereApplyChanges = baseBranchName || await getRepoDefaultBranch(octokit, name, owner); + const branchName = `bot/bump-${dependencyName}-${dependencyVersion}`; + const cloneDir = __webpack_require__.ab + "clones/" + name; + + try { await mkdir(cloneDir, {recursive: true}); + } catch (error) { + core.warning(`Unable to create directory where close should end up: ${ error}`); + } - const branchName = `bot/bump-${dependencyName}-${dependencyVersion}`; - const git = simpleGit({baseDir: cloneDir}); + const git = simpleGit({baseDir: cloneDir}); + + core.info(`Clonning ${name} with branch ${baseBranchWhereApplyChanges}.`); + try { + await clone(html_url, cloneDir, baseBranchWhereApplyChanges, git); + } catch (error) { + core.warning(`Cloning failed: ${ error}`); + continue; + } - core.info(`Clonning ${name}.`); - await clone(html_url, cloneDir, git); - - core.info(`Creating branch ${branchName}.`); + core.info(`Creating branch ${branchName}.`); + try { await createBranch(branchName, git); + } catch (error) { + core.warning(`Branch creation failes: ${ error}`); + continue; + } - core.info('Checking if dependency is prod, dev or both'); - const packageJsonLocation = path.join(cloneDir, filepath); - const packageJson = await readPackageJson(packageJsonLocation); - const dependencyType = await verifyDependencyType(packageJson, dependencyName); - if (dependencyType === 'NONE') { - core.info(`We could not find ${dependencyName} neither in dependencies property nor in the devDependencies property. No further steps will be performed. It was found as GitHub search is not perfect and you probably use a package with similar name.`); - continue; - } + core.info('Checking if dependency is prod, dev or both'); + const packageJsonLocation = path.join(cloneDir, filepath); + let packageJson; + let dependencyType; + try { + packageJson = await readPackageJson(packageJsonLocation); + dependencyType = await verifyDependencyType(packageJson, dependencyName); + } catch (error) { + core.warning(`Verification of dependency failed: ${ error}`); + continue; + } + + if (dependencyType === 'NONE') { + core.info(`We could not find ${dependencyName} neither in dependencies property nor in the devDependencies property. No further steps will be performed. It was found as GitHub search is not perfect and you probably use a package with similar name.`); + continue; + } - core.info('Bumping version'); + core.info('Bumping version'); + try { await installDependency(dependencyName, dependencyVersion, packageJsonLocation); - const commitMessage = dependencyType === 'PROD' ? commitMessageProd : commitMessageDev; + } catch (error) { + core.warning(`Dependency installation failed: ${ error}`); + continue; + } + const commitMessage = dependencyType === 'PROD' ? commitMessageProd : commitMessageDev; - core.info('Pushing changes to remote'); + core.info('Pushing changes to remote'); + try { await push(gitHubKey, html_url, branchName, commitMessage, committerUsername, committerEmail, git); + } catch (error) { + core.warning(`Pushing changes failed: ${ error}`); + continue; + } - core.info('Creating PR'); - const pullRequestUrl = await createPr(octokit, branchName, node_id, commitMessage, await getRepoDefaultBranch(octokit, name, owner)); - - core.info(`Finished with success and PR for ${name} is created -> ${pullRequestUrl}`); + let pullRequestUrl; + core.info('Creating PR'); + try { + pullRequestUrl = await createPr(octokit, branchName, node_id, commitMessage, baseBranchWhereApplyChanges); + } catch (error) { + core.warning(`Opening PR failed: ${ error}`); + core.info('Attempting to remove branch that was initially pushed to remote'); + try { + //we should cleanup dead branch from remote if PR creation is not possible + await removeRemoteBranch(branchName, git); + } catch (error) { + core.warning(`Could not remove branch in remote after failed PR creation: ${ error}`); + } + continue; } - - core.endGroup(); - } catch (error) { - core.setFailed(`Action failed because of: ${ error}`); + + core.info(`Finished with success and PR for ${name} is created -> ${pullRequestUrl}`); } + + core.endGroup(); } run(); diff --git a/lib/git.js b/lib/git.js index 19c6bca..521b3cf 100644 --- a/lib/git.js +++ b/lib/git.js @@ -1,13 +1,15 @@ -module.exports = {createBranch, clone, push}; +module.exports = {createBranch, clone, push, removeRemoteBranch}; + +const remoteName = 'auth'; async function createBranch(branchName, git) { return await git .checkout(`-b${branchName}`); } -async function clone(remote, dir, git) { +async function clone(remote, dir, branchName, git) { return await git - .clone(remote, dir, {'--depth': 1}); + .clone(remote, dir, {'--depth': 1, '--branch': branchName}); } async function push(token, url, branchName, message, committerUsername, committerEmail, git) { @@ -21,7 +23,11 @@ async function push(token, url, branchName, message, committerUsername, committe .addConfig('user.name', committerUsername) .addConfig('user.email', committerEmail) .commit(message) - .addRemote('auth', authanticatedUrl(token, url, committerUsername)) - .push(['-u', 'auth', branchName]); + .addRemote(remoteName, authanticatedUrl(token, url, committerUsername)) + .push(['-u', remoteName, branchName]); +} + +async function removeRemoteBranch(branchName, git) { + return await git.push(['-u', remoteName, branchName, '--delete', '--force']); } \ No newline at end of file diff --git a/lib/index.js b/lib/index.js index 4c1650f..aee7bc9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,7 +4,7 @@ const simpleGit = require('simple-git'); const path = require('path'); const {mkdir} = require('fs').promises; -const { createBranch, clone, push } = require('./git'); +const { createBranch, clone, push, removeRemoteBranch } = require('./git'); const { getReposList, createPr, getRepoDefaultBranch } = require('./api-calls'); const { readPackageJson, parseCommaList, verifyDependencyType, installDependency } = require('./utils'); @@ -14,79 +14,128 @@ const { readPackageJson, parseCommaList, verifyDependencyType, installDependency * It looks complex because of extensive usage of core package to log as much as possible */ async function run() { - try { - const gitHubKey = process.env.GITHUB_TOKEN || core.getInput('github_token', { required: true }); - const committerUsername = core.getInput('committer_username') || 'web-flow'; - const committerEmail = core.getInput('committer_email') || 'noreply@github.com'; - const packageJsonPath = process.env.PACKAGE_JSON_LOC || core.getInput('packagejson_path') || './'; - const { name: dependencyName, version: dependencyVersion} = await readPackageJson(path.join(packageJsonPath, 'package.json')); - core.info(`Identified dependency name as ${dependencyName} with version ${dependencyVersion}. Now it will be bumped in dependent projects.`); - const commitMessageProd = core.getInput('commit_message_prod') || `fix: update ${dependencyName} to ${dependencyVersion} version`; - const commitMessageDev = core.getInput('commit_message_dev') || `chore: update ${dependencyName} to ${dependencyVersion} version`; - const reposToIgnore = core.getInput('repos_to_ignore'); + const gitHubKey = process.env.GITHUB_TOKEN || core.getInput('github_token', { required: true }); + const committerUsername = core.getInput('committer_username') || 'web-flow'; + const committerEmail = core.getInput('committer_email') || 'noreply@github.com'; + const packageJsonPath = process.env.PACKAGE_JSON_LOC || core.getInput('packagejson_path') || './'; + const { name: dependencyName, version: dependencyVersion} = await readPackageJson(path.join(packageJsonPath, 'package.json')); + core.info(`Identified dependency name as ${dependencyName} with version ${dependencyVersion}. Now it will be bumped in dependent projects.`); + const commitMessageProd = core.getInput('commit_message_prod') || `fix: update ${dependencyName} to ${dependencyVersion} version`; + const commitMessageDev = core.getInput('commit_message_dev') || `chore: update ${dependencyName} to ${dependencyVersion} version`; + const reposToIgnore = core.getInput('repos_to_ignore'); + const baseBranchName = core.getInput('base_branch'); - const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); - const octokit = github.getOctokit(gitHubKey); - const ignoredRepositories = reposToIgnore ? parseCommaList(reposToIgnore) : []; - //by default repo where workflow runs should always be ignored - ignoredRepositories.push(repo); + const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); + const octokit = github.getOctokit(gitHubKey); + const ignoredRepositories = reposToIgnore ? parseCommaList(reposToIgnore) : []; + //by default repo where workflow runs should always be ignored + let reposList; - const reposList = await getReposList(octokit, dependencyName, owner); - core.debug('DEBUG: List of all repost returned by search without duplicates:'); - core.debug(JSON.stringify(reposList, null, 2)); + try { + reposList = await getReposList(octokit, dependencyName, owner); + } catch (error) { + core.setFailed(`Action failed while getting list of repos to process: ${ error}`); + } - const foundReposAmount = reposList.length; - if (!foundReposAmount) return core.info(`No dependants found. No version bump performed. Looks like you do not use ${dependencyName} in your organization :man_shrugging:`); + core.debug('DEBUG: List of all repost returned by search without duplicates:'); + core.debug(JSON.stringify(reposList, null, 2)); + + const foundReposAmount = reposList.length; + if (!foundReposAmount) return core.info(`No dependants found. No version bump performed. Looks like you do not use ${dependencyName} in your organization :man_shrugging:`); - core.startGroup(`Iterating over ${foundReposAmount} repos from ${owner} that have ${dependencyName} in their package.json. The following repos will be later ignored: ${ignoredRepositories}`); + core.startGroup(`Iterating over ${foundReposAmount} repos from ${owner} that have ${dependencyName} in their package.json. The following repos will be later ignored: ${ignoredRepositories}`); - for (const {path: filepath, repository: { name, html_url, node_id }} of reposList) { - if (ignoredRepositories.includes(name)) continue; - //Sometimes there might be files like package.json.js or similar as the repository might contain some templated package.json files that cannot be parsed from string to JSON - //Such files must be ignored - if (filepath.substring(filepath.lastIndexOf('/') + 1) !== 'package.json') { - core.info(`Ignoring ${filepath} from ${name} repo as only package.json files are supported`); - continue; - } + for (const {path: filepath, repository: { name, html_url, node_id }} of reposList) { + if (ignoredRepositories.includes(name)) continue; + //Sometimes there might be files like package.json.js or similar as the repository might contain some templated package.json files that cannot be parsed from string to JSON + //Such files must be ignored + if (filepath.substring(filepath.lastIndexOf('/') + 1) !== 'package.json') { + core.info(`Ignoring ${filepath} from ${name} repo as only package.json files are supported`); + continue; + } - const cloneDir = path.join(process.cwd(), './clones', name); + const baseBranchWhereApplyChanges = baseBranchName || await getRepoDefaultBranch(octokit, name, owner); + const branchName = `bot/bump-${dependencyName}-${dependencyVersion}`; + const cloneDir = path.join(process.cwd(), './clones', name); + + try { await mkdir(cloneDir, {recursive: true}); + } catch (error) { + core.warning(`Unable to create directory where close should end up: ${ error}`); + } - const branchName = `bot/bump-${dependencyName}-${dependencyVersion}`; - const git = simpleGit({baseDir: cloneDir}); + const git = simpleGit({baseDir: cloneDir}); + + core.info(`Clonning ${name} with branch ${baseBranchWhereApplyChanges}.`); + try { + await clone(html_url, cloneDir, baseBranchWhereApplyChanges, git); + } catch (error) { + core.warning(`Cloning failed: ${ error}`); + continue; + } - core.info(`Clonning ${name}.`); - await clone(html_url, cloneDir, git); - - core.info(`Creating branch ${branchName}.`); + core.info(`Creating branch ${branchName}.`); + try { await createBranch(branchName, git); + } catch (error) { + core.warning(`Branch creation failes: ${ error}`); + continue; + } - core.info('Checking if dependency is prod, dev or both'); - const packageJsonLocation = path.join(cloneDir, filepath); - const packageJson = await readPackageJson(packageJsonLocation); - const dependencyType = await verifyDependencyType(packageJson, dependencyName); - if (dependencyType === 'NONE') { - core.info(`We could not find ${dependencyName} neither in dependencies property nor in the devDependencies property. No further steps will be performed. It was found as GitHub search is not perfect and you probably use a package with similar name.`); - continue; - } + core.info('Checking if dependency is prod, dev or both'); + const packageJsonLocation = path.join(cloneDir, filepath); + let packageJson; + let dependencyType; + try { + packageJson = await readPackageJson(packageJsonLocation); + dependencyType = await verifyDependencyType(packageJson, dependencyName); + } catch (error) { + core.warning(`Verification of dependency failed: ${ error}`); + continue; + } + + if (dependencyType === 'NONE') { + core.info(`We could not find ${dependencyName} neither in dependencies property nor in the devDependencies property. No further steps will be performed. It was found as GitHub search is not perfect and you probably use a package with similar name.`); + continue; + } - core.info('Bumping version'); + core.info('Bumping version'); + try { await installDependency(dependencyName, dependencyVersion, packageJsonLocation); - const commitMessage = dependencyType === 'PROD' ? commitMessageProd : commitMessageDev; + } catch (error) { + core.warning(`Dependency installation failed: ${ error}`); + continue; + } + const commitMessage = dependencyType === 'PROD' ? commitMessageProd : commitMessageDev; - core.info('Pushing changes to remote'); + core.info('Pushing changes to remote'); + try { await push(gitHubKey, html_url, branchName, commitMessage, committerUsername, committerEmail, git); + } catch (error) { + core.warning(`Pushing changes failed: ${ error}`); + continue; + } - core.info('Creating PR'); - const pullRequestUrl = await createPr(octokit, branchName, node_id, commitMessage, await getRepoDefaultBranch(octokit, name, owner)); - - core.info(`Finished with success and PR for ${name} is created -> ${pullRequestUrl}`); + let pullRequestUrl; + core.info('Creating PR'); + try { + pullRequestUrl = await createPr(octokit, branchName, node_id, commitMessage, baseBranchWhereApplyChanges); + } catch (error) { + core.warning(`Opening PR failed: ${ error}`); + core.info('Attempting to remove branch that was initially pushed to remote'); + try { + //we should cleanup dead branch from remote if PR creation is not possible + await removeRemoteBranch(branchName, git); + } catch (error) { + core.warning(`Could not remove branch in remote after failed PR creation: ${ error}`); + } + continue; } - - core.endGroup(); - } catch (error) { - core.setFailed(`Action failed because of: ${ error}`); + + core.info(`Finished with success and PR for ${name} is created -> ${pullRequestUrl}`); } + + core.endGroup(); } run();