Skip to content

Commit

Permalink
refactor and test
Browse files Browse the repository at this point in the history
  • Loading branch information
jtoar committed Feb 28, 2024
1 parent 063cfce commit c2c01fa
Show file tree
Hide file tree
Showing 42 changed files with 1,213 additions and 774 deletions.
11 changes: 11 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
46 changes: 38 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,50 @@
# Release tooling

Release tooling for Redwood. See the [package.json](./package.json) scripts for the available commands.
This repository contains release tooling for the [Redwood monorepo](https://github.com/redwoodjs/redwood).

## Quick Start

- In your shell start-up file (e.g. `~/.zshrc` or `~/.bashrc`), add the following environment variable:
- In your shell start-up file (e.g. `~/.zshrc` or `~/.bashrc`) or a `.env` file (that you create) in this directory, add the following environment variable:

```bash
export RWFW_PATH='/path/to/redwoodjs/redwood'
touch .env

echo "export RWFW_PATH='/path/to/redwoodjs/redwood'" >> .env
echo "export REDWOOD_GITHUB_TOKEN='...'" >> .env
```

Where `RWFW_PATH` is the path to your local copy of the Redwood monorepo and `REDWOOD_GITHUB_TOKEN` is a personal access token with the `public_repo` scope.

- Check out the `main` and `next` branches from the Redwood monorepo's remote.

```bash
cd $RWFW_PATH

git fetch <your-redwood-remote>
git switch main
git switch next
```

Where `RWFW_PATH` is the path to your local copy of the Redwood monorepo.
- Run `yarn install` in this directory. It should fly by! This project uses Yarn's [Zero-installs](https://yarnpkg.com/features/caching#zero-installs).

## Commands

### Triage

Redwood has a dual-branch strategy.
Most of the work involves moving commits from main to next and then from next to a release branch.
The triage command guides you through this process:

```
yarn triage
```

### Release

- Make sure that you've checked out the `main` and `next` branches from the Redwood monorepo's remote and have the latest changes on your machine.
When it's time to release, the release command walks you through the process:

## Notes
```
yarn release
```

- This project uses Yarn's [Zero-installs](https://yarnpkg.com/features/caching#zero-installs)
- The code for release is incomplete. Use the release script in the Redwood monorepo for the time being.
Ensure you have access to the RedwoodJS organization on NPM first.
18 changes: 18 additions & 0 deletions lib/assert_github_token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'

import { assertGitHubToken } from './assert_github_token.js'

describe('assertGitHubToken', () => {
it('works', () => {
expect(process.env.REDWOOD_GITHUB_TOKEN).toBeDefined()
expect(assertGitHubToken).not.toThrow()
})

it("throws if `REDWOOD_GITHUB_TOKEN` isn't defined", () => {
const originalToken = process.env.REDWOOD_GITHUB_TOKEN
delete process.env.REDWOOD_GITHUB_TOKEN
expect(process.env.REDWOOD_GITHUB_TOKEN).toBeUndefined()
expect(assertGitHubToken).toThrow()
process.env.REDWOOD_GITHUB_TOKEN = originalToken
})
})
21 changes: 21 additions & 0 deletions lib/assert_github_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { chalk } from 'zx'

import { CustomError } from './custom_error.js'

export function assertGitHubToken() {
if (process.env.REDWOOD_GITHUB_TOKEN) {
return
}

throw new CustomError([
`The ${chalk.magenta('REDWOOD_GITHUB_TOKEN')} environment variable isn't set. Set it to a GitHub personal access token:`,
'',
` ${chalk.green("export REDWOOD_GITHUB_TOKEN='...'")}`,
'',
`in one of your shell start-up files (e.g. ${chalk.magenta('~/.bashrc')} or ${chalk.magenta('~/.zshrc')})`,
'or in a .env file in this directory that you create.',
'',
`You can create a new personal access token at https://github.com/settings/tokens`,
`All it needs is the ${chalk.magenta('public_repo')} scope`
].join('\n'))
}
32 changes: 32 additions & 0 deletions lib/branches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'

import { branchExists, getReleaseBranchesFromStdout } from './branches.js'

describe('branchExists', () => {
it('works', async () => {
const exists = await branchExists('main')
expect(exists).toBe(true)
})

it("returns false if the branch doesn't exist", async () => {
const exists = await branchExists('nonexistent-branch')
expect(exists).toBe(false)
})
})

describe('getReleaseBranches', () => {
it('works', () => {
const releaseBranches = getReleaseBranchesFromStdout('release/patch/v7.0.4\nrelease/minor/v7.1.0\nrelease/patch/v7.0.5')
expect(releaseBranches).toEqual(["release/minor/v7.1.0", "release/patch/v7.0.5", "release/patch/v7.0.4"])
})

it("returns an empty array if there's no release branches", () => {
const releaseBranches = getReleaseBranchesFromStdout('')
expect(releaseBranches).toEqual([])
})

it('handles a release branch currently being checked out', () => {
const releaseBranches = getReleaseBranchesFromStdout('release/patch/v7.0.4\n* release/patch/v7.0.5')
expect(releaseBranches).toEqual(["release/patch/v7.0.5", "release/patch/v7.0.4"])
})
})
76 changes: 76 additions & 0 deletions lib/branches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import semver from 'semver'
import { chalk, $ } from 'zx'

import { CustomError } from './custom_error.js'
import { unwrap } from './zx_helpers.js'

export async function assertWorkTreeIsClean() {
const workTreeIsClean = unwrap(await $`git status -s`) === ''
if (!workTreeIsClean) {
throw new CustomError(
`The working tree at ${chalk.magenta(process.cwd())} isn't clean. Commit or stash your changes`
);
}
console.log('✨ The working tree is clean')
}

export async function branchExists(branch: string) {
return !!unwrap(await $`git branch --list ${branch}`)
}

export async function assertBranchExistsAndTracksRemote(branch: string, remote: string) {
if (!(await branchExists(branch))) {
throw new CustomError([
`The ${chalk.magenta(branch)} branch doesn't exist locally. Check it out from the Redwood remote:`,
'',
chalk.green(` git checkout -b ${branch} ${remote}/${branch}`),
].join('\n'))
}
console.log(`🏠 The ${chalk.magenta(branch)} branch exists locally`)

const trackingBranch = unwrap(await $`git rev-parse --abbrev-ref ${branch}@{upstream}`)
if (trackingBranch === `${remote}/${branch}`) {
console.log(`🆗 The ${chalk.magenta(branch)} branch tracks ${chalk.magenta(`${remote}/${branch}`)}`)
return
}

throw new CustomError([
`The ${chalk.magenta(branch)} branch doesn't track ${chalk.magenta(`${remote}/${branch}`)}`,
`It's currently tracking ${chalk.magenta(trackingBranch)}`,
'',
`Make it track the remote with:`,
'',
chalk.green(` git branch -u ${remote}/${branch}`),
].join('\n'))
}

export async function pullBranch(branch: string, remote: string) {
await $`git switch ${branch}`
await $`git pull ${remote} ${branch}`
}

export async function pushBranch(branch: string, remote: string) {
await $`git push ${remote} ${branch}`
}

export async function getReleaseBranches() {
const stdout = unwrap(await $`git branch --list release/*`)
return getReleaseBranchesFromStdout(stdout)
}

export function getReleaseBranchesFromStdout(stdout: string) {
if (stdout === '') {
return []
}

const releaseBranches = stdout
.split('\n')
.map((branch) => branch.trim().replace('* ', ''))
.sort((releaseBranchA, releaseBranchB) => {
const [, , versionA] = releaseBranchA.split('/')
const [, , versionB] = releaseBranchB.split('/')
return semver.compare(versionA, versionB)
})

return releaseBranches.reverse()
}
3 changes: 2 additions & 1 deletion lib/cherry_pick.ts → lib/cherry_pick_commits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ export async function cherryPickCommits(commits: Commit[], {
console.log(chalk.green('🌸 Successfully cherry picked'))
await afterCherryPick?.(commit)
break
} catch (error) {
} catch (_error) {
console.log()
console.log(chalk.yellow("✋ Couldn't cleanly cherry pick. Resolve the conflicts and run `git cherry-pick --continue`"))
console.log()
await question('Press anything to continue > ')
await afterCherryPick?.(commit)
break
Expand Down
54 changes: 54 additions & 0 deletions lib/commits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it, test } from 'vitest'

import { getCommitHash, getCommitMessage, getCommitNotes, getCommitPr } from './commits.js'

describe('getCommitHash', () => {
it('works', () => {
const hash = getCommitHash("< 487548234b49bb93bb79ad89c7ac4a91ed6c0dc9 chore(deps): update dependency @playwright/test to v1.41.2 (#10040)")
expect(hash).toEqual('487548234b49bb93bb79ad89c7ac4a91ed6c0dc9')
})

it('throws if no hash is found', () => {
expect(() => getCommitHash("|\\")).toThrowErrorMatchingInlineSnapshot(`
[Error: Couldn't find a commit hash in the line "|\\"
This most likely means that a line that's UI isn't being identified as such]
`)
})

it('works for non left-right lines', () => {
const hash = getCommitHash("487548234b49bb93bb79ad89c7ac4a91ed6c0dc9 chore(deps): update dependency @playwright/test to v1.41.2 (#10040)")
expect(hash).toEqual('487548234b49bb93bb79ad89c7ac4a91ed6c0dc9')

})
})

test('getCommitMessage', async () => {
const message = await getCommitMessage('4f4ad5989b794ddd0065d9c96c3091343c2a63c0')
expect(message).toEqual('Initial commit')
})

describe('getCommitNotes', () => {
it('works', async () => {
const notes = await getCommitNotes('4f4ad5989b794ddd0065d9c96c3091343c2a63c0')
expect(notes).toEqual('(jtoar) hello')
})

it("returns `undefined` there's no notes", async () => {
const notes = await getCommitNotes('9fd38ae1b3ad6afc5b0c1b2acb627b2bed25abda')
expect(notes).toBeUndefined()
})
})

describe('getCommitPr', () => {
it('works', () => {
expect(getCommitPr("< 487548234b49bb93bb79ad89c7ac4a91ed6c0dc9 chore(deps): update dependency @playwright/test to v1.41.2 (#10040)")).toEqual('10040')
})

it('works for reverts', () => {
expect(getCommitPr("< 5f89c0176f517b894cb2c3f1ab9cee4c7c207393 Revert \"Revert `@testing-library/jest-dom` v6 upgrade (#9713)\" (#9719)")).toEqual('9719')
})

it("returns `undefined` if it can't find a PR", () => {
expect(getCommitPr("< 635d6dea677b28993661a2e46659ff8c987b7275 Merge branch 'release/major/v7.0.0'")).toBeUndefined()
})
})
54 changes: 54 additions & 0 deletions lib/commits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { $ } from 'zx'

import { unwrap } from './zx_helpers.js'

/** Square brackets (`[` or `]`) in commit messages need to be escaped */
function sanitizeMessage(message: string) {
return message.replace('[', '\\[').replace(']', '\\]')
}

/** See if a commit has been cherry picked into a given ref */
export async function commitIsInRef(ref: string, message: string) {
message = sanitizeMessage(message)
return unwrap(await $`git log ${ref} --oneline --grep ${message}`)
}

export const commitRegExps = {
hash: /(?<hash>\w{40})\s/,
pr: /\(#(?<pr>\d+)\)$/,
annotatedTag: /^v\d.\d.\d$/,
}

/** Get a commit's 40-character hash */
export function getCommitHash(line: string) {
const match = line.match(commitRegExps.hash)

if (!match?.groups) {
throw new Error([
`Couldn't find a commit hash in the line "${line}"`,
"This most likely means that a line that's UI isn't being identified as such",
].join('\n'))
}

return match.groups.hash
}

/** Get a commit's message from its 40-character hash */
export async function getCommitMessage(hash: string) {
return unwrap(await $`git log --format=%s -n 1 ${hash}`)
}

/** Get a commit's PR if it has one */
export function getCommitPr(message: string) {
return message.match(commitRegExps.pr)?.groups?.pr
}

/** Get a commit's notes if it has any */
export async function getCommitNotes(hash: string) {
try {
const notes = unwrap(await $`git notes show ${hash}`)
return notes
} catch (error) {
return undefined
}
}
21 changes: 21 additions & 0 deletions lib/cwd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'

import { assertRwfwPathAndSetCwd } from './cwd.js'

describe('assertRwfwPathAndSetCwd', () => {
it('works', async () => {
const originalCwd = process.cwd()
const resetCwd = await assertRwfwPathAndSetCwd()
expect(process.cwd()).toEqual(process.env.RWFW_PATH)
resetCwd()
expect(process.cwd()).toEqual(originalCwd)
})

it("throws if RWFW_PATH isn't set", async () => {
const originalRwfwPath = process.env.RWFW_PATH
delete process.env.RWFW_PATH
expect(process.env.RWFW_PATH).toBeUndefined()
await expect(() => assertRwfwPathAndSetCwd()).rejects.toThrowError()
process.env.RWFW_PATH = originalRwfwPath
})
})
Loading

0 comments on commit c2c01fa

Please sign in to comment.