Skip to content

Commit

Permalink
Generate github release notes from changelog + tags (#553)
Browse files Browse the repository at this point in the history
* add release script

* add test for scripts

* make changelogPath work with irregular changelog names

* add title

* return array of tags

* fix comment
  • Loading branch information
silesky authored Jul 26, 2022
1 parent 6741775 commit 5cc6a43
Show file tree
Hide file tree
Showing 14 changed files with 443 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .buildkite/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ steps:
agents:
queue: v1
commands:
- npm config set "//registry.npmjs.org/:_authToken" $${NPM_TOKEN}
- echo "--- Install dependencies"
- PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 HUSKY=0 yarn install --immutable
- echo "+++ Run tests"
- yarn constraints
- yarn run test:scripts

- label: "[Browser] Lint + Test"
key: build
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/create-github-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup Node.js 12.x
uses: actions/setup-node@v3
with:
node-version: 12.x
cache: "yarn"
- name: Install Dependencies
run: HUSKY=0 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn install --immutable
- name: Create Github Release From Tags
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "Segment Github"
git config --global user.email "[email protected]"
bash scripts/create-release-from-tags.sh
yarn ts-node-script --files scripts/create-release-from-tags/run.ts
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"scripts": {
"test": "turbo run test",
"test:scripts": "jest --config scripts/jest.config.js",
"lint": "yarn constraints && turbo run lint",
"build": "turbo run build",
"build:packages": "turbo run build --filter='./packages/*'",
Expand All @@ -34,13 +35,15 @@
"devDependencies": {
"@changesets/changelog-github": "^0.4.5",
"@changesets/cli": "^2.23.2",
"@npmcli/promise-spawn": "^3.0.0",
"@types/jest": "^28.1.1",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.21.0",
"concurrently": "^7.2.1",
"eslint": "^8.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"get-monorepo-packages": "^1.2.0",
"husky": "^8.0.0",
"jest": "^28.1.0",
"lint-staged": "^13.0.0",
Expand Down
9 changes: 0 additions & 9 deletions scripts/create-release-from-tags.sh

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# @segment/analytics-core

## 1.99.0

### Minor Changes

* [#606](https://github.com/segmentio/analytics-next/pull/606) [\`b9c6356\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)

### Patch Changes

* [#404](https://github.com/segmentio/analytics-next/pull/404) [\`b9abc6\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)
17 changes: 17 additions & 0 deletions scripts/create-release-from-tags/__tests__/fixtures/reg-example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# @segment/analytics-core

## 1.99.0

### Minor Changes

* [#606](https://github.com/segmentio/analytics-next/pull/606) [\`b9c6356\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)

### Patch Changes

* [#404](https://github.com/segmentio/analytics-next/pull/404) [\`b9abc6\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)

## 1.39.2

### Patch Changes

* [#513](https://github.com/segmentio/analytics-next/pull/513) [\`1d36ca1\`](https://github.com/segmentio/analytics-next/commit/1d36ca1440fc5df9171d16278d8918b3e5a32128) Thanks [@silesky](https://github.com/silesky)! - test
40 changes: 40 additions & 0 deletions scripts/create-release-from-tags/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { parseReleaseNotes } from '..'
import fs from 'fs'
import path from 'path'

const readFixture = (filename: string) => {
return fs.readFileSync(path.join(__dirname, 'fixtures', filename), {
encoding: 'utf8',
})
}

describe('parseReleaseNotes', () => {
test('should work with reg example', () => {
const fixture = readFixture('reg-example.md')
expect(parseReleaseNotes(fixture, '1.99.0')).toMatchInlineSnapshot(`
"
### Minor Changes
* [#606](https://github.com/segmentio/analytics-next/pull/606) [\\\\\`b9c6356\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
### Patch Changes
* [#404](https://github.com/segmentio/analytics-next/pull/404) [\\\\\`b9abc6\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)
"
`)
})

test('should work if first release', () => {
const fixture = readFixture('first-release-example.md')
expect(parseReleaseNotes(fixture, '1.99.0')).toMatchInlineSnapshot(`
"
### Minor Changes
* [#606](https://github.com/segmentio/analytics-next/pull/606) [\\\\\`b9c6356\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
### Patch Changes
* [#404](https://github.com/segmentio/analytics-next/pull/404) [\\\\\`b9abc6\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)"
`)
})
})
192 changes: 192 additions & 0 deletions scripts/create-release-from-tags/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import spawn from '@npmcli/promise-spawn'
import getPackages from 'get-monorepo-packages'
import path from 'path'
import fs from 'fs'
import { exists } from '../utils/exists'

export type Config = {
isDryRun: boolean
tags: Tag[]
}

export type Tag = {
name: string
versionNumber: string
raw: string
}

/**
*
* @returns list of tags
* @example ["@segment/[email protected]", "@segment/[email protected]"]
*/
export const getCurrentGitTags = async (): Promise<Tag[]> => {
const { stdout, stderr, code } = await spawn('git', [
'tag',
'--points-at',
'HEAD',
'--column',
])
if (code !== 0) {
throw new Error(stderr.toString())
}

return parseRawTags(stdout.toString())
}

export const getConfig = async ({
DRY_RUN,
TAGS,
}: NodeJS.ProcessEnv): Promise<Config> => {
const isDryRun = Boolean(DRY_RUN)
const tags = TAGS ? parseRawTags(TAGS) : await getCurrentGitTags()

if (!tags.length) {
throw new Error('No git tags found.')
}
return {
isDryRun,
tags,
}
}

const getChangelogPath = (packageName: string): string | undefined => {
const result = getPackages('.').find((p) =>
p.package.name.includes(packageName)
)
if (!result)
throw new Error(`could not find package with name: ${packageName}.`)

let changelogPath = undefined
for (const fileName of ['CHANGELOG.MD', 'CHANGELOG.md']) {
if (changelogPath) break
const myPath = path.join(result.location, fileName)
const pathExists = fs.existsSync(myPath)
if (pathExists) {
changelogPath = myPath
}
}

if (changelogPath) {
return changelogPath
} else {
console.log(`could not find changelog path for ${result.location}`)
}
}

/**
*
* @returns list of tags
* @example ["@segment/[email protected]", "@segment/[email protected]"]
*/
const createGithubRelease = async (
tag: string,
releaseNotes?: string
): Promise<void> => {
const { stderr, code } = await spawn('gh', [
'release',
'create',
tag,
'--title',
tag,
'--notes',
releaseNotes || '',
])
if (code !== 0) {
throw new Error(stderr.toString())
}
}

/**
*
* @param rawTag - ex. "@segment/[email protected]"
*/
const extractPartsFromTag = (rawTag: string): Tag | undefined => {
const [name, version] = rawTag.split(/@(\d.*)/)
if (!name || !version) return undefined
return {
name,
versionNumber: version?.replace('\n', '') as string,
raw: rawTag,
}
}

/**
*
* @param rawTags - string delimited list of tags (e.g. `@segment/[email protected] @segment/[email protected]`)
*/
export const parseRawTags = (rawTags: string): Tag[] => {
return rawTags.trim().split(' ').map(extractPartsFromTag).filter(exists)
}

/**
*
* @returns the release notes that correspond to a given tag.
*/
export const parseReleaseNotes = (
changelogText: string,
versionNumber: string
): string => {
const h2tag = /(##\s.*\d.*)/gi
let begin: number
let end: number

changelogText.split('\n').forEach((line, idx) => {
if (begin && end) return
if (line.includes(versionNumber)) {
begin = idx + 1
} else if (begin && h2tag.test(line)) {
end = idx - 1
}
})

const result = changelogText.split('\n').filter((_, idx) => {
return idx >= begin && idx <= (end ?? Infinity)
})
return result.join('\n')
}

const getReleaseNotes = (tag: Tag): string | undefined => {
const { name, versionNumber } = tag
const changelogPath = getChangelogPath(name)
if (!changelogPath) {
console.log(`no changelog path for ${name}... skipping.`)
return
}
const changelogText = fs.readFileSync(changelogPath, { encoding: 'utf8' })
const releaseNotes = parseReleaseNotes(changelogText, versionNumber)
if (!releaseNotes) {
console.log(
`Could not find release notes for tags ${tag.raw} in ${changelogPath}.`
)
}
return releaseNotes
}

const createGithubReleaseFromTag = async (
tag: Tag,
{ dryRun = false } = {}
): Promise<void> => {
const notes = getReleaseNotes(tag)
if (notes) {
console.log(
`\n ---> Outputting release titled: ${tag.raw} with notes: \n ${notes}`
)
}

if (dryRun) {
console.log(`--> Dry run: ${tag.raw} not released.`)
return undefined
}

await createGithubRelease(tag.raw, notes)
return undefined
}

export const createReleaseFromTags = async (config: Config) => {
console.log('Processing tags:', config.tags, '\n')

for (const tag of config.tags) {
await createGithubReleaseFromTag(tag, { dryRun: config.isDryRun })
}
}
8 changes: 8 additions & 0 deletions scripts/create-release-from-tags/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createReleaseFromTags, getConfig } from '.'

async function run() {
const config = await getConfig(process.env)
return createReleaseFromTags(config)
}

void run()
10 changes: 10 additions & 0 deletions scripts/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ["**/?(*.)+(test).[jt]s?(x)"],
globals: {
'ts-jest': {
isolatedModules: true,
},
},
}
7 changes: 7 additions & 0 deletions scripts/utils/exists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* This type guard can be passed into a function such as native filter
* in order to remove nullish values from a list in a type-safe way.
*/
export const exists = <T>(value: T): value is NonNullable<T> => {
return value != null && value !== undefined
}
9 changes: 9 additions & 0 deletions typings/get-monorepo-packages.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
declare module 'get-monorepo-packages' {
export default function getPackages(pathToRoot: string): {
location: string
package: {
name: string
version: string
}
}[]
}
10 changes: 10 additions & 0 deletions typings/spawn.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
declare module '@npmcli/promise-spawn' {
import { EventEmitter } from 'events'
import { SpawnOptions } from 'child_process'

export default function spawn(
cmd: string,
args?: string[],
opts?: SpawnOptions
): Promise<{ stdout: Buffer; code: number; stderr: Buffer }> & EventEmitter
}
Loading

0 comments on commit 5cc6a43

Please sign in to comment.