|
| 1 | +#!/usr/bin/env node |
| 2 | +// Local, GH-Actions-free release: derive the bump + changelog from conventional |
| 3 | +// commits since the last `zaileys@*` tag, then version -> build -> publish -> tag -> push. |
| 4 | +// Usage: node scripts/release.mjs [--dry-run] [--patch|--minor|--major] [--no-publish] [--no-push] |
| 5 | +import { execSync } from 'node:child_process' |
| 6 | +import { readFileSync, writeFileSync } from 'node:fs' |
| 7 | +import { fileURLToPath } from 'node:url' |
| 8 | +import { dirname, join } from 'node:path' |
| 9 | + |
| 10 | +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') |
| 11 | +const args = new Set(process.argv.slice(2)) |
| 12 | +const dryRun = args.has('--dry-run') |
| 13 | +const noPublish = args.has('--no-publish') |
| 14 | +const noPush = args.has('--no-push') |
| 15 | +const forced = args.has('--major') ? 'major' : args.has('--minor') ? 'minor' : args.has('--patch') ? 'patch' : null |
| 16 | + |
| 17 | +const sh = (cmd, opts = {}) => execSync(cmd, { cwd: ROOT, encoding: 'utf8', stdio: opts.capture ? 'pipe' : 'inherit', ...opts }) |
| 18 | +const out = (cmd) => sh(cmd, { capture: true }).trim() |
| 19 | +const die = (msg) => { console.error(`\n✖ ${msg}`); process.exit(1) } |
| 20 | + |
| 21 | +const lastTag = (() => { |
| 22 | + try { return out('git describe --tags --abbrev=0 --match "zaileys@*"') } catch { return null } |
| 23 | +})() |
| 24 | + |
| 25 | +const range = lastTag ? `${lastTag}..HEAD` : 'HEAD' |
| 26 | +const subjects = out(`git log ${range} --pretty=format:%s%x00%b%x1e`) |
| 27 | + .split('\x1e').map((s) => s.trim()).filter(Boolean) |
| 28 | + .map((block) => { const [subject, body = ''] = block.split('\x00'); return { subject, body } }) |
| 29 | + |
| 30 | +if (subjects.length === 0) die(`No commits since ${lastTag ?? 'repo start'} — nothing to release.`) |
| 31 | + |
| 32 | +const RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/ |
| 33 | +const major = [], minor = [], patch = [] |
| 34 | +for (const { subject, body } of subjects) { |
| 35 | + const m = RE.exec(subject) |
| 36 | + if (!m) continue |
| 37 | + const [, type, , bang, desc] = m |
| 38 | + const breaking = bang === '!' || /BREAKING CHANGE/.test(body) |
| 39 | + const line = desc.trim() |
| 40 | + if (breaking) major.push(line) |
| 41 | + else if (type === 'feat') minor.push(line) |
| 42 | + else if (type === 'fix' || type === 'perf') patch.push(line) |
| 43 | +} |
| 44 | + |
| 45 | +const bump = forced ?? (major.length ? 'major' : minor.length ? 'minor' : patch.length ? 'patch' : null) |
| 46 | +if (!bump) die('No feat/fix/perf/breaking commits since last release — nothing to publish (chore/docs only).') |
| 47 | + |
| 48 | +const pkgPath = join(ROOT, 'package.json') |
| 49 | +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) |
| 50 | +const [maj, min, pat] = pkg.version.split('.').map(Number) |
| 51 | +const next = bump === 'major' ? `${maj + 1}.0.0` : bump === 'minor' ? `${maj}.${min + 1}.0` : `${maj}.${min}.${pat + 1}` |
| 52 | + |
| 53 | +const section = (title, items) => (items.length ? `### ${title}\n\n${items.map((i) => `- ${i}`).join('\n')}\n\n` : '') |
| 54 | +const entry = `## ${next}\n\n` + |
| 55 | + section('Major Changes', major) + section('Minor Changes', minor) + section('Patch Changes', patch) |
| 56 | + |
| 57 | +console.log(`\nRelease: ${pkg.version} -> ${next} (${bump}) from ${subjects.length} commits since ${lastTag ?? 'start'}\n`) |
| 58 | +console.log(entry) |
| 59 | + |
| 60 | +if (dryRun) { console.log('--dry-run: no changes written.'); process.exit(0) } |
| 61 | + |
| 62 | +pkg.version = next |
| 63 | +writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n') |
| 64 | + |
| 65 | +const clog = readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8') |
| 66 | +const header = '# zaileys\n\n' |
| 67 | +const rest = clog.startsWith(header) ? clog.slice(header.length) : clog |
| 68 | +writeFileSync(join(ROOT, 'CHANGELOG.md'), header + entry + rest) |
| 69 | + |
| 70 | +sh('pnpm build') |
| 71 | +if (!noPublish) sh('npm publish') |
| 72 | + |
| 73 | +sh('git add package.json CHANGELOG.md') |
| 74 | +sh(`git commit -m "chore: release v${next}" --no-verify`) |
| 75 | +sh(`git tag zaileys@${next}`) |
| 76 | +if (!noPush) { |
| 77 | + const branch = out('git rev-parse --abbrev-ref HEAD') |
| 78 | + sh(`git push origin ${branch} --follow-tags`) |
| 79 | + sh(`git push origin ${branch}:main`) |
| 80 | +} |
| 81 | +console.log(`\n✔ Released zaileys@${next}`) |
0 commit comments