|
| 1 | +import { execSync } from 'node:child_process' |
| 2 | +import { existsSync } from 'node:fs' |
| 3 | +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' |
| 4 | +import path from 'node:path' |
| 5 | + |
| 6 | +const RELEASE_COMMIT_PREFIX = 'ci: Version Packages' |
| 7 | +const WORKSPACE_DIRS = ['packages', 'cli-aliases'] |
| 8 | +const PATCH_TYPES = new Set(['fix', 'perf', 'refactor', 'docs', 'chore', 'build', 'ci', 'test', 'style']) |
| 9 | + |
| 10 | +function runGit(args) { |
| 11 | + return execSync(`git ${args}`, { encoding: 'utf8' }).trim() |
| 12 | +} |
| 13 | + |
| 14 | +async function getPendingChangesetFiles() { |
| 15 | + const changesetDir = path.resolve('.changeset') |
| 16 | + if (!existsSync(changesetDir)) return [] |
| 17 | + |
| 18 | + const entries = await readdir(changesetDir, { withFileTypes: true }) |
| 19 | + return entries |
| 20 | + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) |
| 21 | + .map((entry) => entry.name) |
| 22 | + .filter((name) => name !== 'README.md') |
| 23 | +} |
| 24 | + |
| 25 | +async function getPublishablePackages() { |
| 26 | + const packages = [] |
| 27 | + |
| 28 | + for (const workspaceDir of WORKSPACE_DIRS) { |
| 29 | + const absWorkspaceDir = path.resolve(workspaceDir) |
| 30 | + if (!existsSync(absWorkspaceDir)) continue |
| 31 | + |
| 32 | + const dirEntries = await readdir(absWorkspaceDir, { withFileTypes: true }) |
| 33 | + for (const dirEntry of dirEntries) { |
| 34 | + if (!dirEntry.isDirectory()) continue |
| 35 | + |
| 36 | + const relDir = path.join(workspaceDir, dirEntry.name) |
| 37 | + const packageJsonPath = path.resolve(relDir, 'package.json') |
| 38 | + if (!existsSync(packageJsonPath)) continue |
| 39 | + |
| 40 | + const raw = await readFile(packageJsonPath, 'utf8') |
| 41 | + const pkg = JSON.parse(raw) |
| 42 | + if (pkg.private || typeof pkg.name !== 'string') continue |
| 43 | + |
| 44 | + packages.push({ |
| 45 | + name: pkg.name, |
| 46 | + dir: relDir.replace(/\\/g, '/'), |
| 47 | + }) |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + return packages |
| 52 | +} |
| 53 | + |
| 54 | +function getBaseSha() { |
| 55 | + const lastReleaseSha = runGit(`log --format=%H --grep="^${RELEASE_COMMIT_PREFIX}" -n 1 HEAD`) |
| 56 | + if (lastReleaseSha) return lastReleaseSha |
| 57 | + |
| 58 | + const roots = runGit('rev-list --max-parents=0 HEAD') |
| 59 | + return roots.split('\n').map((line) => line.trim()).filter(Boolean).at(-1) |
| 60 | +} |
| 61 | + |
| 62 | +function parseCommitRecords(baseSha) { |
| 63 | + const raw = runGit(`log --format=%H%x1f%s%x1f%b%x1e ${baseSha}..HEAD`) |
| 64 | + if (!raw) return [] |
| 65 | + |
| 66 | + return raw |
| 67 | + .split('\x1e') |
| 68 | + .map((entry) => entry.trim()) |
| 69 | + .filter(Boolean) |
| 70 | + .map((entry) => { |
| 71 | + const [sha, subject, body = ''] = entry.split('\x1f') |
| 72 | + return { |
| 73 | + sha, |
| 74 | + subject: subject ?? '', |
| 75 | + body, |
| 76 | + } |
| 77 | + }) |
| 78 | +} |
| 79 | + |
| 80 | +function getCommitBump(commit) { |
| 81 | + const headerMatch = commit.subject.match(/^([a-z]+)(\([^)]*\))?(!)?:\s+/i) |
| 82 | + const type = headerMatch?.[1]?.toLowerCase() |
| 83 | + const breaking = Boolean(headerMatch?.[3]) || /BREAKING CHANGE:/i.test(commit.body) |
| 84 | + |
| 85 | + if (breaking) return 'major' |
| 86 | + if (type === 'feat') return 'minor' |
| 87 | + if (type && PATCH_TYPES.has(type)) return 'patch' |
| 88 | + return null |
| 89 | +} |
| 90 | + |
| 91 | +function mergeBump(current, incoming) { |
| 92 | + const rank = { patch: 1, minor: 2, major: 3 } |
| 93 | + if (!current) return incoming |
| 94 | + if (!incoming) return current |
| 95 | + return rank[incoming] > rank[current] ? incoming : current |
| 96 | +} |
| 97 | + |
| 98 | +function getChangedFiles(baseSha) { |
| 99 | + const raw = runGit(`diff --name-only ${baseSha}..HEAD`) |
| 100 | + if (!raw) return [] |
| 101 | + return raw.split('\n').map((line) => line.trim()).filter(Boolean) |
| 102 | +} |
| 103 | + |
| 104 | +function getTargetPackageNames(changedFiles, packages) { |
| 105 | + const affected = new Set() |
| 106 | + let globalImpact = false |
| 107 | + |
| 108 | + for (const file of changedFiles) { |
| 109 | + const normalized = file.replace(/\\/g, '/') |
| 110 | + |
| 111 | + if ( |
| 112 | + normalized.startsWith('.changeset/') || |
| 113 | + normalized === 'pnpm-lock.yaml' || |
| 114 | + normalized === 'package.json' |
| 115 | + ) { |
| 116 | + globalImpact = true |
| 117 | + continue |
| 118 | + } |
| 119 | + |
| 120 | + let matched = false |
| 121 | + for (const pkg of packages) { |
| 122 | + if (normalized === pkg.dir || normalized.startsWith(`${pkg.dir}/`)) { |
| 123 | + affected.add(pkg.name) |
| 124 | + matched = true |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + if (!matched) { |
| 129 | + globalImpact = true |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + if (globalImpact) { |
| 134 | + return packages.map((pkg) => pkg.name) |
| 135 | + } |
| 136 | + |
| 137 | + return [...affected] |
| 138 | +} |
| 139 | + |
| 140 | +async function writeChangesetFile(packageNames, bump, commits) { |
| 141 | + const changesetDir = path.resolve('.changeset') |
| 142 | + if (!existsSync(changesetDir)) { |
| 143 | + await mkdir(changesetDir, { recursive: true }) |
| 144 | + } |
| 145 | + |
| 146 | + const branchName = process.env.GITHUB_REF_NAME || runGit('branch --show-current') || 'branch' |
| 147 | + const shortSha = runGit('rev-parse --short HEAD') |
| 148 | + const fileName = `auto-semantic-${branchName}-${shortSha}.md`.replace(/[^a-zA-Z0-9._-]/g, '-') |
| 149 | + const filePath = path.join(changesetDir, fileName) |
| 150 | + |
| 151 | + const frontmatter = packageNames |
| 152 | + .sort((a, b) => a.localeCompare(b)) |
| 153 | + .map((pkgName) => `'${pkgName}': ${bump}`) |
| 154 | + .join('\n') |
| 155 | + |
| 156 | + const bullets = commits |
| 157 | + .slice(0, 6) |
| 158 | + .map((commit) => `- ${commit.subject} (${commit.sha.slice(0, 7)})`) |
| 159 | + .join('\n') |
| 160 | + |
| 161 | + const content = `---\n${frontmatter}\n---\n\nAuto-generated changeset from semantic commits on ${branchName}.\n\n${bullets}\n` |
| 162 | + |
| 163 | + await writeFile(filePath, content, 'utf8') |
| 164 | + return fileName |
| 165 | +} |
| 166 | + |
| 167 | +async function main() { |
| 168 | + const pendingChangesets = await getPendingChangesetFiles() |
| 169 | + if (pendingChangesets.length > 0) { |
| 170 | + console.log(`Found ${pendingChangesets.length} authored changeset(s); skipping semantic fallback.`) |
| 171 | + return |
| 172 | + } |
| 173 | + |
| 174 | + const baseSha = getBaseSha() |
| 175 | + const commits = parseCommitRecords(baseSha) |
| 176 | + const releaseCommits = commits |
| 177 | + .filter((commit) => !commit.subject.startsWith(RELEASE_COMMIT_PREFIX)) |
| 178 | + .map((commit) => ({ ...commit, bump: getCommitBump(commit) })) |
| 179 | + .filter((commit) => Boolean(commit.bump)) |
| 180 | + |
| 181 | + if (releaseCommits.length === 0) { |
| 182 | + console.log('No semantic commits eligible for release; skipping generated changeset.') |
| 183 | + return |
| 184 | + } |
| 185 | + |
| 186 | + const packages = await getPublishablePackages() |
| 187 | + if (packages.length === 0) { |
| 188 | + console.log('No publishable workspace packages found; skipping generated changeset.') |
| 189 | + return |
| 190 | + } |
| 191 | + |
| 192 | + const changedFiles = getChangedFiles(baseSha) |
| 193 | + const targetPackages = getTargetPackageNames(changedFiles, packages) |
| 194 | + if (targetPackages.length === 0) { |
| 195 | + console.log('No affected publishable packages detected; skipping generated changeset.') |
| 196 | + return |
| 197 | + } |
| 198 | + |
| 199 | + let bump = null |
| 200 | + for (const commit of releaseCommits) { |
| 201 | + bump = mergeBump(bump, commit.bump) |
| 202 | + } |
| 203 | + |
| 204 | + if (!bump) { |
| 205 | + console.log('Unable to determine bump level; skipping generated changeset.') |
| 206 | + return |
| 207 | + } |
| 208 | + |
| 209 | + const fileName = await writeChangesetFile(targetPackages, bump, releaseCommits) |
| 210 | + |
| 211 | + console.log( |
| 212 | + `Generated ${fileName} (${bump}) for ${targetPackages.length} package(s) based on semantic commits.`, |
| 213 | + ) |
| 214 | +} |
| 215 | + |
| 216 | +main().catch((error) => { |
| 217 | + console.error(error) |
| 218 | + process.exit(1) |
| 219 | +}) |
0 commit comments