-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add forward-merge action (#67)
In this PR it adds forward merge action to sync `main` and `prerelease/major` branches [category:Infrastructure]
- Loading branch information
1 parent
f5b795f
commit 007e843
Showing
2 changed files
with
386 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]; | ||
} |