From 007e84336d18e777d341f809b1f44f3c426a1d87 Mon Sep 17 00:00:00 2001 From: Raisa Primerova <48605821+RayRedGoose@users.noreply.github.com> Date: Thu, 7 Dec 2023 11:38:43 -0700 Subject: [PATCH] feat: Add forward-merge action (#67) In this PR it adds forward merge action to sync `main` and `prerelease/major` branches [category:Infrastructure] --- .github/workflows/forward-merge.yml | 145 +++++++++++++++++ scripts/utils/forward-merge.js | 241 ++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 .github/workflows/forward-merge.yml create mode 100644 scripts/utils/forward-merge.js diff --git a/.github/workflows/forward-merge.yml b/.github/workflows/forward-merge.yml new file mode 100644 index 0000000..5b365ab --- /dev/null +++ b/.github/workflows/forward-merge.yml @@ -0,0 +1,145 @@ +name: 'forward-merge' +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + test-ff-only: + runs-on: ubuntu-latest + + steps: + ## First, we'll checkout the repository. We don't persist credentials because we need a + ## Personal Access Token to push on a branch that is protected. See + ## https://github.com/cycjimmy/semantic-release-action#basic-usage + - uses: actions/checkout@v3 + with: + persist-credentials: false + ref: prerelease/major # checkout the next branch + fetch-depth: 0 # Needed to do merges + + ## Attempt to do a fast-forward-only merge. If this succeeds, there is no divergence + ## between the branches and we do not need to retest. The commit has already been + ## verified. If this line fails, it will trigger `verify-merge` + - name: Test ff-only merge + run: git merge origin/main --ff-only + + ## If the previous step passed, push the verified commit directly to the next branch + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GH_RW_TOKEN }} + branch: refs/heads/prerelease/major + + ## If the previous step failed, it means the fast-forward attempt failed. There is a + ## divergence and we will need to merge the branches and verify everything works. + verify-merge: + runs-on: ubuntu-latest + if: failure() + needs: ['test-ff-only'] + + steps: + ## First, we'll checkout the repository. We don't persist credentials because we need a + ## Personal Access Token to push on a branch that is protected. See + ## https://github.com/cycjimmy/semantic-release-action#basic-usage + - uses: actions/checkout@v3 + with: + persist-credentials: false + fetch-depth: 0 # Needed to do merges + + - uses: Workday/canvas-kit-actions/install@v1 + with: + node_version: 18.x + + ## A `yarn bump` will create a commit and a tag. We need to set up the git user to do this. + ## We'll make that user be the github-actions user. + - name: Config git user + run: | + git config --global user.name "${{ github.actor }}" + git config --global user.email "${{ github.actor }}@users.noreply.github.com" + git config --global pull.rebase false + + ## Create a merge branch + - name: Forward merge + run: node scripts/utils/forward-merge.js + + - name: Git Log + run: git log + + # Keep steps separate for Github Actions annotation matching: https://github.com/actions/setup-node/blob/83c9f7a7df54d6b57455f7c57ac414f2ae5fb8de/src/setup-node.ts#L26-L33 + - name: Lint + run: yarn lint + + - name: Type Check + run: yarn typecheck + + - name: Unit tests + run: yarn test + + - name: Build Storybook + run: yarn build-storybook --quiet + + - name: Cache Build + id: build-cache + uses: actions/cache/@v2 + with: + path: docs + key: ${{ runner.os }}-build-${{ github.sha }} + + ## Push both the commit and tag created by Lerna's version command using a PAT + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GH_RW_TOKEN }} + branch: refs/heads/prerelease/major + + ## If we get here, it means the branches are not fast-forward mergeable, OR the merge commit + ## failed verification. We will need manual intervention, so we'll create a pull request to + ## be verified by a person. + make-pull-request: + runs-on: ubuntu-latest + # Run only if the verify-merge job failed. If the test-ff-only fails, but verify-merge passes, we should skip + if: failure() && needs.verify-merge.result == 'failure' + needs: ['verify-merge'] + + steps: + ## If we've failed any previous step, we'll need to create a PR instead + - uses: NicholasBoll/action-forward-merge-pr@main + with: + token: ${{secrets.GH_RW_TOKEN}} # use PAT to force GH Actions to run the PR verify. The regular token will not + branches: main+prerelease/major + prefix: 'chore: ' + body: | + This pull request was automatically created by an automated [forward-merge job](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). The automated job failed automated checks and must be resolved manually. + Reasons for failure may include: + - Merge conflicts that cannot be automatically resolved + - A merge resulted in check failures + - Lint or type errors + - Test failures + - Unexpected visual changes + The pull request should inform you of merge conflicts before you start if any. + 1. Run the following commands in your terminal. If this succeeds, skip step 2. The last command will run a script that tries to merge and resolve conflicts automatically. + ``` + git branch -D merge/main-into-prerelease/major || true + git fetch upstream + git checkout merge/main-into-prerelease/major + git pull upstream merge/main-into-prerelease/major -f + node scripts/utils/forward-merge.js + ``` + 2. If the previous step succeeded, skip to step 3. Resolve conflicts manually. Then run the following. + ``` + git add . + git commit -m "chore: Merge main into prerelease/major" + ``` + 3. Push the merge commit back to the pull request + ``` + git push upstream merge/main-into-prerelease/major + ``` + If there were no merge conflicts, the forward-merge job failed because of a test failure. You can wait for the pull request to give errors, or you can check the logs for failures. You'll have to update code to fix errors. + This pull request will be merged using the `merge` strategy instead of the `squash` strategy. This means any commit in the log will show in the branch's history. Any commit you make should amend the merge commit. Use the following command: + ``` + git commit --amend --no-edit + ``` + You must then force-push the branch and the CI will rerun verification. + Use the `automerge` label like normal and the CI will pick the correct merge strategy. diff --git a/scripts/utils/forward-merge.js b/scripts/utils/forward-merge.js new file mode 100644 index 0000000..41643c9 --- /dev/null +++ b/scripts/utils/forward-merge.js @@ -0,0 +1,241 @@ +#!/usr/bin/env node +// @ts-check +'use strict'; + +const fs = require('node:fs/promises'); +const orderBy = require('lodash/orderBy'); +const {promisify} = require('node:util'); +const {exec: originalExec} = require('node:child_process'); +const exec = promisify(originalExec); +const nodeSpawn = require('node:child_process').spawn; + +// Tokenize and parse command arguments and be aware that anything in quotes is part of a single argument +// For example: `echo "hello there" bob` returns args like `['"hello there"', 'bob'] +function splitArgs(/** @type {string} */ input) { + const [name, ...rest] = input.split(' '); + + /** @type {string[]} */ + const args = []; + let quote = ''; + let currentArg = ''; + for (const l of rest.join(' ')) { + if (l === quote) { + quote = ''; + } else if (l === '"' || l === "'") { + quote = l; + } else { + if (l !== ' ' || quote) { + currentArg += l; + } else { + args.push(currentArg); + currentArg = ''; + } + } + } + + if (currentArg) { + args.push(currentArg); + } + + return {name, args}; +} + +/** */ +async function spawn(/** @type {string} */ cmd) { + console.log(`Running: "${cmd}"`); + const {name, args} = splitArgs(cmd); + + const child = nodeSpawn(name, args); + + for await (const chunk of child.stdout) { + console.log(chunk.toString()); + } + let error = ''; + for await (const chunk of child.stderr) { + console.error(chunk.toString()); + error += chunk.toString(); + } + + // eslint-disable-next-line compat/compat + const exitCode = await new Promise((resolve, reject) => { + child.on('close', resolve); + }); + + if (exitCode) { + throw new Error(`subprocess error exit ${exitCode}, ${error}`); + } + return; +} + +async function main() { + // get the current branch + const {stdout: defaultBranch} = await exec(`git rev-parse --abbrev-ref HEAD`); + const alreadyMerging = defaultBranch.startsWith('merge'); + + let hasConflicts = false; + let hasUnresolvedConflicts = false; + + // create a merge branch + if (!alreadyMerging) { + console.log('Creating a merge branch'); + await spawn(`git checkout -b merge/main-into-prerelease/major`); + } + + // get last commit message. If it was a skip release, we'll try to make the merge commit a skip + // release as well. + const {stdout: commitMessage} = await exec(`git log -1 --pretty=%B | cat`); + const isSkipRelease = commitMessage.includes('[skip release]'); + + try { + console.log(`Creating a merge branch`); + // The CI uses `origin` while locally we use `upstream`. + const remote = alreadyMerging ? 'upstream' : 'origin'; + const {stdout} = await exec( + `git merge ${remote}/prerelease/major -m 'chore: Merge main into prerelease/major${ + isSkipRelease ? ' [skip release]' : '' // continue the skip release if applicable + }'` + ); + // exec doesn't automatically log + console.log(stdout); + + // The merge was successful with no merge conflicts + } catch (result) { + hasConflicts = true; + console.log(`Attempting to automatically resolve conflicts`); + // The merge had conflicts + + /** @type {{stdout: string}} */ + const {stdout} = result; + const lines = stdout.split('\n'); + + // gather the merge conflicts + const conflicts = lines + .filter(line => line.startsWith('CONFLICT')) + .map(line => { + const match = line.match(/Merge conflict in (.+)/); + return (match && match[1]) || ''; + }); + + for (const conflict of conflicts) { + console.log(`Attempting to resolve conflict in ${conflict}`); + + if (conflict === 'lerna.json' || conflict.includes('package.json')) { + // resolve the conflicts by taking incoming file + await spawn(`git checkout --theirs -- "${conflict}"`); + await spawn(`git add ${conflict}`); + + console.log(`Resolved conflicts in ${conflict}`); + } else if (conflict === 'CHANGELOG.md') { + await updateChangelog(); + + console.log(`Resolved conflicts in ${conflict}`); + } else if (conflict === 'yarn.lock') { + // yarn resolves yarn.lock conflicts + console.log(`Conflicts in ${conflict} will be resolved later.`); + } else { + console.log('Merge cannot be resolved automatically'); + hasUnresolvedConflicts = true; + if (!alreadyMerging) { + // If we're not already merging, we want to bail now - this is the default for CI + // If we are already merging, we must be doing things manually + process.exit(1); + } + } + } + } + + await spawn(`yarn install --production=false`); + + if (hasConflicts) { + if (hasUnresolvedConflicts) { + // We have conflicts. Inform the user + console.log(`Conflicts still need to be resolved manually.`); + console.log(`Manually resolve the conflicts, then run the following command:`); + console.log( + `git add . && git commit --no-verify -m "chore: Merge main into prerelease/major" && git push upstream merge/main-into-prerelease/major` + ); + } else { + console.log('All conflicts automatically resolved.'); + + // If we're here, we've fixed all merge conflicts. We need to commit + await spawn(`git add .`); + await spawn(`git commit --no-verify -m "chore: Merge main into prerelease/major"`); + } + } else { + console.log('No conflicts detected'); + } +} + +main().catch(err => { + console.error('Error:\n', err.message); + console.error('Stack:\n', err.stack); + process.exit(1); +}); + +/** + * @param line {string} + */ +function getHeadingMatch(line) { + return line.match(/(#+) (.+)/); +} + +async function updateChangelog() { + let lines = (await fs.readFile('./CHANGELOG.md')).toString().split('\n'); + + const header = lines.splice(0, 5); + const releases = []; + + do { + const [line, ...rest] = lines; + lines = rest; + const headingMatch = getHeadingMatch(line); + if (headingMatch && headingMatch[1].length === 2) { + const [rest, contents] = parseContents(lines); + lines = rest; + const dateMatch = headingMatch[0].match(/\([0-9]+-[0-9]+-[0-9]+\)/); + const date = dateMatch && dateMatch[0]; + + const release = { + title: headingMatch[0], + contents, + date, + }; + releases.push(release); + } + } while (lines.length); + + const sortedReleases = orderBy(releases, 'date', 'desc'); //? + + const contents = [ + ...header, + ...sortedReleases.map(release => [release.title, ...release.contents]).flat(), + ] + // Remove the merge conflict markers - essentially "both" when resolving merge conflicts. We want both release notes if there's a conflict + .filter( + line => !line.startsWith('<<<<<<<') && !line.startsWith('>>>>>>>') && line !== '=======' + ) + .join('\n'); + + await fs.writeFile('./CHANGELOG.md', contents); +} + +/** + * + * @param lines {string[]} + */ +function parseContents(lines) { + const contents = []; + let remainingLines = lines; + do { + const [line, ...rest] = remainingLines; + const headingMatch = getHeadingMatch(line); + if (!headingMatch || headingMatch[1].length !== 2) { + contents.push(line); + } else { + return [remainingLines, contents]; + } + remainingLines = rest; + } while (remainingLines.length); + + return [remainingLines, contents]; +}