Skip to content

Commit 5cc6a43

Browse files
authored
Generate github release notes from changelog + tags (#553)
* add release script * add test for scripts * make changelogPath work with irregular changelog names * add title * return array of tags * fix comment
1 parent 6741775 commit 5cc6a43

14 files changed

+443
-21
lines changed

.buildkite/pipeline.yml

+5
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ steps:
77
agents:
88
queue: v1
99
commands:
10+
- npm config set "//registry.npmjs.org/:_authToken" $${NPM_TOKEN}
11+
- echo "--- Install dependencies"
12+
- PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 HUSKY=0 yarn install --immutable
13+
- echo "+++ Run tests"
1014
- yarn constraints
15+
- yarn run test:scripts
1116

1217
- label: "[Browser] Lint + Test"
1318
key: build

.github/workflows/create-github-release.yml

+8-1
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,17 @@ jobs:
1313
steps:
1414
- name: Checkout Repo
1515
uses: actions/checkout@v3
16+
- name: Setup Node.js 12.x
17+
uses: actions/setup-node@v3
18+
with:
19+
node-version: 12.x
20+
cache: "yarn"
21+
- name: Install Dependencies
22+
run: HUSKY=0 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn install --immutable
1623
- name: Create Github Release From Tags
1724
env:
1825
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1926
run: |
2027
git config --global user.name "Segment Github"
2128
git config --global user.email "[email protected]"
22-
bash scripts/create-release-from-tags.sh
29+
yarn ts-node-script --files scripts/create-release-from-tags/run.ts

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"scripts": {
1313
"test": "turbo run test",
14+
"test:scripts": "jest --config scripts/jest.config.js",
1415
"lint": "yarn constraints && turbo run lint",
1516
"build": "turbo run build",
1617
"build:packages": "turbo run build --filter='./packages/*'",
@@ -34,13 +35,15 @@
3435
"devDependencies": {
3536
"@changesets/changelog-github": "^0.4.5",
3637
"@changesets/cli": "^2.23.2",
38+
"@npmcli/promise-spawn": "^3.0.0",
3739
"@types/jest": "^28.1.1",
3840
"@typescript-eslint/eslint-plugin": "^5.21.0",
3941
"@typescript-eslint/parser": "^5.21.0",
4042
"concurrently": "^7.2.1",
4143
"eslint": "^8.14.0",
4244
"eslint-config-prettier": "^8.5.0",
4345
"eslint-plugin-prettier": "^4.0.0",
46+
"get-monorepo-packages": "^1.2.0",
4447
"husky": "^8.0.0",
4548
"jest": "^28.1.0",
4649
"lint-staged": "^13.0.0",

scripts/create-release-from-tags.sh

-9
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# @segment/analytics-core
2+
3+
## 1.99.0
4+
5+
### Minor Changes
6+
7+
* [#606](https://github.com/segmentio/analytics-next/pull/606) [\`b9c6356\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
8+
9+
### Patch Changes
10+
11+
* [#404](https://github.com/segmentio/analytics-next/pull/404) [\`b9abc6\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# @segment/analytics-core
2+
3+
## 1.99.0
4+
5+
### Minor Changes
6+
7+
* [#606](https://github.com/segmentio/analytics-next/pull/606) [\`b9c6356\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
8+
9+
### Patch Changes
10+
11+
* [#404](https://github.com/segmentio/analytics-next/pull/404) [\`b9abc6\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)
12+
13+
## 1.39.2
14+
15+
### Patch Changes
16+
17+
* [#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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { parseReleaseNotes } from '..'
2+
import fs from 'fs'
3+
import path from 'path'
4+
5+
const readFixture = (filename: string) => {
6+
return fs.readFileSync(path.join(__dirname, 'fixtures', filename), {
7+
encoding: 'utf8',
8+
})
9+
}
10+
11+
describe('parseReleaseNotes', () => {
12+
test('should work with reg example', () => {
13+
const fixture = readFixture('reg-example.md')
14+
expect(parseReleaseNotes(fixture, '1.99.0')).toMatchInlineSnapshot(`
15+
"
16+
### Minor Changes
17+
18+
* [#606](https://github.com/segmentio/analytics-next/pull/606) [\\\\\`b9c6356\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
19+
20+
### Patch Changes
21+
22+
* [#404](https://github.com/segmentio/analytics-next/pull/404) [\\\\\`b9abc6\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)
23+
"
24+
`)
25+
})
26+
27+
test('should work if first release', () => {
28+
const fixture = readFixture('first-release-example.md')
29+
expect(parseReleaseNotes(fixture, '1.99.0')).toMatchInlineSnapshot(`
30+
"
31+
### Minor Changes
32+
33+
* [#606](https://github.com/segmentio/analytics-next/pull/606) [\\\\\`b9c6356\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
34+
35+
### Patch Changes
36+
37+
* [#404](https://github.com/segmentio/analytics-next/pull/404) [\\\\\`b9abc6\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)"
38+
`)
39+
})
40+
})
+192
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import spawn from '@npmcli/promise-spawn'
2+
import getPackages from 'get-monorepo-packages'
3+
import path from 'path'
4+
import fs from 'fs'
5+
import { exists } from '../utils/exists'
6+
7+
export type Config = {
8+
isDryRun: boolean
9+
tags: Tag[]
10+
}
11+
12+
export type Tag = {
13+
name: string
14+
versionNumber: string
15+
raw: string
16+
}
17+
18+
/**
19+
*
20+
* @returns list of tags
21+
* @example ["@segment/[email protected]", "@segment/[email protected]"]
22+
*/
23+
export const getCurrentGitTags = async (): Promise<Tag[]> => {
24+
const { stdout, stderr, code } = await spawn('git', [
25+
'tag',
26+
'--points-at',
27+
'HEAD',
28+
'--column',
29+
])
30+
if (code !== 0) {
31+
throw new Error(stderr.toString())
32+
}
33+
34+
return parseRawTags(stdout.toString())
35+
}
36+
37+
export const getConfig = async ({
38+
DRY_RUN,
39+
TAGS,
40+
}: NodeJS.ProcessEnv): Promise<Config> => {
41+
const isDryRun = Boolean(DRY_RUN)
42+
const tags = TAGS ? parseRawTags(TAGS) : await getCurrentGitTags()
43+
44+
if (!tags.length) {
45+
throw new Error('No git tags found.')
46+
}
47+
return {
48+
isDryRun,
49+
tags,
50+
}
51+
}
52+
53+
const getChangelogPath = (packageName: string): string | undefined => {
54+
const result = getPackages('.').find((p) =>
55+
p.package.name.includes(packageName)
56+
)
57+
if (!result)
58+
throw new Error(`could not find package with name: ${packageName}.`)
59+
60+
let changelogPath = undefined
61+
for (const fileName of ['CHANGELOG.MD', 'CHANGELOG.md']) {
62+
if (changelogPath) break
63+
const myPath = path.join(result.location, fileName)
64+
const pathExists = fs.existsSync(myPath)
65+
if (pathExists) {
66+
changelogPath = myPath
67+
}
68+
}
69+
70+
if (changelogPath) {
71+
return changelogPath
72+
} else {
73+
console.log(`could not find changelog path for ${result.location}`)
74+
}
75+
}
76+
77+
/**
78+
*
79+
* @returns list of tags
80+
* @example ["@segment/[email protected]", "@segment/[email protected]"]
81+
*/
82+
const createGithubRelease = async (
83+
tag: string,
84+
releaseNotes?: string
85+
): Promise<void> => {
86+
const { stderr, code } = await spawn('gh', [
87+
'release',
88+
'create',
89+
tag,
90+
'--title',
91+
tag,
92+
'--notes',
93+
releaseNotes || '',
94+
])
95+
if (code !== 0) {
96+
throw new Error(stderr.toString())
97+
}
98+
}
99+
100+
/**
101+
*
102+
* @param rawTag - ex. "@segment/[email protected]"
103+
*/
104+
const extractPartsFromTag = (rawTag: string): Tag | undefined => {
105+
const [name, version] = rawTag.split(/@(\d.*)/)
106+
if (!name || !version) return undefined
107+
return {
108+
name,
109+
versionNumber: version?.replace('\n', '') as string,
110+
raw: rawTag,
111+
}
112+
}
113+
114+
/**
115+
*
116+
* @param rawTags - string delimited list of tags (e.g. `@segment/[email protected] @segment/[email protected]`)
117+
*/
118+
export const parseRawTags = (rawTags: string): Tag[] => {
119+
return rawTags.trim().split(' ').map(extractPartsFromTag).filter(exists)
120+
}
121+
122+
/**
123+
*
124+
* @returns the release notes that correspond to a given tag.
125+
*/
126+
export const parseReleaseNotes = (
127+
changelogText: string,
128+
versionNumber: string
129+
): string => {
130+
const h2tag = /(##\s.*\d.*)/gi
131+
let begin: number
132+
let end: number
133+
134+
changelogText.split('\n').forEach((line, idx) => {
135+
if (begin && end) return
136+
if (line.includes(versionNumber)) {
137+
begin = idx + 1
138+
} else if (begin && h2tag.test(line)) {
139+
end = idx - 1
140+
}
141+
})
142+
143+
const result = changelogText.split('\n').filter((_, idx) => {
144+
return idx >= begin && idx <= (end ?? Infinity)
145+
})
146+
return result.join('\n')
147+
}
148+
149+
const getReleaseNotes = (tag: Tag): string | undefined => {
150+
const { name, versionNumber } = tag
151+
const changelogPath = getChangelogPath(name)
152+
if (!changelogPath) {
153+
console.log(`no changelog path for ${name}... skipping.`)
154+
return
155+
}
156+
const changelogText = fs.readFileSync(changelogPath, { encoding: 'utf8' })
157+
const releaseNotes = parseReleaseNotes(changelogText, versionNumber)
158+
if (!releaseNotes) {
159+
console.log(
160+
`Could not find release notes for tags ${tag.raw} in ${changelogPath}.`
161+
)
162+
}
163+
return releaseNotes
164+
}
165+
166+
const createGithubReleaseFromTag = async (
167+
tag: Tag,
168+
{ dryRun = false } = {}
169+
): Promise<void> => {
170+
const notes = getReleaseNotes(tag)
171+
if (notes) {
172+
console.log(
173+
`\n ---> Outputting release titled: ${tag.raw} with notes: \n ${notes}`
174+
)
175+
}
176+
177+
if (dryRun) {
178+
console.log(`--> Dry run: ${tag.raw} not released.`)
179+
return undefined
180+
}
181+
182+
await createGithubRelease(tag.raw, notes)
183+
return undefined
184+
}
185+
186+
export const createReleaseFromTags = async (config: Config) => {
187+
console.log('Processing tags:', config.tags, '\n')
188+
189+
for (const tag of config.tags) {
190+
await createGithubReleaseFromTag(tag, { dryRun: config.isDryRun })
191+
}
192+
}
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { createReleaseFromTags, getConfig } from '.'
2+
3+
async function run() {
4+
const config = await getConfig(process.env)
5+
return createReleaseFromTags(config)
6+
}
7+
8+
void run()

scripts/jest.config.js

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
testMatch: ["**/?(*.)+(test).[jt]s?(x)"],
5+
globals: {
6+
'ts-jest': {
7+
isolatedModules: true,
8+
},
9+
},
10+
}

scripts/utils/exists.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* This type guard can be passed into a function such as native filter
3+
* in order to remove nullish values from a list in a type-safe way.
4+
*/
5+
export const exists = <T>(value: T): value is NonNullable<T> => {
6+
return value != null && value !== undefined
7+
}

typings/get-monorepo-packages.d.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
declare module 'get-monorepo-packages' {
2+
export default function getPackages(pathToRoot: string): {
3+
location: string
4+
package: {
5+
name: string
6+
version: string
7+
}
8+
}[]
9+
}

typings/spawn.d.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
declare module '@npmcli/promise-spawn' {
2+
import { EventEmitter } from 'events'
3+
import { SpawnOptions } from 'child_process'
4+
5+
export default function spawn(
6+
cmd: string,
7+
args?: string[],
8+
opts?: SpawnOptions
9+
): Promise<{ stdout: Buffer; code: number; stderr: Buffer }> & EventEmitter
10+
}

0 commit comments

Comments
 (0)