diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3644e9b..84e64bd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,8 +10,14 @@ updates: # Repo author's own packages: take the latest immediately. exclude: [glassnode-api, ink-uplot] groups: - # One PR per week for all non-major bumps; majors stay separate for review. - minor-and-patch: + # One PR per week for each dependency type's non-major bumps; majors stay separate for review. + # A runtime PR changes what users install and needs a version bump (see CONTRIBUTING.md); + # a development PR needs none and passes CI as opened. + runtime: + dependency-type: production + update-types: [minor, patch] + development: + dependency-type: development update-types: [minor, patch] open-pull-requests-limit: 5 diff --git a/CLAUDE.md b/CLAUDE.md index 6d18558..0336810 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,13 +65,15 @@ Interactive TUI (Terminal User Interface) for Glassnode on-chain crypto data, bu ## Versioning -Follow [semver](https://semver.org/) (major: breaking for users — removed flags/env vars, changed defaults, higher Node floor; minor: new features; patch: fixes, docs, dependency bumps, refactors). +Follow [semver](https://semver.org/) (major: breaking for users — removed flags/env vars, changed defaults, higher Node floor; minor: new features; patch: fixes, dependency bumps, refactors). -**Every change** (except PRs into `release/**` branches, which still add a CHANGELOG entry) MUST: +**Every change to the published package** (except PRs into `release/**` branches, which still add a CHANGELOG entry) MUST: 1. Bump `version` in `package.json` 2. Add a `## ` entry at the top of `CHANGELOG.md` +Only changes to published files need them: `RELEASE_PATHS` in `scripts/check-changelog-heading.mjs` (`src/`, `tsconfig.json`, `package.json`, `LICENSE`, `NOTICE`). In `package.json`, `devDependencies` don't count. Docs, `CODEOWNERS`, `.github/`, `scripts/`, `test/` and the lockfile need neither. Dependabot opens separate runtime and development PRs: push a version bump and CHANGELOG entry onto a runtime one before merging. + The `version` in `package.json` is exactly what gets published: CI never bumps it. A change merged without a bump publishes nothing. ## CI diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bc1c97..1d1ca58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,19 +46,25 @@ UI changes by eye; charts draw differently in kitty, iTerm2/VS Code and plain te ### 3. Bump the version and add a changelog entry -Every change bumps `version` in `package.json` and adds an entry at the top of `CHANGELOG.md` -(a `## ` heading) describing it. Follow [semver](https://semver.org/): +Every change to the published package bumps `version` in `package.json` and adds an entry at the +top of `CHANGELOG.md` (a `## ` heading) describing it. The package is what changes +`src/`, `tsconfig.json`, `package.json`, `LICENSE` or `NOTICE` (`RELEASE_PATHS` in +`scripts/check-changelog-heading.mjs`; in `package.json`, `devDependencies` don't count); changes +elsewhere (docs, `CODEOWNERS`, `.github/`, `scripts/`, `test/`, the lockfile) skip both. Dependabot +opens separate PRs for runtime and development dependencies: a development PR passes as opened; a +runtime PR changes what users install, so push a patch bump and a `CHANGELOG.md` entry onto it +before merging. Follow [semver](https://semver.org/): - **Major**: breaking changes for users (a removed command-line flag or environment variable, a changed default that alters behaviour, a higher minimum Node.js version). - **Minor**: new features, flags, keys or views that are backward compatible. -- **Patch**: bug fixes, docs, dependency bumps and internal refactors with no behaviour change. +- **Patch**: bug fixes, dependency bumps and internal refactors with no behaviour change. The version in your PR is exactly the one that gets published: the release workflow does not bump it. A PR merged without a bump publishes nothing. CI checks that the top `CHANGELOG.md` heading is exactly `## ` (`node scripts/check-changelog-heading.mjs` runs it locally). -For PRs into `main`, CI also compares the version with the exact PR base commit: it must be -greater, and its changelog section must contain release notes. Locally, set `BASE_SHA` to a base +For PRs into `main` that change the package, CI also compares the version with the exact PR base +commit: it must be greater, and its changelog section must contain release notes. Locally, set `BASE_SHA` to a base commit to run the same comparison. **Release branches.** A large release can be prepared on a `release/**` branch. PRs into it carry diff --git a/scripts/check-changelog-heading.mjs b/scripts/check-changelog-heading.mjs index 587ec3e..4858da8 100644 --- a/scripts/check-changelog-heading.mjs +++ b/scripts/check-changelog-heading.mjs @@ -11,12 +11,32 @@ // Usage: node scripts/check-changelog-heading.mjs [target-branch] // The target defaults to $TARGET_BRANCH, then `main`. Env CHANGELOG and PACKAGE_JSON override // the file paths (for tests). Exits 1 with a GitHub `::error::` annotation on failure. -// BASE_SHA enables PR version/notes checks against that commit's package.json. +// BASE_SHA enables PR version/notes checks against that commit's package.json. They apply only +// to PRs that change the published package (RELEASE_PATHS; in package.json, any field other than +// devDependencies). /* global process, console */ import { readFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { compareSemver, parseSemver } from './release-plan.mjs'; +// Changes to these paths change what users of the npm package get, so they need a version bump: +// the source compiled into dist/, the build config, package.json (dependencies, bin, engines) and +// the license files in the tarball. Anything else (docs, CI, scripts, tests) needs none. +const RELEASE_PATHS = [/^src\//, /^tsconfig\.json$/, /^package\.json$/, /^(LICENSE|NOTICE)$/]; + +/** Whether package.json changed since `baseSha` in a field users install (not devDependencies). */ +function packageJsonChanged(baseSha, current) { + const base = JSON.parse(execFileSync('git', ['show', `${baseSha}:package.json`], { encoding: 'utf8' })); + const published = ({ devDependencies, ...rest }) => JSON.stringify(rest); + return published(base) !== published(current); +} + +/** Files changed since `baseSha`: committed, staged, unstaged and untracked. */ +function changedFiles(baseSha) { + const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).split('\n').filter(Boolean); + return [...git(['diff', '--name-only', baseSha]), ...git(['ls-files', '--others', '--exclude-standard'])]; +} + /** Returns null when `heading` is acceptable for `target`, or the reason it is not. */ function checkHeading(heading, version, target) { const release = /^release\//.test(target); @@ -35,7 +55,8 @@ function checkHeading(heading, version, target) { const target = process.argv[2] || process.env.TARGET_BRANCH || 'main'; const changelog = readFileSync(process.env.CHANGELOG || 'CHANGELOG.md', 'utf8'); -const { version } = JSON.parse(readFileSync(process.env.PACKAGE_JSON || 'package.json', 'utf8')); +const pkg = JSON.parse(readFileSync(process.env.PACKAGE_JSON || 'package.json', 'utf8')); +const { version } = pkg; const heading = changelog.split(/\r?\n/).find((line) => line.startsWith('## ')) ?? null; const error = checkHeading(heading, version, target); if (error) { @@ -47,6 +68,13 @@ console.log(`CHANGELOG.md heading "${heading}" is valid for a PR into ${target}. // PR CI supplies the exact base commit. Push/release runs and local heading checks omit it. // Release-branch PRs accumulate changes under an unreleased heading without a version bump. if (process.env.BASE_SHA && !/^release\//.test(target)) { + const changed = changedFiles(process.env.BASE_SHA).filter( + (file) => file !== 'package.json' || packageJsonChanged(process.env.BASE_SHA, pkg), + ); + if (!changed.some((file) => RELEASE_PATHS.some((re) => re.test(file)))) { + console.log(`No version bump needed: no published files changed (${changed.join(', ') || 'none'}).`); + process.exit(0); + } const base = JSON.parse(execFileSync('git', ['show', `${process.env.BASE_SHA}:package.json`], { encoding: 'utf8' })); if (!parseSemver(version) || !parseSemver(base.version) || compareSemver(version, base.version) <= 0) { console.error(`::error file=package.json::Version ${version} must be greater than base version ${base.version} for a PR into ${target}.`); diff --git a/test/release-scripts.spec.ts b/test/release-scripts.spec.ts index ec179a6..c776318 100644 --- a/test/release-scripts.spec.ts +++ b/test/release-scripts.spec.ts @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; @@ -351,6 +351,9 @@ describe('check-changelog-heading.mjs', () => { expect(result.status, result.stderr).toBe(0); } baseSha = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).stdout.trim(); + // The PR changes a published file, so the version rules apply. + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'index.ts'), 'export {};\n'); } writeFileSync(join(dir, 'CHANGELOG.md'), changelog); writeFileSync(join(dir, 'package.json'), JSON.stringify({ version })); @@ -377,6 +380,62 @@ describe('check-changelog-heading.mjs', () => { expect(r.stderr).toContain('must be greater than base version'); }); + // In these cases CHANGELOG.md matches the base: only the extra files differ. + function checkOnly(extraFiles: Record) { + const dir = tempDir(); + const changelog = log('## 0.30.1'); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '0.30.1' })); + writeFileSync(join(dir, 'CHANGELOG.md'), changelog); + const git = (args: string[]) => { + const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + git(['init', '--quiet']); + git(['add', '.']); + git(['-c', 'user.name=Release test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'base']); + const baseSha = git(['rev-parse', 'HEAD']); + for (const [file, content] of Object.entries(extraFiles)) { + mkdirSync(dirname(join(dir, file)), { recursive: true }); + writeFileSync(join(dir, file), content); + } + return spawnSync('node', [join(scripts, 'check-changelog-heading.mjs'), 'main'], { + encoding: 'utf8', + cwd: dir, + env: { ...process.env, BASE_SHA: baseSha, CHANGELOG: join(dir, 'CHANGELOG.md'), PACKAGE_JSON: join(dir, 'package.json') }, + }); + } + + it.each([ + ['CODEOWNERS', { CODEOWNERS: '* @a @b\n' }], + ['docs', { 'README.md': 'docs\n', 'CONTRIBUTING.md': 'docs\n' }], + ['CI and tests', { '.github/workflows/ci.yml': 'name: CI\n', 'test/x.spec.ts': '\n' }], + ['other unlisted files', { 'eslint.config.js': '\n', 'docs/x.md': 'docs\n' }], + [ + 'devDependencies', + { 'package.json': JSON.stringify({ version: '0.30.1', devDependencies: { vitest: '5.0.3' } }), 'pnpm-lock.yaml': 'x\n' }, + ], + ])('skips the version check when only %s change', (_, files) => { + const r = checkOnly(files); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain('No version bump needed'); + }); + + it.each([ + ['src/', { CODEOWNERS: '* @a @b\n', 'src/x.ts': 'export {};\n' }], + ['package.json dependencies', { 'package.json': JSON.stringify({ version: '0.30.1', dependencies: { x: '1.0.0' } }) }], + [ + 'dependencies alongside devDependencies', + { 'package.json': JSON.stringify({ version: '0.30.1', dependencies: { x: '1.0.0' }, devDependencies: { y: '1.0.0' } }) }, + ], + ['tsconfig.json', { 'tsconfig.json': '{}\n' }], + ['NOTICE', { NOTICE: 'Copyright\n' }], + ])('requires a bump when %s changes', (_, files) => { + const r = checkOnly(files); + expect(r.status).toBe(1); + expect(r.stderr).toContain('must be greater than base version'); + }); + it('accepts a bumped PR version with release notes', () => { const r = check(log('## 0.30.2'), '0.30.2', 'main', '0.30.1'); expect(r.status, r.stderr).toBe(0);