├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.config.ts ├── cli.mjs ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── cli.ts ├── config.ts ├── generate.ts ├── git.ts ├── github.ts ├── index.ts ├── markdown.ts ├── parse.ts └── types.ts ├── test └── git.test.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [antfu] 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Set node 19 | uses: actions/setup-node@v4 20 | with: 21 | registry-url: https://registry.npmjs.org/ 22 | node-version: lts/* 23 | 24 | - name: Setup 25 | run: npm i -g @antfu/ni 26 | 27 | - name: Install 28 | run: nci 29 | 30 | - run: npm start 31 | env: 32 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 33 | 34 | - run: npm publish 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Changelogen", 4 | "changelogithub" 5 | ], 6 | 7 | // Enable the flat config support 8 | "eslint.experimental.useFlatConfig": true, 9 | 10 | // Disable the default formatter 11 | "prettier.enable": false, 12 | "editor.formatOnSave": false, 13 | 14 | // Auto fix 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll": "explicit", 17 | "source.organizeImports": "never" 18 | }, 19 | 20 | // Silent the stylistic rules in you IDE, but still auto fix them 21 | "eslint.rules.customizations": [ 22 | { "rule": "@stylistic/*", "severity": "off" }, 23 | { "rule": "*-indent", "severity": "off" }, 24 | { "rule": "*-spacing", "severity": "off" }, 25 | { "rule": "*-spaces", "severity": "off" }, 26 | { "rule": "*-order", "severity": "off" }, 27 | { "rule": "*-dangle", "severity": "off" }, 28 | { "rule": "*-newline", "severity": "off" }, 29 | { "rule": "*quotes", "severity": "off" }, 30 | { "rule": "*semi", "severity": "off" } 31 | ], 32 | 33 | "eslint.validate": [ 34 | "javascript", 35 | "javascriptreact", 36 | "typescript", 37 | "typescriptreact", 38 | "vue", 39 | "html", 40 | "markdown", 41 | "json", 42 | "jsonc", 43 | "yaml" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please refer to https://github.com/antfu/contribute 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Anthony Fu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # changelogithub 2 | 3 | [![NPM version](https://img.shields.io/npm/v/changelogithub?color=a1b858&label=)](https://www.npmjs.com/package/changelogithub) 4 | 5 | Generate changelog for GitHub releases from [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), powered by [changelogen](https://github.com/unjs/changelogen). 6 | 7 | [👉 Changelog example](https://github.com/unocss/unocss/releases/tag/v0.39.0) 8 | 9 | ## Features 10 | 11 | - Support exclamation mark as breaking change, e.g. `chore!: drop node v10` 12 | - Grouped scope in changelog 13 | - Create the release note, or update the existing one 14 | - List contributors 15 | 16 | ## Usage 17 | 18 | In GitHub Actions: 19 | 20 | ```yml 21 | # .github/workflows/release.yml 22 | 23 | name: Release 24 | 25 | permissions: 26 | contents: write 27 | 28 | on: 29 | push: 30 | tags: 31 | - 'v*' 32 | 33 | jobs: 34 | release: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | with: 39 | fetch-depth: 0 40 | 41 | - name: Set node 42 | uses: actions/setup-node@v4 43 | with: 44 | registry-url: https://registry.npmjs.org/ 45 | node-version: lts/* 46 | 47 | - run: npx changelogithub # or changelogithub@0.12 to ensure a stable result 48 | env: 49 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 50 | ``` 51 | 52 | It will be trigged whenever you push a tag to GitHub that starts with `v`. 53 | 54 | ## Configuration 55 | 56 | You can put a configuration file in the project root, named as `changelogithub.config.{json,ts,js,mjs,cjs}`, `.changelogithubrc` or use the `changelogithub` field in `package.json`. 57 | 58 | ## Preview Locally 59 | 60 | ```bash 61 | npx changelogithub --dry 62 | ``` 63 | 64 | ## Why? 65 | 66 | I used to use [`conventional-github-releaser`](https://github.com/conventional-changelog/releaser-tools/tree/master/packages/conventional-github-releaser) for almost all my projects. Until I found that it [does NOT support using exclamation marks for breaking changes](https://github.com/conventional-changelog/conventional-changelog/issues/648) - hiding those important breaking changes in the changelog without the awareness from maintainers. 67 | 68 | ## License 69 | 70 | [MIT](./LICENSE) License © 2022 [Anthony Fu](https://github.com/antfu) 71 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | entries: [ 5 | 'src/index', 6 | 'src/cli', 7 | ], 8 | declaration: true, 9 | clean: true, 10 | rollup: { 11 | emitCJS: true, 12 | inlineDependencies: [ 13 | '@antfu/utils', 14 | ], 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // eslint-disable-next-line antfu/no-import-dist 3 | import './dist/cli.mjs' 4 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu() 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "changelogithub", 3 | "type": "module", 4 | "version": "13.15.0", 5 | "packageManager": "pnpm@10.11.0", 6 | "description": "Generate changelog for GitHub.", 7 | "author": "Anthony Fu ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/antfu", 10 | "homepage": "https://github.com/antfu/changelogithub#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/antfu/changelogithub.git" 14 | }, 15 | "bugs": "https://github.com/antfu/changelogithub/issues", 16 | "keywords": [ 17 | "github", 18 | "release", 19 | "releases", 20 | "conventional", 21 | "changelog", 22 | "log" 23 | ], 24 | "sideEffects": false, 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "import": "./dist/index.mjs", 29 | "require": "./dist/index.cjs" 30 | } 31 | }, 32 | "main": "./dist/index.mjs", 33 | "module": "./dist/index.mjs", 34 | "types": "./dist/index.d.ts", 35 | "bin": "./cli.mjs", 36 | "files": [ 37 | "*.mjs", 38 | "dist" 39 | ], 40 | "engines": { 41 | "node": ">=12.0.0" 42 | }, 43 | "scripts": { 44 | "build": "unbuild", 45 | "dev": "unbuild --stub", 46 | "test": "vitest", 47 | "lint": "eslint .", 48 | "prepublishOnly": "nr build", 49 | "release": "bumpp --commit --push --tag", 50 | "start": "nr dev && node cli.mjs", 51 | "typecheck": "tsc --noEmit" 52 | }, 53 | "dependencies": { 54 | "ansis": "^4.1.0", 55 | "c12": "^3.0.4", 56 | "cac": "^6.7.14", 57 | "changelogen": "0.5.7", 58 | "convert-gitmoji": "^0.1.5", 59 | "execa": "^9.6.0", 60 | "ofetch": "^1.4.1", 61 | "semver": "^7.7.2" 62 | }, 63 | "devDependencies": { 64 | "@antfu/eslint-config": "^4.13.2", 65 | "@antfu/utils": "^9.2.0", 66 | "@types/debug": "^4.1.12", 67 | "@types/fs-extra": "^11.0.4", 68 | "@types/minimist": "^1.2.5", 69 | "@types/semver": "^7.7.0", 70 | "bumpp": "^10.1.1", 71 | "eslint": "^9.27.0", 72 | "fs-extra": "^11.3.0", 73 | "typescript": "^5.8.3", 74 | "unbuild": "^3.5.0", 75 | "vitest": "^3.1.4" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - unrs-resolver 3 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'node:fs/promises' 4 | import process from 'node:process' 5 | import { blue, bold, cyan, dim, red, yellow } from 'ansis' 6 | import cac from 'cac' 7 | import { execa } from 'execa' 8 | import { version } from '../package.json' 9 | import { uploadAssets } from './github' 10 | import { generate, hasTagOnGitHub, isRepoShallow, sendRelease } from './index' 11 | 12 | const cli = cac('changelogithub') 13 | 14 | cli 15 | .version(version) 16 | .option('-t, --token ', 'GitHub Token') 17 | .option('--from ', 'From tag') 18 | .option('--to ', 'To tag') 19 | .option('--github ', 'GitHub Repository, e.g. antfu/changelogithub') 20 | .option('--release-github ', 'Release GitHub Repository, defaults to `github`') 21 | .option('--name ', 'Name of the release') 22 | .option('--contributors', 'Show contributors section') 23 | .option('--prerelease', 'Mark release as prerelease') 24 | .option('-d, --draft', 'Mark release as draft') 25 | .option('--output ', 'Output to file instead of sending to GitHub') 26 | .option('--capitalize', 'Should capitalize for each comment message') 27 | .option('--emoji', 'Use emojis in section titles', { default: true }) 28 | .option('--group', 'Nest commit messages under their scopes') 29 | .option('--dry', 'Dry run') 30 | .option('--assets ', 'Files to upload as assets to the release') 31 | .help() 32 | 33 | async function readTokenFromGitHubCli() { 34 | try { 35 | return (await execa('gh', ['auth', 'token'])).stdout.trim() 36 | } 37 | catch { 38 | return '' 39 | } 40 | } 41 | 42 | cli 43 | .command('') 44 | .action(async (args) => { 45 | const token = args.token || process.env.GITHUB_TOKEN || await readTokenFromGitHubCli() 46 | 47 | if (token) { 48 | args.token = token 49 | } 50 | 51 | let webUrl = '' 52 | 53 | try { 54 | console.log() 55 | console.log(dim(`changelo${bold('github')} `) + dim(`v${version}`)) 56 | 57 | const { config, md, commits } = await generate(args as any) 58 | webUrl = `https://${config.baseUrl}/${config.releaseRepo}/releases/new?title=${encodeURIComponent(String(config.name || config.to))}&body=${encodeURIComponent(String(md))}&tag=${encodeURIComponent(String(config.to))}&prerelease=${config.prerelease}` 59 | 60 | console.log(cyan(config.from) + dim(' -> ') + blue(config.to) + dim(` (${commits.length} commits)`)) 61 | console.log(dim('--------------')) 62 | console.log() 63 | console.log(md.replace(/ /g, '')) 64 | console.log() 65 | console.log(dim('--------------')) 66 | 67 | function printWebUrl() { 68 | console.log() 69 | console.error(yellow('Using the following link to create it manually:')) 70 | console.error(yellow(webUrl)) 71 | console.log() 72 | } 73 | 74 | if (config.dry) { 75 | console.log(yellow('Dry run. Release skipped.')) 76 | printWebUrl() 77 | return 78 | } 79 | 80 | if (typeof config.output === 'string') { 81 | await fs.writeFile(config.output, md, 'utf-8') 82 | console.log(yellow(`Saved to ${config.output}`)) 83 | return 84 | } 85 | 86 | if (!config.token) { 87 | console.error(red('No GitHub token found, specify it via GITHUB_TOKEN env. Release skipped.')) 88 | process.exitCode = 1 89 | printWebUrl() 90 | return 91 | } 92 | 93 | if (!await hasTagOnGitHub(config.to, config)) { 94 | console.error(yellow(`Current ref "${bold(config.to)}" is not available as tags on GitHub. Release skipped.`)) 95 | process.exitCode = 1 96 | printWebUrl() 97 | return 98 | } 99 | 100 | if (!commits.length && await isRepoShallow()) { 101 | console.error(yellow('The repo seems to be clone shallowly, which make changelog failed to generate. You might want to specify `fetch-depth: 0` in your CI config.')) 102 | process.exitCode = 1 103 | printWebUrl() 104 | return 105 | } 106 | 107 | await sendRelease(config, md) 108 | 109 | if (args.assets && args.assets.length > 0) { 110 | await uploadAssets(config, args.assets) 111 | } 112 | } 113 | catch (e: any) { 114 | console.error(red(String(e))) 115 | if (e?.stack) 116 | console.error(dim(e.stack?.split('\n').slice(1).join('\n'))) 117 | 118 | if (webUrl) { 119 | console.log() 120 | console.error(red('Failed to create the release. Using the following link to create it manually:')) 121 | console.error(yellow(webUrl)) 122 | console.log() 123 | } 124 | 125 | process.exit(1) 126 | } 127 | }) 128 | 129 | cli.parse() 130 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import type { ChangelogOptions, ResolvedChangelogOptions } from './types' 2 | import { getCurrentGitBranch, getFirstGitCommit, getGitHubRepo, getLastMatchingTag, getSafeTagTemplate, isPrerelease } from './git' 3 | 4 | export function defineConfig(config: ChangelogOptions) { 5 | return config 6 | } 7 | 8 | const defaultConfig = { 9 | scopeMap: {}, 10 | types: { 11 | feat: { title: '🚀 Features' }, 12 | fix: { title: '🐞 Bug Fixes' }, 13 | perf: { title: '🏎 Performance' }, 14 | }, 15 | titles: { 16 | breakingChanges: '🚨 Breaking Changes', 17 | }, 18 | contributors: true, 19 | capitalize: true, 20 | group: true, 21 | tag: 'v%s', 22 | } satisfies ChangelogOptions 23 | 24 | export async function resolveConfig(options: ChangelogOptions) { 25 | const { loadConfig } = await import('c12') 26 | const config = await loadConfig({ 27 | name: 'changelogithub', 28 | defaults: defaultConfig, 29 | overrides: options, 30 | packageJson: 'changelogithub', 31 | }).then(r => r.config || defaultConfig) 32 | 33 | config.baseUrl = config.baseUrl ?? 'github.com' 34 | config.baseUrlApi = config.baseUrlApi ?? 'api.github.com' 35 | config.to = config.to || await getCurrentGitBranch() 36 | config.tagFilter = config.tagFilter ?? (() => true) 37 | config.tag = getSafeTagTemplate(config.tag ?? defaultConfig.tag) 38 | config.from = config.from || await getLastMatchingTag( 39 | config.to, 40 | config.tagFilter, 41 | config.tag, 42 | ) || await getFirstGitCommit() 43 | // @ts-expect-error backward compatibility 44 | config.repo = config.repo || config.github || await getGitHubRepo(config.baseUrl) 45 | // @ts-expect-error backward compatibility 46 | config.releaseRepo = config.releaseRepo || config.releaseGithub || config.repo 47 | config.prerelease = config.prerelease ?? isPrerelease(config.to) 48 | 49 | if (typeof config.repo !== 'string') 50 | throw new Error(`Invalid GitHub repository, expected a string but got ${JSON.stringify(config.repo)}`) 51 | 52 | return config as ResolvedChangelogOptions 53 | } 54 | -------------------------------------------------------------------------------- /src/generate.ts: -------------------------------------------------------------------------------- 1 | import type { ChangelogOptions } from './types' 2 | import { getGitDiff } from 'changelogen' 3 | import { resolveConfig } from './config' 4 | import { resolveAuthors } from './github' 5 | import { generateMarkdown } from './markdown' 6 | import { parseCommits } from './parse' 7 | 8 | export async function generate(options: ChangelogOptions) { 9 | const resolved = await resolveConfig(options) 10 | 11 | const rawCommits = await getGitDiff(resolved.from, resolved.to) 12 | const commits = parseCommits(rawCommits, resolved) 13 | if (resolved.contributors) 14 | await resolveAuthors(commits, resolved) 15 | const md = generateMarkdown(commits, resolved) 16 | 17 | return { config: resolved, md, commits } 18 | } 19 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import semver from 'semver' 2 | 3 | export async function getGitHubRepo(baseUrl: string) { 4 | const url = await execCommand('git', ['config', '--get', 'remote.origin.url']) 5 | const escapedBaseUrl = baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 6 | const regex = new RegExp(`${escapedBaseUrl}[\/:]([\\w\\d._-]+?)\\/([\\w\\d._-]+?)(\\.git)?$`, 'i') 7 | const match = regex.exec(url) 8 | if (!match) 9 | throw new Error(`Can not parse GitHub repo from url ${url}`) 10 | return `${match[1]}/${match[2]}` 11 | } 12 | 13 | export async function getCurrentGitBranch() { 14 | return await execCommand('git', ['tag', '--points-at', 'HEAD']) || await execCommand('git', ['rev-parse', '--abbrev-ref', 'HEAD']) 15 | } 16 | 17 | export async function isRepoShallow() { 18 | return (await execCommand('git', ['rev-parse', '--is-shallow-repository'])).trim() === 'true' 19 | } 20 | 21 | export function getSafeTagTemplate(template: string) { 22 | return template.includes('%s') ? template : `${template}%s` 23 | } 24 | 25 | function getVersionString(template: string, tag: string) { 26 | const pattern = template.replace(/%s/g, '(.+)') 27 | const regex = new RegExp(`^${pattern}$`) 28 | const match = regex.exec(tag) 29 | return match ? match[1] : tag 30 | } 31 | 32 | export async function getGitTags() { 33 | const output = await execCommand('git', [ 34 | 'log', 35 | '--simplify-by-decoration', 36 | '--pretty=format:"%d"', 37 | ]) 38 | 39 | const tagRegex = /tag: ([^,)]+)/g 40 | const tagList: string[] = [] 41 | let match 42 | 43 | while (match !== null) { 44 | const tag = match?.[1].trim() 45 | if (tag) { 46 | tagList.push(tag) 47 | } 48 | match = tagRegex.exec(output) 49 | } 50 | 51 | return tagList 52 | } 53 | 54 | export async function getLastMatchingTag( 55 | inputTag: string, 56 | tagFilter: (tag: string) => boolean, 57 | tagTemplate: string, 58 | ) { 59 | const inputVersionString = getVersionString(tagTemplate, inputTag) 60 | const isVersion = semver.valid(inputVersionString) !== null 61 | const isPrerelease = semver.prerelease(inputVersionString) !== null 62 | const tags = await getGitTags() 63 | const filteredTags = tags.filter(tagFilter) 64 | 65 | let tag: string | undefined 66 | // Doing a stable release, find the last stable release to compare with 67 | if (!isPrerelease && isVersion) { 68 | tag = filteredTags.find((tag) => { 69 | const versionString = getVersionString(tagTemplate, tag) 70 | 71 | return versionString !== inputVersionString 72 | && semver.valid(versionString) !== null 73 | && semver.prerelease(versionString) === null 74 | }) 75 | } 76 | 77 | // Fallback to the last tag, that are not the input tag 78 | tag ||= filteredTags.find(tag => tag !== inputTag) 79 | return tag 80 | } 81 | 82 | export async function isRefGitTag(to: string) { 83 | const { execa } = await import('execa') 84 | try { 85 | await execa('git', ['show-ref', '--verify', `refs/tags/${to}`], { reject: true }) 86 | } 87 | catch { 88 | return false 89 | } 90 | } 91 | 92 | export async function getFirstGitCommit() { 93 | return await execCommand('git', ['rev-list', '--max-parents=0', 'HEAD']) 94 | } 95 | 96 | export function isPrerelease(version: string) { 97 | return !/^[^.]*(?:\.[\d.]*|\d)$/.test(version) 98 | } 99 | 100 | async function execCommand(cmd: string, args: string[]) { 101 | const { execa } = await import('execa') 102 | const res = await execa(cmd, args) 103 | return res.stdout.trim() 104 | } 105 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import type { AuthorInfo, ChangelogOptions, Commit } from './types' 2 | import fs from 'node:fs/promises' 3 | import path from 'node:path' 4 | /* eslint-disable no-console */ 5 | import { notNullish } from '@antfu/utils' 6 | import { cyan, green, red } from 'ansis' 7 | import { $fetch } from 'ofetch' 8 | 9 | export async function sendRelease( 10 | options: ChangelogOptions, 11 | content: string, 12 | ) { 13 | const headers = getHeaders(options) 14 | let url = `https://${options.baseUrlApi}/repos/${options.releaseRepo}/releases` 15 | let method = 'POST' 16 | 17 | try { 18 | const exists = await $fetch(`https://${options.baseUrlApi}/repos/${options.releaseRepo}/releases/tags/${options.to}`, { 19 | headers, 20 | }) 21 | if (exists.url) { 22 | url = exists.url 23 | method = 'PATCH' 24 | } 25 | } 26 | catch { 27 | } 28 | 29 | const body = { 30 | body: content, 31 | draft: options.draft || false, 32 | name: options.name || options.to, 33 | prerelease: options.prerelease, 34 | tag_name: options.to, 35 | } 36 | console.log(cyan(method === 'POST' 37 | ? 'Creating release notes...' 38 | : 'Updating release notes...'), 39 | ) 40 | const res = await $fetch(url, { 41 | method, 42 | body: JSON.stringify(body), 43 | headers, 44 | }) 45 | console.log(green(`Released on ${res.html_url}`)) 46 | } 47 | 48 | function getHeaders(options: ChangelogOptions) { 49 | return { 50 | accept: 'application/vnd.github.v3+json', 51 | authorization: `token ${options.token}`, 52 | } 53 | } 54 | 55 | const excludeAuthors = [ 56 | /\[bot\]/i, 57 | /dependabot/i, 58 | /\(bot\)/i, 59 | ] 60 | 61 | export async function resolveAuthorInfo(options: ChangelogOptions, info: AuthorInfo) { 62 | if (info.login) 63 | return info 64 | 65 | // token not provided, skip github resolving 66 | if (!options.token) 67 | return info 68 | 69 | try { 70 | // https://docs.github.com/en/search-github/searching-on-github/searching-users#search-only-users-or-organizations 71 | const q = encodeURIComponent(`${info.email} type:user in:email`) 72 | const data = await $fetch(`https://${options.baseUrlApi}/search/users?q=${q}`, { 73 | headers: getHeaders(options), 74 | }) 75 | info.login = data.items[0].login 76 | } 77 | catch {} 78 | 79 | if (info.login) 80 | return info 81 | 82 | if (info.commits.length) { 83 | try { 84 | const data = await $fetch(`https://${options.baseUrlApi}/repos/${options.repo}/commits/${info.commits[0]}`, { 85 | headers: getHeaders(options), 86 | }) 87 | info.login = data.author.login 88 | } 89 | catch {} 90 | } 91 | 92 | return info 93 | } 94 | 95 | export async function resolveAuthors(commits: Commit[], options: ChangelogOptions) { 96 | const map = new Map() 97 | commits.forEach((commit) => { 98 | commit.resolvedAuthors = commit.authors.map((a, idx) => { 99 | if (!a.email || !a.name) 100 | return null 101 | if (excludeAuthors.some(re => re.test(a.name))) 102 | return null 103 | if (!map.has(a.email)) { 104 | map.set(a.email, { 105 | commits: [], 106 | name: a.name, 107 | email: a.email, 108 | }) 109 | } 110 | const info = map.get(a.email)! 111 | 112 | // record commits only for the first author 113 | if (idx === 0) 114 | info.commits.push(commit.shortHash) 115 | 116 | return info 117 | }).filter(notNullish) 118 | }) 119 | const authors = Array.from(map.values()) 120 | const resolved = await Promise.all(authors.map(info => resolveAuthorInfo(options, info))) 121 | 122 | const loginSet = new Set() 123 | const nameSet = new Set() 124 | return resolved 125 | .sort((a, b) => (a.login || a.name).localeCompare(b.login || b.name)) 126 | .filter((i) => { 127 | if (i.login && loginSet.has(i.login)) 128 | return false 129 | if (i.login) { 130 | loginSet.add(i.login) 131 | } 132 | else { 133 | if (nameSet.has(i.name)) 134 | return false 135 | nameSet.add(i.name) 136 | } 137 | return true 138 | }) 139 | } 140 | 141 | export async function hasTagOnGitHub(tag: string, options: ChangelogOptions) { 142 | try { 143 | await $fetch(`https://${options.baseUrlApi}/repos/${options.repo}/git/ref/tags/${tag}`, { 144 | headers: getHeaders(options), 145 | }) 146 | return true 147 | } 148 | catch { 149 | return false 150 | } 151 | } 152 | 153 | export async function uploadAssets(options: ChangelogOptions, assets: string | string[]) { 154 | const headers = getHeaders(options) 155 | 156 | let assetList: string[] = [] 157 | if (typeof assets === 'string') { 158 | assetList = assets.split(',').map(s => s.trim()).filter(Boolean) 159 | } 160 | else if (Array.isArray(assets)) { 161 | assetList = assets.flatMap(item => 162 | typeof item === 'string' ? item.split(',').map(s => s.trim()) : [], 163 | ).filter(Boolean) 164 | } 165 | 166 | // Get the release by tag to obtain the upload_url 167 | const release = await $fetch(`https://${options.baseUrlApi}/repos/${options.releaseRepo}/releases/tags/${options.to}`, { 168 | headers, 169 | }) 170 | 171 | for (const asset of assetList) { 172 | const filePath = path.resolve(asset) 173 | const fileData = await fs.readFile(filePath) 174 | const fileName = path.basename(filePath) 175 | const contentType = 'application/octet-stream' 176 | 177 | const uploadUrl = release.upload_url.replace('{?name,label}', `?name=${encodeURIComponent(fileName)}`) 178 | console.log(cyan(`Uploading ${fileName}...`)) 179 | try { 180 | await $fetch(uploadUrl, { 181 | method: 'POST', 182 | headers: { 183 | ...headers, 184 | 'Content-Type': contentType, 185 | }, 186 | body: fileData, 187 | }) 188 | console.log(green(`Uploaded ${fileName} successfully.`)) 189 | } 190 | catch (error) { 191 | console.error(red(`Failed to upload ${fileName}: ${error}`)) 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config' 2 | export * from './generate' 3 | export * from './git' 4 | export * from './github' 5 | export * from './markdown' 6 | export * from './parse' 7 | export * from './types' 8 | -------------------------------------------------------------------------------- /src/markdown.ts: -------------------------------------------------------------------------------- 1 | import type { Reference } from 'changelogen' 2 | import type { Commit, ResolvedChangelogOptions } from './types' 3 | import { partition } from '@antfu/utils' 4 | import { convert } from 'convert-gitmoji' 5 | 6 | const emojisRE = /([\u2700-\u27BF\uE000-\uF8FF\u2011-\u26FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|\uD83E[\uDD10-\uDDFF])/g 7 | 8 | function formatReferences(references: Reference[], baseUrl: string, github: string, type: 'issues' | 'hash'): string { 9 | const refs = references 10 | .filter((i) => { 11 | if (type === 'issues') 12 | return i.type === 'issue' || i.type === 'pull-request' 13 | return i.type === 'hash' 14 | }) 15 | .map((ref) => { 16 | if (!github) 17 | return ref.value 18 | if (ref.type === 'pull-request' || ref.type === 'issue') 19 | return `https://${baseUrl}/${github}/issues/${ref.value.slice(1)}` 20 | return `[(${ref.value.slice(0, 5)})](https://${baseUrl}/${github}/commit/${ref.value})` 21 | }) 22 | 23 | const referencesString = join(refs).trim() 24 | 25 | if (type === 'issues') 26 | return referencesString && `in ${referencesString}` 27 | return referencesString 28 | } 29 | 30 | function formatLine(commit: Commit, options: ResolvedChangelogOptions) { 31 | const prRefs = formatReferences(commit.references, options.baseUrl, options.repo as string, 'issues') 32 | const hashRefs = formatReferences(commit.references, options.baseUrl, options.repo as string, 'hash') 33 | 34 | let authors = join([...new Set(commit.resolvedAuthors?.map(i => i.login ? `@${i.login}` : `**${i.name}**`))])?.trim() 35 | if (authors) 36 | authors = `by ${authors}` 37 | 38 | let refs = [authors, prRefs, hashRefs].filter(i => i?.trim()).join(' ') 39 | 40 | if (refs) 41 | refs = ` -  ${refs}` 42 | 43 | const description = options.capitalize ? capitalize(commit.description) : commit.description 44 | 45 | return [description, refs].filter(i => i?.trim()).join(' ') 46 | } 47 | 48 | function formatTitle(name: string, options: ResolvedChangelogOptions) { 49 | if (!options.emoji) 50 | name = name.replace(emojisRE, '') 51 | 52 | return `###    ${name.trim()}` 53 | } 54 | 55 | function formatSection(commits: Commit[], sectionName: string, options: ResolvedChangelogOptions) { 56 | if (!commits.length) 57 | return [] 58 | 59 | const lines: string[] = [ 60 | '', 61 | formatTitle(sectionName, options), 62 | '', 63 | ] 64 | 65 | const scopes = groupBy(commits, 'scope') 66 | let useScopeGroup = options.group 67 | 68 | // group scopes only when one of the scope have multiple commits 69 | if (!Object.entries(scopes).some(([k, v]) => k && v.length > 1)) 70 | useScopeGroup = false 71 | 72 | Object.keys(scopes).sort().forEach((scope) => { 73 | let padding = '' 74 | let prefix = '' 75 | const scopeText = `**${options.scopeMap[scope] || scope}**` 76 | if (scope && (useScopeGroup === true || (useScopeGroup === 'multiple' && scopes[scope].length > 1))) { 77 | lines.push(`- ${scopeText}:`) 78 | padding = ' ' 79 | } 80 | else if (scope) { 81 | prefix = `${scopeText}: ` 82 | } 83 | 84 | lines.push(...scopes[scope] 85 | .reverse() 86 | .map(commit => `${padding}- ${prefix}${formatLine(commit, options)}`), 87 | ) 88 | }) 89 | 90 | return lines 91 | } 92 | 93 | export function generateMarkdown(commits: Commit[], options: ResolvedChangelogOptions) { 94 | const lines: string[] = [] 95 | 96 | const [breaking, changes] = partition(commits, c => c.isBreaking) 97 | 98 | const group = groupBy(changes, 'type') 99 | 100 | lines.push( 101 | ...formatSection(breaking, options.titles.breakingChanges!, options), 102 | ) 103 | 104 | for (const type of Object.keys(options.types)) { 105 | const items = group[type] || [] 106 | lines.push( 107 | ...formatSection(items, options.types[type].title, options), 108 | ) 109 | } 110 | 111 | if (!lines.length) 112 | lines.push('*No significant changes*') 113 | 114 | const url = `https://${options.baseUrl}/${options.repo}/compare/${options.from}...${options.to}` 115 | 116 | lines.push('', `#####     [View changes on GitHub](${url})`) 117 | 118 | return convert(lines.join('\n').trim(), true) 119 | } 120 | 121 | function groupBy(items: T[], key: string, groups: Record = {}) { 122 | for (const item of items) { 123 | const v = (item as any)[key] as string 124 | groups[v] = groups[v] || [] 125 | groups[v].push(item) 126 | } 127 | return groups 128 | } 129 | 130 | function capitalize(str: string) { 131 | return str.charAt(0).toUpperCase() + str.slice(1) 132 | } 133 | 134 | function join(array?: string[], glue = ', ', finalGlue = ' and '): string { 135 | if (!array || array.length === 0) 136 | return '' 137 | 138 | if (array.length === 1) 139 | return array[0] 140 | 141 | if (array.length === 2) 142 | return array.join(finalGlue) 143 | 144 | return `${array.slice(0, -1).join(glue)}${finalGlue}${array.slice(-1)}` 145 | } 146 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | import type { GitCommit, RawGitCommit } from 'changelogen' 2 | import type { ChangelogenOptions } from './types' 3 | import { notNullish } from '@antfu/utils' 4 | import { parseGitCommit } from 'changelogen' 5 | 6 | export function parseCommits(commits: RawGitCommit[], config: ChangelogenOptions): GitCommit[] { 7 | return commits.map(commit => parseGitCommit(commit, config)).filter(notNullish) 8 | } 9 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ChangelogConfig, GitCommit, RepoConfig } from 'changelogen' 2 | 3 | export type ChangelogenOptions = ChangelogConfig 4 | 5 | export interface GitHubRepo { 6 | owner: string 7 | repo: string 8 | } 9 | 10 | export interface GitHubAuth { 11 | token: string 12 | url: string 13 | } 14 | 15 | export interface Commit extends GitCommit { 16 | resolvedAuthors?: AuthorInfo[] 17 | } 18 | 19 | export interface ChangelogOptions extends Partial { 20 | /** 21 | * Dry run. Skip releasing to GitHub. 22 | */ 23 | dry?: boolean 24 | /** 25 | * Whether to include contributors in release notes. 26 | * 27 | * @default true 28 | */ 29 | contributors?: boolean 30 | /** 31 | * Name of the release 32 | */ 33 | name?: string 34 | /** 35 | * Mark the release as a draft 36 | */ 37 | draft?: boolean 38 | /** 39 | * Mark the release as prerelease 40 | */ 41 | prerelease?: boolean 42 | /** 43 | * GitHub Token 44 | */ 45 | token?: string 46 | /** 47 | * Custom titles 48 | */ 49 | titles?: { 50 | breakingChanges?: string 51 | } 52 | /** 53 | * Capitalize commit messages 54 | * @default true 55 | */ 56 | capitalize?: boolean 57 | /** 58 | * Nest commit messages under their scopes 59 | * @default true 60 | */ 61 | group?: boolean | 'multiple' 62 | /** 63 | * Use emojis in section titles 64 | * @default true 65 | */ 66 | emoji?: boolean 67 | /** 68 | * Github base url 69 | * @default github.com 70 | */ 71 | baseUrl?: string 72 | /** 73 | * Github base API url 74 | * @default api.github.com 75 | */ 76 | baseUrlApi?: string 77 | 78 | /** 79 | * Filter tags 80 | */ 81 | tagFilter?: (tag: string) => boolean 82 | 83 | /** 84 | * Release repository, defaults to `repo` 85 | */ 86 | releaseRepo?: RepoConfig | string 87 | 88 | /** 89 | * Can be set to a custom tag string 90 | * Any `%s` placeholders in the tag string will be replaced 91 | * If the tag string does _not_ contain any `%s` placeholders, 92 | * then the version number will be appended to the tag. 93 | * 94 | * @default `v%s`. 95 | */ 96 | tag?: string 97 | 98 | /** 99 | * Files to upload as assets to the release 100 | * `--assets path1,path2` or `--assets path1 --assets path2` 101 | */ 102 | assets?: string[] | string 103 | } 104 | 105 | export type ResolvedChangelogOptions = Required 106 | 107 | export interface AuthorInfo { 108 | commits: string[] 109 | login?: string 110 | email: string 111 | name: string 112 | } 113 | -------------------------------------------------------------------------------- /test/git.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { generate, getGitHubRepo } from '../src' 3 | 4 | const COMMIT_FROM = '19cf4f84f16f1a8e1e7032bbef550c382938649d' 5 | const COMMIT_TO = '49b0222e8d60b7f299941def7511cee0460a8149' 6 | const regexToFindAllUrls = /https:\/\/\S*/g 7 | 8 | it('parse', async () => { 9 | const { config, md } = await generate({ 10 | from: COMMIT_FROM, 11 | to: COMMIT_TO, 12 | }) 13 | 14 | expect(config).toMatchInlineSnapshot(` 15 | { 16 | "baseUrl": "github.com", 17 | "baseUrlApi": "api.github.com", 18 | "capitalize": true, 19 | "contributors": true, 20 | "from": "19cf4f84f16f1a8e1e7032bbef550c382938649d", 21 | "group": true, 22 | "prerelease": false, 23 | "releaseRepo": "antfu/changelogithub", 24 | "repo": "antfu/changelogithub", 25 | "scopeMap": {}, 26 | "tagFilter": [Function], 27 | "titles": { 28 | "breakingChanges": "🚨 Breaking Changes", 29 | }, 30 | "to": "49b0222e8d60b7f299941def7511cee0460a8149", 31 | "types": { 32 | "feat": { 33 | "title": "🚀 Features", 34 | }, 35 | "fix": { 36 | "title": "🐞 Bug Fixes", 37 | }, 38 | "perf": { 39 | "title": "🏎 Performance", 40 | }, 41 | }, 42 | } 43 | `) 44 | expect(md.replace(/ /g, ' ').replace(/ +/g, ' ')).toMatchInlineSnapshot(` 45 | "### Breaking Changes 46 | 47 | - **cli**: Rename \`groupByScope\` to \`group\` - by **Enzo Innocenzi** in https://github.com/antfu/changelogithub/issues/22 [(89282)](https://github.com/antfu/changelogithub/commit/8928229) 48 | 49 | ### Features 50 | 51 | - Inline contributors - by **Anthony Fu** [(e4044)](https://github.com/antfu/changelogithub/commit/e404493) 52 | - Throw on shallow repo - by **Anthony Fu** [(f1c1f)](https://github.com/antfu/changelogithub/commit/f1c1fad) 53 | - Improve how references are displayed - by **Enzo Innocenzi** in https://github.com/antfu/changelogithub/issues/19 [(cdf8f)](https://github.com/antfu/changelogithub/commit/cdf8fe5) 54 | - Support \`--no-emoji\` - by **Enzo Innocenzi** in https://github.com/antfu/changelogithub/issues/20 [(e94ba)](https://github.com/antfu/changelogithub/commit/e94ba4a) 55 | - **contributors**: 56 | - Improve author list - by **Enzo Innocenzi** in https://github.com/antfu/changelogithub/issues/18 [(8d8d9)](https://github.com/antfu/changelogithub/commit/8d8d914) 57 | - **style**: 58 | - Group scopes only when one of the scope have multiple commits - by **Anthony Fu** [(312f7)](https://github.com/antfu/changelogithub/commit/312f796) 59 | - Use \`\` for author info - by **Anthony Fu** [(b51c0)](https://github.com/antfu/changelogithub/commit/b51c075) 60 | - Limit sha to 5 letters and make monospace - by **Anthony Fu** [(b07ad)](https://github.com/antfu/changelogithub/commit/b07ade8) 61 | 62 | ### Bug Fixes 63 | 64 | - Use \`creatordate\` to sort tags - by **Frost Ming** in https://github.com/antfu/changelogithub/issues/17 [(5666d)](https://github.com/antfu/changelogithub/commit/5666d8d) 65 | - Config defaults - by **Anthony Fu** [(9232f)](https://github.com/antfu/changelogithub/commit/9232fdf) 66 | - Use \`replace\` instead of \`replaceAll\` for Node 14 - by **Anthony Fu** [(5154e)](https://github.com/antfu/changelogithub/commit/5154e78) 67 | - **cli**: Add missing \`--group\` option - by **Enzo Innocenzi** in https://github.com/antfu/changelogithub/issues/21 [(22800)](https://github.com/antfu/changelogithub/commit/228001d) 68 | - **style**: Revert \`\` style - by **Anthony Fu** [(742ae)](https://github.com/antfu/changelogithub/commit/742ae0b) 69 | 70 | ##### [View changes on GitHub](https://github.com/antfu/changelogithub/compare/19cf4f84f16f1a8e1e7032bbef550c382938649d...49b0222e8d60b7f299941def7511cee0460a8149)" 71 | `) 72 | }) 73 | 74 | it.each([ 75 | { baseUrl: undefined, baseUrlApi: undefined, repo: undefined }, 76 | { baseUrl: 'test.github.com', baseUrlApi: 'api.test.github.com', repo: 'user/changelogithub' }, 77 | ])('should generate config while baseUrl is set to $baseUrl', async (proposedConfig) => { 78 | const { config, md } = await generate({ 79 | ...proposedConfig, 80 | from: COMMIT_FROM, 81 | to: COMMIT_TO, 82 | }) 83 | 84 | if (proposedConfig.baseUrl) { 85 | expect(config).toEqual(expect.objectContaining(proposedConfig)) 86 | } 87 | else { 88 | expect(config).toEqual(expect.objectContaining({ 89 | baseUrl: 'github.com', 90 | baseUrlApi: 'api.github.com', 91 | })) 92 | } 93 | 94 | const urlsToGithub = md.match(regexToFindAllUrls) 95 | expect(urlsToGithub?.every(url => url.startsWith(`https://${config.baseUrl}`))).toBe(true) 96 | }) 97 | 98 | it('should match with current github repo', async () => { 99 | const repo = await getGitHubRepo('github.com') 100 | expect(repo).toContain('/changelogithub') 101 | }) 102 | 103 | it('should throw error when baseUrl is different from git repository', () => { 104 | expect(async () => { 105 | await getGitHubRepo('custom.git.com') 106 | }).rejects.toThrow('Can not parse GitHub repo from url') 107 | }) 108 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["esnext"], 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "esModuleInterop": true, 11 | "skipDefaultLibCheck": true, 12 | "skipLibCheck": true 13 | }, 14 | "exclude": [ 15 | "node_modules", 16 | "dist" 17 | ] 18 | } 19 | --------------------------------------------------------------------------------