@frsource/semantic-release-config 1.117.6 → 1.117.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/index.d.mts +3 -1
- package/index.mjs +3 -1
- package/package.json +3 -2
- package/skip-unchanged.d.mts +2 -0
- package/skip-unchanged.mjs +132 -0
- package/skip-unchanged.test.mjs +85 -0
package/README.md
CHANGED
|
@@ -9,3 +9,12 @@ Semantic Release configuration files used across the FRSOURCE organization.
|
|
|
9
9
|
|
|
10
10
|
export { default } from '@frsource/semantic-release-config';
|
|
11
11
|
```
|
|
12
|
+
|
|
13
|
+
## Skipping releases that change nothing
|
|
14
|
+
|
|
15
|
+
`chore(deps)` commits trigger a patch release. To avoid publishing a new version for dependency bumps that do not change what consumers receive (for example `devDependencies` updates), the commit analyzer is wrapped: when `chore(deps)` commits are the only reason to release, the package is packed with `npm pack` and compared to the tarball of the last published version. If the content is identical, the release is skipped.
|
|
16
|
+
|
|
17
|
+
- Compared byte for byte, except `package.json`, where `version` and `devDependencies` (and registry-injected fields) are ignored. A change to anything else in `package.json`, such as `dependencies`, still releases.
|
|
18
|
+
- Any `fix`, `feat` or breaking commit always releases, without packing.
|
|
19
|
+
- If the comparison cannot be made (first release, version not on the registry, offline), the release goes ahead.
|
|
20
|
+
- Build the package before running semantic-release, so the packed output reflects the release.
|
package/index.d.mts
CHANGED
package/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { analyzeCommits } from './skip-unchanged.mjs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @type {import('semantic-release').GlobalConfig}
|
|
3
5
|
*/
|
|
@@ -5,7 +7,7 @@ export default {
|
|
|
5
7
|
branches: ['main'],
|
|
6
8
|
plugins: [
|
|
7
9
|
[
|
|
8
|
-
|
|
10
|
+
{ analyzeCommits },
|
|
9
11
|
{
|
|
10
12
|
releaseRules: [{ type: 'chore', scope: 'deps', release: 'patch' }],
|
|
11
13
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frsource/semantic-release-config",
|
|
3
|
-
"version": "1.117.
|
|
3
|
+
"version": "1.117.7",
|
|
4
4
|
"main": "index.mjs",
|
|
5
5
|
"types": "index.d.mts",
|
|
6
6
|
"dependencies": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"@semantic-release/release-notes-generator": "14.1.1"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@frsource/eslint-config": "1.86.
|
|
15
|
+
"@frsource/eslint-config": "1.86.4",
|
|
16
16
|
"@frsource/prettier-config": "1.48.2",
|
|
17
17
|
"@frsource/release-it-config": "1.60.3",
|
|
18
18
|
"@types/node": "24.19.2",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"funding": "https://buymeacoffee.com/frsource",
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsc index.mjs --declaration --emitDeclarationOnly --allowJs --moduleResolution bundler --target esnext --skipLibCheck",
|
|
50
|
+
"test": "node --test",
|
|
50
51
|
"release": "release-it",
|
|
51
52
|
"eslint": "eslint .",
|
|
52
53
|
"prettier": "prettier . --check",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { analyzeCommits as analyze } from '@semantic-release/commit-analyzer';
|
|
14
|
+
|
|
15
|
+
/** package.json fields that never reach consumers in a meaningful way. */
|
|
16
|
+
const IGNORED_MANIFEST_FIELDS = ['version', 'devDependencies', 'gitHead'];
|
|
17
|
+
|
|
18
|
+
const sortKeys = (value) => {
|
|
19
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
20
|
+
if (value && typeof value === 'object') {
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
Object.keys(value)
|
|
23
|
+
.sort()
|
|
24
|
+
.map((key) => [key, sortKeys(value[key])]),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const normalizeManifest = (raw) => {
|
|
31
|
+
const manifest = JSON.parse(raw);
|
|
32
|
+
for (const field of IGNORED_MANIFEST_FIELDS) delete manifest[field];
|
|
33
|
+
for (const field of Object.keys(manifest)) {
|
|
34
|
+
if (field.startsWith('_')) delete manifest[field];
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(sortKeys(manifest));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const listFiles = (dir) =>
|
|
40
|
+
readdirSync(dir, { recursive: true })
|
|
41
|
+
.filter((entry) => statSync(join(dir, entry)).isFile())
|
|
42
|
+
.map((entry) => entry.split('\\').join('/'))
|
|
43
|
+
.sort();
|
|
44
|
+
|
|
45
|
+
const hash = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compares two extracted `npm pack` directories (the `package/` folders).
|
|
49
|
+
* `package.json` is compared without `version`, `devDependencies` and
|
|
50
|
+
* registry-injected fields; every other file byte by byte.
|
|
51
|
+
*
|
|
52
|
+
* @returns {boolean} true when consumers would receive identical content
|
|
53
|
+
*/
|
|
54
|
+
export const isSamePackedContent = (dirA, dirB) => {
|
|
55
|
+
const filesA = listFiles(dirA);
|
|
56
|
+
const filesB = listFiles(dirB);
|
|
57
|
+
if (JSON.stringify(filesA) !== JSON.stringify(filesB)) return false;
|
|
58
|
+
|
|
59
|
+
return filesA.every((file) => {
|
|
60
|
+
const a = readFileSync(join(dirA, file));
|
|
61
|
+
const b = readFileSync(join(dirB, file));
|
|
62
|
+
if (file === 'package.json') {
|
|
63
|
+
return normalizeManifest(a) === normalizeManifest(b);
|
|
64
|
+
}
|
|
65
|
+
return hash(a) === hash(b);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const pack = (spec, cwd, destination) => {
|
|
70
|
+
const [{ filename }] = JSON.parse(
|
|
71
|
+
execFileSync(
|
|
72
|
+
'npm',
|
|
73
|
+
['pack', spec, '--json', '--pack-destination', destination],
|
|
74
|
+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
const extracted = join(destination, `${hash(spec)}-extracted`);
|
|
78
|
+
mkdirSync(extracted);
|
|
79
|
+
execFileSync('tar', ['-xzf', join(destination, filename), '-C', extracted]);
|
|
80
|
+
return join(extracted, 'package');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Packs the current working tree and the last published version and compares
|
|
85
|
+
* them. Throws when either of them cannot be packed.
|
|
86
|
+
*/
|
|
87
|
+
const isUnchangedSinceLastRelease = ({ cwd, lastRelease }) => {
|
|
88
|
+
const { name } = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
89
|
+
const tmp = mkdtempSync(join(tmpdir(), 'skip-unchanged-'));
|
|
90
|
+
try {
|
|
91
|
+
const current = pack('.', cwd, tmp);
|
|
92
|
+
const published = pack(`${name}@${lastRelease.version}`, cwd, tmp);
|
|
93
|
+
return isSamePackedContent(current, published);
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Drop-in replacement for `@semantic-release/commit-analyzer`. When the only
|
|
101
|
+
* thing triggering a release are the dependency-bump rules, the release is
|
|
102
|
+
* skipped if the packed output is identical to the last published version.
|
|
103
|
+
* Fails open: on any problem it behaves like the plain commit analyzer.
|
|
104
|
+
*/
|
|
105
|
+
export const analyzeCommits = async (pluginConfig, context) => {
|
|
106
|
+
const releaseType = await analyze(pluginConfig, context);
|
|
107
|
+
if (releaseType !== 'patch' || !context.lastRelease?.version) {
|
|
108
|
+
return releaseType;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const withoutDepsRules = {
|
|
112
|
+
...pluginConfig,
|
|
113
|
+
releaseRules: (pluginConfig.releaseRules ?? []).filter(
|
|
114
|
+
({ scope }) => scope !== 'deps',
|
|
115
|
+
),
|
|
116
|
+
};
|
|
117
|
+
if (await analyze(withoutDepsRules, context)) return releaseType;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (isUnchangedSinceLastRelease(context)) {
|
|
121
|
+
context.logger.log(
|
|
122
|
+
`Only dependency updates since ${context.lastRelease.version} and the packed output is unchanged, skipping release.`,
|
|
123
|
+
);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
context.logger.log(
|
|
128
|
+
`Could not compare with ${context.lastRelease.version} (${error.message}), releasing.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return releaseType;
|
|
132
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { afterEach, describe, it } from 'node:test';
|
|
6
|
+
import { isSamePackedContent } from './skip-unchanged.mjs';
|
|
7
|
+
|
|
8
|
+
const dirs = [];
|
|
9
|
+
const make = (files) => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'skip-unchanged-test-'));
|
|
11
|
+
dirs.push(dir);
|
|
12
|
+
for (const [name, content] of Object.entries(files)) {
|
|
13
|
+
mkdirSync(dirname(join(dir, name)), { recursive: true });
|
|
14
|
+
writeFileSync(join(dir, name), content);
|
|
15
|
+
}
|
|
16
|
+
return dir;
|
|
17
|
+
};
|
|
18
|
+
const manifest = (extra = {}) =>
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
name: 'pkg',
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
dependencies: { a: '1.0.0' },
|
|
23
|
+
devDependencies: { b: '1.0.0' },
|
|
24
|
+
...extra,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('isSamePackedContent', () => {
|
|
32
|
+
it('is true for identical content', () => {
|
|
33
|
+
const files = { 'package.json': manifest(), 'dist/a.js': 'a' };
|
|
34
|
+
assert.equal(isSamePackedContent(make(files), make(files)), true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('ignores version, devDependencies and registry fields', () => {
|
|
38
|
+
const a = make({ 'package.json': manifest(), 'dist/a.js': 'a' });
|
|
39
|
+
const b = make({
|
|
40
|
+
'package.json': manifest({
|
|
41
|
+
version: '1.0.1',
|
|
42
|
+
devDependencies: { b: '2.0.0' },
|
|
43
|
+
gitHead: 'abc',
|
|
44
|
+
_id: 'pkg@1.0.1',
|
|
45
|
+
}),
|
|
46
|
+
'dist/a.js': 'a',
|
|
47
|
+
});
|
|
48
|
+
assert.equal(isSamePackedContent(a, b), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('detects dependencies and other manifest changes', () => {
|
|
52
|
+
const a = make({ 'package.json': manifest() });
|
|
53
|
+
assert.equal(
|
|
54
|
+
isSamePackedContent(
|
|
55
|
+
a,
|
|
56
|
+
make({ 'package.json': manifest({ dependencies: { a: '2.0.0' } }) }),
|
|
57
|
+
),
|
|
58
|
+
false,
|
|
59
|
+
);
|
|
60
|
+
assert.equal(
|
|
61
|
+
isSamePackedContent(
|
|
62
|
+
a,
|
|
63
|
+
make({ 'package.json': manifest({ engines: { node: '>=20' } }) }),
|
|
64
|
+
),
|
|
65
|
+
false,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('detects changed, added and removed files', () => {
|
|
70
|
+
const base = { 'package.json': manifest(), 'dist/a.js': 'a' };
|
|
71
|
+
const a = make(base);
|
|
72
|
+
assert.equal(
|
|
73
|
+
isSamePackedContent(a, make({ ...base, 'dist/a.js': 'b' })),
|
|
74
|
+
false,
|
|
75
|
+
);
|
|
76
|
+
assert.equal(
|
|
77
|
+
isSamePackedContent(a, make({ ...base, 'dist/b.js': 'a' })),
|
|
78
|
+
false,
|
|
79
|
+
);
|
|
80
|
+
assert.equal(
|
|
81
|
+
isSamePackedContent(a, make({ 'package.json': manifest() })),
|
|
82
|
+
false,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|