Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `## <version>` 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
Expand Down
16 changes: 11 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `## <version>` 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 `## <version>` 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 `## <package.json version>` (`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
Expand Down
32 changes: 30 additions & 2 deletions scripts/check-changelog-heading.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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}.`);
Expand Down
61 changes: 60 additions & 1 deletion test/release-scripts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 }));
Expand All @@ -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<string, string>) {
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);
Expand Down
Loading