From c988eb1c7f5e9bf05a5b10c867a3fea20b6099b8 Mon Sep 17 00:00:00 2001 From: Iwan Eising Date: Fri, 18 Sep 2026 21:46:14 +0400 Subject: [PATCH] ci(release): work out each module's version and notes from its own commits semantic-release reads every commit since a module's last tag, whichever module the commit changed, so one module's feat raised another's version and appeared in its changelog. release/component-commits.js keeps the commits that change something inside the module's own directory. Each module's own first-release analyzer, or release/component-commit-analyzer.js for the modules that used the plain analyzer, works out the version from those, and release/component-release-notes.js hands the same commits to the release-notes generator. node:test covers the filter against a real git repository, and the shared version calculation runs those tests first. The same design as the API, Code and Gradle repositories. --- .../workflows/semantic-version-calculate.yml | 5 ++ .../first-release-patch-analyzer.js | 4 +- api-detector-core/release.config.js | 2 +- .../first-release-patch-analyzer.js | 4 +- .../release.config.js | 2 +- .../first-release-patch-analyzer.js | 4 +- dsl-updater-core/release.config.js | 2 +- geo-tracker-lens-pack/release.config.js | 2 +- hexagonal-spring-rules/release.config.js | 4 +- release/component-commit-analyzer.js | 14 ++++ release/component-commits.js | 61 ++++++++++++++ release/component-commits.test.js | 81 +++++++++++++++++++ release/component-release-notes.js | 14 ++++ sedr-library/release.config.js | 4 +- 14 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 release/component-commit-analyzer.js create mode 100644 release/component-commits.js create mode 100644 release/component-commits.test.js create mode 100644 release/component-release-notes.js diff --git a/.github/workflows/semantic-version-calculate.yml b/.github/workflows/semantic-version-calculate.yml index cf7a002..0570b49 100644 --- a/.github/workflows/semantic-version-calculate.yml +++ b/.github/workflows/semantic-version-calculate.yml @@ -49,6 +49,11 @@ jobs: with: node-version: "lts/*" + - name: Test the shared release scripts + # Every module's version and release notes are worked out through release/, so a + # broken filter there must fail here, before it can choose a version. + run: node --test release/*.test.js + - name: Set PROJECT_DIR environment variable if: ${{ inputs.project_dir != '' }} run: echo "PROJECT_DIR=${{ inputs.project_dir }}" >> "$GITHUB_ENV" diff --git a/api-detector-core/first-release-patch-analyzer.js b/api-detector-core/first-release-patch-analyzer.js index 4763f19..782bb94 100644 --- a/api-detector-core/first-release-patch-analyzer.js +++ b/api-detector-core/first-release-patch-analyzer.js @@ -1,4 +1,6 @@ const commitAnalyzer = require(require.resolve('@semantic-release/commit-analyzer', { paths: [process.cwd()] })); +// Only the commits that change this module count for its version: see ../release/component-commits.js. +const { forComponent } = require('../release/component-commits'); // This module is seeded with a baseline tag (api-detector-core-v0.0.0) specifically so // semantic-release finds a lastRelease and increments from it instead of falling back to its @@ -15,6 +17,6 @@ module.exports = { if (!context.lastRelease || !context.lastRelease.version || context.lastRelease.version === SEED_VERSION) { return 'patch'; } - return commitAnalyzer.analyzeCommits(pluginConfig, context); + return commitAnalyzer.analyzeCommits(pluginConfig, forComponent(context)); } }; diff --git a/api-detector-core/release.config.js b/api-detector-core/release.config.js index baf291b..f91480d 100644 --- a/api-detector-core/release.config.js +++ b/api-detector-core/release.config.js @@ -28,7 +28,7 @@ module.exports = { breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/ } }], - ["@semantic-release/release-notes-generator", { + ["../release/component-release-notes", { preset: 'angular', parserOpts: { noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'], diff --git a/api-only-transcriberj-restdocs/first-release-patch-analyzer.js b/api-only-transcriberj-restdocs/first-release-patch-analyzer.js index c5b8e66..0449687 100644 --- a/api-only-transcriberj-restdocs/first-release-patch-analyzer.js +++ b/api-only-transcriberj-restdocs/first-release-patch-analyzer.js @@ -1,4 +1,6 @@ const commitAnalyzer = require(require.resolve('@semantic-release/commit-analyzer', { paths: [process.cwd()] })); +// Only the commits that change this module count for its version: see ../release/component-commits.js. +const { forComponent } = require('../release/component-commits'); // This module is seeded with a baseline tag (api-only-transcriberj-restdocs-v0.0.0) specifically so // semantic-release finds a lastRelease and increments from it instead of falling back to its @@ -15,6 +17,6 @@ module.exports = { if (!context.lastRelease || !context.lastRelease.version || context.lastRelease.version === SEED_VERSION) { return 'patch'; } - return commitAnalyzer.analyzeCommits(pluginConfig, context); + return commitAnalyzer.analyzeCommits(pluginConfig, forComponent(context)); } }; diff --git a/api-only-transcriberj-restdocs/release.config.js b/api-only-transcriberj-restdocs/release.config.js index 476efcf..137fe6d 100644 --- a/api-only-transcriberj-restdocs/release.config.js +++ b/api-only-transcriberj-restdocs/release.config.js @@ -28,7 +28,7 @@ module.exports = { breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/ } }], - ["@semantic-release/release-notes-generator", { + ["../release/component-release-notes", { preset: 'angular', parserOpts: { noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'], diff --git a/dsl-updater-core/first-release-patch-analyzer.js b/dsl-updater-core/first-release-patch-analyzer.js index 4763f19..782bb94 100644 --- a/dsl-updater-core/first-release-patch-analyzer.js +++ b/dsl-updater-core/first-release-patch-analyzer.js @@ -1,4 +1,6 @@ const commitAnalyzer = require(require.resolve('@semantic-release/commit-analyzer', { paths: [process.cwd()] })); +// Only the commits that change this module count for its version: see ../release/component-commits.js. +const { forComponent } = require('../release/component-commits'); // This module is seeded with a baseline tag (api-detector-core-v0.0.0) specifically so // semantic-release finds a lastRelease and increments from it instead of falling back to its @@ -15,6 +17,6 @@ module.exports = { if (!context.lastRelease || !context.lastRelease.version || context.lastRelease.version === SEED_VERSION) { return 'patch'; } - return commitAnalyzer.analyzeCommits(pluginConfig, context); + return commitAnalyzer.analyzeCommits(pluginConfig, forComponent(context)); } }; diff --git a/dsl-updater-core/release.config.js b/dsl-updater-core/release.config.js index d806e85..345461f 100644 --- a/dsl-updater-core/release.config.js +++ b/dsl-updater-core/release.config.js @@ -28,7 +28,7 @@ module.exports = { breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/ } }], - ["@semantic-release/release-notes-generator", { + ["../release/component-release-notes", { preset: 'angular', parserOpts: { noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'], diff --git a/geo-tracker-lens-pack/release.config.js b/geo-tracker-lens-pack/release.config.js index c97b309..b6945fa 100644 --- a/geo-tracker-lens-pack/release.config.js +++ b/geo-tracker-lens-pack/release.config.js @@ -6,7 +6,7 @@ module.exports = { branches: ["main"], tagFormat: "geo-tracker-lens-pack-v${version}", plugins: [ - ["@semantic-release/commit-analyzer", { + ["../release/component-commit-analyzer", { preset: 'angular', releaseRules: [ { type: 'feat', release: 'minor' }, diff --git a/hexagonal-spring-rules/release.config.js b/hexagonal-spring-rules/release.config.js index 285d71d..958be5b 100644 --- a/hexagonal-spring-rules/release.config.js +++ b/hexagonal-spring-rules/release.config.js @@ -13,7 +13,7 @@ module.exports = { // of restarting. tagFormat: "hexagonal-spring-rules-v${version}", plugins: [ - ["@semantic-release/commit-analyzer", { + ["../release/component-commit-analyzer", { preset: 'angular', releaseRules: [ { type: 'feat', release: 'minor' }, @@ -32,7 +32,7 @@ module.exports = { ["@semantic-release/npm", { "npmPublish": false, }], - ["@semantic-release/release-notes-generator", { + ["../release/component-release-notes", { preset: 'angular', parserOpts: { noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'], diff --git a/release/component-commit-analyzer.js b/release/component-commit-analyzer.js new file mode 100644 index 0000000..a0d935d --- /dev/null +++ b/release/component-commit-analyzer.js @@ -0,0 +1,14 @@ +// The version of a module, from the commits that change it: see component-commits.js. +// Each module's release.config.js names this file where it named +// @semantic-release/commit-analyzer, with the same options; this hands those options, and a +// context holding only the module's own commits, to the real analyzer. +// +// Resolved from process.cwd() rather than from this file: semantic-release runs in the +// module's directory, and the dependency is installed in the module's own node_modules, not +// beside this file. +const commitAnalyzer = require(require.resolve('@semantic-release/commit-analyzer', { paths: [process.cwd()] })); +const { forComponent } = require('./component-commits'); + +module.exports = { + analyzeCommits: async (pluginConfig, context) => commitAnalyzer.analyzeCommits(pluginConfig, forComponent(context)) +}; diff --git a/release/component-commits.js b/release/component-commits.js new file mode 100644 index 0000000..fa2706a --- /dev/null +++ b/release/component-commits.js @@ -0,0 +1,61 @@ +// This file lives at the repository root's release/ directory, shared across every module's +// own release.config.js: through the module's own first-release-patch-analyzer.js, or +// through component-commit-analyzer.js, and through component-release-notes.js. +// +// semantic-release reads every commit since a module's last tag, whichever module the commit +// changed, because it knows nothing of the other modules in this repository. So one module's +// feat raised another's version, and appeared in its changelog. The version and the release +// notes are therefore worked out from the commits that change something inside the module's +// own directory, and only those. +// +// It needs nothing but git, which semantic-release needs anyway, so it can be tested and +// required from anywhere. +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +/** + * The module a semantic-release run is for: the directory it runs in, relative to the + * repository's root, with forward slashes. Empty at the root itself. + */ +function componentDirectory(cwd) { + const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd }).toString().trim(); + return path.relative(fs(root), fs(cwd)).split(path.sep).join('/'); +} + +/** A path as the file system spells it, so a symlinked temporary directory compares equal. */ +function fs(p) { + return require('node:fs').realpathSync(p); +} + +/** The files a commit changes, relative to the repository's root. */ +function changedFiles(hash, cwd) { + return execFileSync('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', hash], { cwd }) + .toString().split('\n').filter(Boolean); +} + +/** + * The commits of a semantic-release context that change something inside the module's own + * directory. At the repository's root, every commit. + */ +function ownCommits(context) { + const cwd = context.cwd || process.cwd(); + const directory = componentDirectory(cwd); + if (!directory) return context.commits; + const prefix = `${directory}/`; + return context.commits.filter((commit) => changedFiles(commit.hash, cwd).some((file) => file.startsWith(prefix))); +} + +/** + * The context with only the module's own commits, for a plugin to work out a version or + * release notes from. The context itself is left as it is. + */ +function forComponent(context) { + const commits = ownCommits(context); + if (context.logger && commits !== context.commits) { + context.logger.log(`${commits.length} of ${context.commits.length} commits change ` + + `${componentDirectory(context.cwd || process.cwd())}/; only those count for its release`); + } + return { ...context, commits }; +} + +module.exports = { componentDirectory, ownCommits, forComponent }; diff --git a/release/component-commits.test.js b/release/component-commits.test.js new file mode 100644 index 0000000..4afa691 --- /dev/null +++ b/release/component-commits.test.js @@ -0,0 +1,81 @@ +// Run with: node --test release/*.test.js +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { componentDirectory, ownCommits, forComponent } = require('./component-commits'); + +/** A repository with two components, and a commit for each kind of change. */ +function repository() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'component-commits-'))); + const git = (...args) => execFileSync('git', args, { cwd: root }).toString().trim(); + git('init', '-q'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'Test'); + git('config', 'commit.gpgsign', 'false'); + const commit = (message, ...files) => { + for (const file of files) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.appendFileSync(path.join(root, file), `${message}\n`); + } + git('add', '-A'); + git('commit', '-q', '-m', message); + return { hash: git('rev-parse', 'HEAD'), message }; + }; + const commits = { + first: commit('feat: the first commit', 'publisher/a.js'), + publisher: commit('fix: the publisher', 'publisher/src/b.js'), + subscriber: commit('feat: the subscriber', 'subscriber/c.java'), + both: commit('fix: both', 'publisher/d.js', 'subscriber/d.java'), + outside: commit('ci: the workflows', '.github/workflows/e.yml'), + lookalike: commit('feat: a directory whose name starts the same', 'publisher-docs/f.md'), + }; + return { root, commits }; +} + +test('a component is the directory semantic-release runs in, relative to the repository', () => { + const { root } = repository(); + + assert.equal(componentDirectory(path.join(root, 'publisher')), 'publisher'); + assert.equal(componentDirectory(root), ''); +}); + +test('a component keeps only the commits that change something inside its directory', () => { + const { root, commits } = repository(); + const all = Object.values(commits); + + const publisher = ownCommits({ cwd: path.join(root, 'publisher'), commits: all }); + const subscriber = ownCommits({ cwd: path.join(root, 'subscriber'), commits: all }); + + assert.deepEqual(publisher.map((c) => c.message), + ['feat: the first commit', 'fix: the publisher', 'fix: both']); + assert.deepEqual(subscriber.map((c) => c.message), ['feat: the subscriber', 'fix: both']); +}); + +test('run at the repository root, every commit counts, as before', () => { + const { root, commits } = repository(); + const all = Object.values(commits); + + assert.equal(ownCommits({ cwd: root, commits: all }).length, all.length); +}); + +test('the context is copied with the commits filtered, and says what it dropped', () => { + const { root, commits } = repository(); + const logged = []; + const context = { + cwd: path.join(root, 'subscriber'), + commits: Object.values(commits), + logger: { log: (...args) => logged.push(args.join(' ')) }, + lastRelease: { version: '1.0.0' }, + }; + + const filtered = forComponent(context); + + assert.equal(filtered.commits.length, 2); + assert.equal(filtered.lastRelease, context.lastRelease); + assert.equal(context.commits.length, 6, 'the original context is left alone'); + assert.deepEqual(logged, ['2 of 6 commits change subscriber/; only those count for its release']); +}); diff --git a/release/component-release-notes.js b/release/component-release-notes.js new file mode 100644 index 0000000..9d3213d --- /dev/null +++ b/release/component-release-notes.js @@ -0,0 +1,14 @@ +// The release notes of a module, from the commits that change it: see component-commits.js. +// Each module's release.config.js names this file where it named +// @semantic-release/release-notes-generator, with the same options; this hands those options, +// and a context holding only the module's own commits, to the real generator. +// +// Resolved from process.cwd() rather than from this file: semantic-release runs in the +// module's directory, and the dependency is installed in the module's own node_modules, not +// beside this file. +const releaseNotesGenerator = require(require.resolve('@semantic-release/release-notes-generator', { paths: [process.cwd()] })); +const { forComponent } = require('./component-commits'); + +module.exports = { + generateNotes: async (pluginConfig, context) => releaseNotesGenerator.generateNotes(pluginConfig, forComponent(context)) +}; diff --git a/sedr-library/release.config.js b/sedr-library/release.config.js index 9c6b89d..ee5c020 100644 --- a/sedr-library/release.config.js +++ b/sedr-library/release.config.js @@ -13,7 +13,7 @@ module.exports = { // old shared scheme, so numbering continues from there instead of restarting. tagFormat: "sedr-library-v${version}", plugins: [ - ["@semantic-release/commit-analyzer", { + ["../release/component-commit-analyzer", { preset: 'angular', releaseRules: [ { type: 'feat', release: 'minor' }, @@ -29,7 +29,7 @@ module.exports = { breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/ } }], - ["@semantic-release/release-notes-generator", { + ["../release/component-release-notes", { preset: 'angular', parserOpts: { noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'],