@avi2dg/checks 0.22.0 → 0.24.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.24.0
6
+
7
+ Released 2026-09-27.
8
+
9
+ ### Features
10
+
11
+ - **scripts:** ship checks-changelog, checks-release-notes and checks-release-report bins [#77](https://github.com/avi2d/checks/pull/77)
12
+
13
+ ### Fixes
14
+
15
+ - **scripts:** make checks-vendor re-freeze a cached tree whose owner write bit came back [#76](https://github.com/avi2d/checks/pull/76)
16
+
17
+ ## 0.23.0
18
+
19
+ Released 2026-09-26.
20
+
21
+ ### Breaking changes
22
+
23
+ - move cognitive-complexity into its own readability oxlint plugin [#74](https://github.com/avi2d/checks/pull/74)
24
+
5
25
  ## 0.22.0
6
26
 
7
27
  Released 2026-09-26.
package/CONTRIBUTING.md CHANGED
@@ -26,8 +26,8 @@ Each generated file is committed, and lint, the suite or CI's diff after the bui
26
26
 
27
27
  To regenerate after an edit:
28
28
 
29
- 1. After editing `effect-channel/` or the templates in `scripts/doc-templates.ts`, run `bun run build`.
30
- It rewrites `dist/index.js` and `templates/`.
29
+ 1. After editing `effect-channel/`, `readability/` or the templates in `scripts/doc-templates.ts`, run `bun run build`.
30
+ It rewrites `dist/` and `templates/`.
31
31
  1. After editing anything a generated block names as its source in its opening marker, run `bun run build`, which rewrites every generated block.
32
32
  1. Commit what the command rewrote in the same commit as the edit.
33
33
 
@@ -68,8 +68,9 @@ To place a change:
68
68
  | Path | What it holds |
69
69
  | --- | --- |
70
70
  | `scripts/` | every bin, and the modules they share |
71
- | `effect-channel/` | the oxlint plugin with the Effect error-channel and cognitive complexity rules |
72
- | `dist/` | the committed oxlint plugin bundle |
71
+ | `effect-channel/` | the oxlint plugin with the Effect error-channel rules |
72
+ | `readability/` | the oxlint plugin with the readability rules |
73
+ | `dist/` | the committed oxlint plugin bundles |
73
74
  | `presets/` | the Effect rule blocks consumers copy into native configs |
74
75
  | `templates/` | one template per kind of doc file, which `bun run build` renders |
75
76
  | `CHANGELOG.md` | every release, which `bun run build` writes from the conventional commits |
@@ -83,7 +84,7 @@ To place a change:
83
84
  A new bin gets its page under `docs/gates/`, and the suite fails until it has one.
84
85
 
85
86
  This repository holds itself to the kit, with two exceptions of its own.
86
- Its `.dependency-cruiser.cjs` redeclares `no-orphans` with the plugin entry added to its `pathNot`.
87
+ Its `.dependency-cruiser.cjs` redeclares `no-orphans` with each plugin entry added to its `pathNot`.
87
88
  Its `.oxlintrc.json` lifts `effect-channel/no-throw` from `scripts/comment-matchers.ts`, whose synchronous `refused()` a host loads without `node_modules`.
88
89
 
89
90
  ## Related topics
package/README.md CHANGED
@@ -126,6 +126,9 @@ These bins run on their own:
126
126
  - [`checks-mutation-compare`](docs/gates/checks-mutation-compare.md) holds every mutant in a pull request to no regression.
127
127
  - [`checks-subsumed-tests`](docs/gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
128
128
  - [`checks-backtest`](docs/gates/checks-backtest.md) reports what the comment check would have refused in recent history.
129
+ - [`checks-changelog`](docs/gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
130
+ - [`checks-release-notes`](docs/gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
131
+ - [`checks-release-report`](docs/gates/checks-release-report.md) tells whether the history holds unreleased features or fixes since the last tag.
129
132
  - [`checks-vendor`](docs/gates/checks-vendor.md) pins each library its `prepare` arguments name to a shared read-only clone and links it under `repos/`.
130
133
 
131
134
  `checks-lint` has [its own page](docs/gates/checks-lint.md), which says which range it resolves.
@@ -164,7 +167,7 @@ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
164
167
  | `oxlintrc.json` | the oxlint base config `.oxlintrc.json` extends |
165
168
  | `stryker.preset.js` | the Stryker mutation-testing preset |
166
169
  | `tsconfig.effect.json` | the tsconfig fragment with the Effect language-service block |
167
- | `dist/` | the compiled oxlint plugin with the Effect error-channel and cognitive complexity rules |
170
+ | `dist/` | the compiled oxlint plugins, one per purpose |
168
171
 
169
172
  <!-- end generated shipped -->
170
173
 
@@ -0,0 +1,122 @@
1
+ // effect-channel/no-error-channel-escape.ts
2
+ var EFFECT_SOURCES = new Set(["effect", "effect/Effect"]);
3
+ var INSTEAD = {
4
+ drops: "it drops the failure and the success together, so no caller can tell one from the other. Handle the error by tag, or keep it as a value with Effect.result or Effect.exit",
5
+ swallows: "a Cause is the typed error plus defects plus interruption, so this swallows the bugs and the cancellations along with it. Catch the errors you named, with Effect.catchTag or Effect.catchTags",
6
+ blind: "the handler cannot see what it is recovering from, so every error in the channel collapses into one fallback. Name the errors with Effect.catchTag, or take the error and use it"
7
+ };
8
+ var REFUSED = new Map([
9
+ ["ignore", INSTEAD.drops],
10
+ ["ignoreCause", INSTEAD.drops],
11
+ ["catchCause", INSTEAD.swallows],
12
+ ["catchCauseIf", INSTEAD.swallows],
13
+ ["catchCauseFilter", INSTEAD.swallows]
14
+ ]);
15
+ var blindToTheError = (handler) => {
16
+ if (!handler)
17
+ return false;
18
+ if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression")
19
+ return false;
20
+ return handler.params.every((param) => param.type === "Identifier" && /^_+$/.test(param.name));
21
+ };
22
+ var rule = {
23
+ meta: {
24
+ type: "problem",
25
+ docs: { description: "Disallow the combinators that erase Effect's error channel" }
26
+ },
27
+ create(context) {
28
+ const effect = new Set;
29
+ const named = (node) => {
30
+ if (!node || node.type !== "MemberExpression" || node.computed)
31
+ return null;
32
+ if (node.object.type !== "Identifier" || !effect.has(node.object.name))
33
+ return null;
34
+ return node.property.type === "Identifier" ? node.property.name : null;
35
+ };
36
+ const refuse = (node, combinator, instead) => {
37
+ context.report({ node, message: `Effect.${combinator} erases the error channel: ${instead}` });
38
+ };
39
+ return {
40
+ ImportDeclaration(node) {
41
+ if (!EFFECT_SOURCES.has(node.source.value))
42
+ return;
43
+ const module = node.source.value === "effect/Effect";
44
+ for (const specifier of node.specifiers) {
45
+ if (specifier.type === "ImportSpecifier") {
46
+ if (specifier.imported.type === "Identifier" && specifier.imported.name === "Effect")
47
+ effect.add(specifier.local.name);
48
+ } else if (module)
49
+ effect.add(specifier.local.name);
50
+ }
51
+ },
52
+ MemberExpression(node) {
53
+ const combinator = named(node);
54
+ if (combinator === null)
55
+ return;
56
+ const instead = REFUSED.get(combinator);
57
+ if (instead !== undefined)
58
+ refuse(node, combinator, instead);
59
+ },
60
+ CallExpression(node) {
61
+ if (named(node.callee) !== "catch")
62
+ return;
63
+ if (!blindToTheError(node.arguments[node.arguments.length - 1]))
64
+ return;
65
+ refuse(node, "catch", INSTEAD.blind);
66
+ }
67
+ };
68
+ }
69
+ };
70
+ var no_error_channel_escape_default = rule;
71
+
72
+ // effect-channel/no-throw.ts
73
+ var rule2 = {
74
+ meta: {
75
+ type: "problem",
76
+ docs: { description: "Disallow throw, which fails outside Effect's error channel" }
77
+ },
78
+ create(context) {
79
+ return {
80
+ ThrowStatement(node) {
81
+ context.report({
82
+ node,
83
+ message: "throw escapes the error channel: no type records the failure, so no caller has to answer for it. Define the failure with Schema.TaggedError and fail with it through Effect.fail, so it stays in E for Effect.catchTag to handle"
84
+ });
85
+ }
86
+ };
87
+ }
88
+ };
89
+ var no_throw_default = rule2;
90
+
91
+ // effect-channel/no-try-catch.ts
92
+ var rule3 = {
93
+ meta: {
94
+ type: "problem",
95
+ docs: { description: "Disallow a try statement with a catch clause, which recovers outside Effect's error channel" }
96
+ },
97
+ create(context) {
98
+ return {
99
+ CatchClause(node) {
100
+ context.report({
101
+ node,
102
+ message: "catch recovers outside the error channel: it takes whatever was thrown as unknown, bugs included. Wrap the throwing call in Effect.try or Effect.tryPromise, whose catch maps the cause to a Schema.TaggedError, and recover by tag with Effect.catchTag"
103
+ });
104
+ }
105
+ };
106
+ }
107
+ };
108
+ var no_try_catch_default = rule3;
109
+
110
+ // effect-channel/index.ts
111
+ var plugin = {
112
+ meta: { name: "effect-channel" },
113
+ rules: {
114
+ "no-error-channel-escape": no_error_channel_escape_default,
115
+ "no-throw": no_throw_default,
116
+ "no-try-catch": no_try_catch_default
117
+ }
118
+ };
119
+ var effect_channel_default = plugin;
120
+ export {
121
+ effect_channel_default as default
122
+ };
@@ -1,4 +1,4 @@
1
- // effect-channel/cognitive-nodes.ts
1
+ // readability/cognitive-nodes.ts
2
2
  var CONTROL_TYPES = ["IfStatement", "ConditionalExpression", "SwitchStatement", "SwitchCase", "TryStatement", "CatchClause"];
3
3
  var LOOP_TYPES = ["ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "LabeledStatement"];
4
4
  var CALL_TYPES = ["LogicalExpression", "BreakStatement", "ContinueStatement", "CallExpression", "NewExpression", "ImportExpression"];
@@ -7,7 +7,7 @@ var PLAIN_A_TYPES = ["BlockStatement", "ExpressionStatement", "ReturnStatement",
7
7
  var PLAIN_B_TYPES = ["ClassDeclaration", "ClassExpression", "ClassBody", "MethodDefinition", "TSAbstractMethodDefinition", "PropertyDefinition", "TSAbstractPropertyDefinition", "AccessorProperty", "TSAbstractAccessorProperty", "ObjectExpression", "ArrayExpression"];
8
8
  var PLAIN_C_TYPES = ["AwaitExpression", "UnaryExpression", "UpdateExpression", "SpreadElement", "YieldExpression", "BinaryExpression", "AssignmentExpression", "TSAsExpression", "TSSatisfiesExpression", "TSTypeAssertion", "TSNonNullExpression", "ChainExpression", "ParenthesizedExpression", "Decorator", "TSInstantiationExpression", "MemberExpression", "JSXMemberExpression", "TemplateLiteral", "TaggedTemplateExpression", "SequenceExpression", "JSXElement", "JSXFragment", "JSXOpeningElement", "JSXAttribute", "JSXExpressionContainer", "JSXSpreadChild", "JSXSpreadAttribute"];
9
9
 
10
- // effect-channel/cognitive-plain.ts
10
+ // readability/cognitive-plain.ts
11
11
  function isControl(node) {
12
12
  return CONTROL_TYPES.includes(node.type);
13
13
  }
@@ -142,7 +142,7 @@ function plainChildrenC(node) {
142
142
  }
143
143
  }
144
144
 
145
- // effect-channel/cognitive.ts
145
+ // readability/cognitive.ts
146
146
  function unreachable2(_value) {}
147
147
  function scoreList(state, nodes, nesting, parent) {
148
148
  for (const node of nodes) {
@@ -334,7 +334,7 @@ function cognitiveComplexity(root, names) {
334
334
  return state.recursive ? state.total + 1 : state.total;
335
335
  }
336
336
 
337
- // effect-channel/cognitive-complexity.ts
337
+ // readability/cognitive-complexity.ts
338
338
  var DEFAULT_MAX = 15;
339
339
  function maxOf(options) {
340
340
  const [first] = options;
@@ -416,126 +416,14 @@ var rule = {
416
416
  };
417
417
  var cognitive_complexity_default = rule;
418
418
 
419
- // effect-channel/no-error-channel-escape.ts
420
- var EFFECT_SOURCES = new Set(["effect", "effect/Effect"]);
421
- var INSTEAD = {
422
- drops: "it drops the failure and the success together, so no caller can tell one from the other. Handle the error by tag, or keep it as a value with Effect.result or Effect.exit",
423
- swallows: "a Cause is the typed error plus defects plus interruption, so this swallows the bugs and the cancellations along with it. Catch the errors you named, with Effect.catchTag or Effect.catchTags",
424
- blind: "the handler cannot see what it is recovering from, so every error in the channel collapses into one fallback. Name the errors with Effect.catchTag, or take the error and use it"
425
- };
426
- var REFUSED = new Map([
427
- ["ignore", INSTEAD.drops],
428
- ["ignoreCause", INSTEAD.drops],
429
- ["catchCause", INSTEAD.swallows],
430
- ["catchCauseIf", INSTEAD.swallows],
431
- ["catchCauseFilter", INSTEAD.swallows]
432
- ]);
433
- var blindToTheError = (handler) => {
434
- if (!handler)
435
- return false;
436
- if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression")
437
- return false;
438
- return handler.params.every((param) => param.type === "Identifier" && /^_+$/.test(param.name));
439
- };
440
- var rule2 = {
441
- meta: {
442
- type: "problem",
443
- docs: { description: "Disallow the combinators that erase Effect's error channel" }
444
- },
445
- create(context) {
446
- const effect = new Set;
447
- const named = (node) => {
448
- if (!node || node.type !== "MemberExpression" || node.computed)
449
- return null;
450
- if (node.object.type !== "Identifier" || !effect.has(node.object.name))
451
- return null;
452
- return node.property.type === "Identifier" ? node.property.name : null;
453
- };
454
- const refuse = (node, combinator, instead) => {
455
- context.report({ node, message: `Effect.${combinator} erases the error channel: ${instead}` });
456
- };
457
- return {
458
- ImportDeclaration(node) {
459
- if (!EFFECT_SOURCES.has(node.source.value))
460
- return;
461
- const module = node.source.value === "effect/Effect";
462
- for (const specifier of node.specifiers) {
463
- if (specifier.type === "ImportSpecifier") {
464
- if (specifier.imported.type === "Identifier" && specifier.imported.name === "Effect")
465
- effect.add(specifier.local.name);
466
- } else if (module)
467
- effect.add(specifier.local.name);
468
- }
469
- },
470
- MemberExpression(node) {
471
- const combinator = named(node);
472
- if (combinator === null)
473
- return;
474
- const instead = REFUSED.get(combinator);
475
- if (instead !== undefined)
476
- refuse(node, combinator, instead);
477
- },
478
- CallExpression(node) {
479
- if (named(node.callee) !== "catch")
480
- return;
481
- if (!blindToTheError(node.arguments[node.arguments.length - 1]))
482
- return;
483
- refuse(node, "catch", INSTEAD.blind);
484
- }
485
- };
486
- }
487
- };
488
- var no_error_channel_escape_default = rule2;
489
-
490
- // effect-channel/no-throw.ts
491
- var rule3 = {
492
- meta: {
493
- type: "problem",
494
- docs: { description: "Disallow throw, which fails outside Effect's error channel" }
495
- },
496
- create(context) {
497
- return {
498
- ThrowStatement(node) {
499
- context.report({
500
- node,
501
- message: "throw escapes the error channel: no type records the failure, so no caller has to answer for it. Define the failure with Schema.TaggedError and fail with it through Effect.fail, so it stays in E for Effect.catchTag to handle"
502
- });
503
- }
504
- };
505
- }
506
- };
507
- var no_throw_default = rule3;
508
-
509
- // effect-channel/no-try-catch.ts
510
- var rule4 = {
511
- meta: {
512
- type: "problem",
513
- docs: { description: "Disallow a try statement with a catch clause, which recovers outside Effect's error channel" }
514
- },
515
- create(context) {
516
- return {
517
- CatchClause(node) {
518
- context.report({
519
- node,
520
- message: "catch recovers outside the error channel: it takes whatever was thrown as unknown, bugs included. Wrap the throwing call in Effect.try or Effect.tryPromise, whose catch maps the cause to a Schema.TaggedError, and recover by tag with Effect.catchTag"
521
- });
522
- }
523
- };
524
- }
525
- };
526
- var no_try_catch_default = rule4;
527
-
528
- // effect-channel/index.ts
419
+ // readability/index.ts
529
420
  var plugin = {
530
- meta: { name: "effect-channel" },
421
+ meta: { name: "readability" },
531
422
  rules: {
532
- "no-error-channel-escape": no_error_channel_escape_default,
533
- "no-throw": no_throw_default,
534
- "no-try-catch": no_try_catch_default,
535
423
  "cognitive-complexity": cognitive_complexity_default
536
424
  }
537
425
  };
538
- var effect_channel_default = plugin;
426
+ var readability_default = plugin;
539
427
  export {
540
- effect_channel_default as default
428
+ readability_default as default
541
429
  };
@@ -57,12 +57,12 @@ The kit's own `.oxlintrc.json` sets these limits for each size override, and a r
57
57
 
58
58
  <!-- generated size-limits: bun run build writes it from .oxlintrc.json, SIZE_RULES in scripts/size-rules.ts and scripts/doc-blocks.ts -->
59
59
 
60
- | Limits | oxlint rule | `effect-channel/**/*.ts`, `scripts/**/*.ts` | `tests/**/*.ts` |
60
+ | Limits | oxlint rule | `effect-channel/**/*.ts`, `readability/**/*.ts`, `scripts/**/*.ts` | `tests/**/*.ts` |
61
61
  | --- | --- | --- | --- |
62
62
  | The most lines a file may hold, blank and comment lines counted | `max-lines` | 400 | 600 |
63
63
  | The most lines a function may span, blank and comment lines counted | `max-lines-per-function` | 100 | off |
64
64
  | The most statements a function may hold | `max-statements` | 30 | 50 |
65
- | The highest cognitive complexity a function may reach, a switch counted once | `effect-channel/cognitive-complexity` | 15 | 15 |
65
+ | The highest cognitive complexity a function may reach, a switch counted once | `readability/cognitive-complexity` | 15 | 15 |
66
66
  | The deepest a block may nest inside a function | `max-depth` | 4 | 4 |
67
67
 
68
68
  <!-- end generated size-limits -->
package/docs/design.md CHANGED
@@ -12,7 +12,7 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
12
12
  - `files` in package.json is the published surface: `tests/`, `AGENTS.md` and the `.ts` plugin source never reach an install.
13
13
  npm adds `package.json`, `README` and `LICENSE` to the tarball whatever `files` says.
14
14
  `bun pm pack` builds the same tarball the registry serves, which is what the packed-tarball consumer e2e test installs.
15
- - The plugin ships compiled as `dist/index.js`, built with `bun build effect-channel/index.ts --outdir dist --target node --format esm`.
15
+ - Each plugin ships compiled under `dist/`, built with `bun build <name>/index.ts --outdir dist/<name> --target node --format esm`.
16
16
  Node refuses to type-strip a `.ts` plugin under `node_modules`, so the `.ts` source would fail to load from an installed package.
17
17
  - The size rules are plain oxlint rules at `error` in `.oxlintrc.json`, and `bun run lint` enforces them on the whole tree.
18
18
  A repository records its existing violations with `oxlint --suppress-all`, and `checks-suppressions-ratchet` refuses any count that rises.
@@ -51,7 +51,7 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
51
51
  `@effect/platform-node-shared` is a direct dependency at the same exact version only to pin it: `@effect/platform-bun` asks for it with a `^` range, and a newer rc peers on a newer `effect` than consumers install, so all three move together.
52
52
  - Each runnable script ships a `checks-` bin entry, so consumer `package.json` scripts call the short name, which the package manager puts on `PATH` only there.
53
53
  A shell runs it through `bun run`, which never falls back to the registry the way `bunx` does.
54
- The `.ts` checks keep a `bun` shebang, which needs no build step and no `dist/` entry, unlike the oxlint plugin that node loads.
54
+ The `.ts` checks keep a `bun` shebang, which needs no build step and no `dist/` entry, unlike the oxlint plugins that node loads.
55
55
  - `checks-lint` runs each gate as its own bin in a child process rather than importing it, so a gate behaves the same called alone or through the entry point, and `lint-coverage.sh` stays a shell script.
56
56
  The gates run one at a time with their output passed straight through, so each report reads whole and in the table's order.
57
57
  - `checks-lint` determines applicable gates from tracked files instead of accepting a repository selection.
@@ -96,6 +96,11 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
96
96
  A directory the range deletes still counts as this repository's, so a path under it reads as stale rather than foreign.
97
97
  - The command check passes over a page whose front matter sets `audience: consumers`.
98
98
  Such a page speaks to a consuming repository, whose scripts are not this one's.
99
+ - `checks-vendor` strips an owner write bit that came back on a cached tree and keeps the tree, rather than refusing it or cloning it again.
100
+ The GitHub Actions runner clears the read only mode of each item before it deletes `$RUNNER_TEMP`, and on a directory link that chmod lands on the shared tree's top directory.
101
+ Refusing the tree would fail every later job on the runner until a person cleared it, and cloning it again would need the network after every such job.
102
+ A write bit is not a write, so the run strips it and then holds the tree to the recorded commit as it holds any tree, and a write it finds there still fails the run.
103
+ A group or other write bit still fails the run, since another user could have edited `.git/config` through it before `git status` reads it.
99
104
 
100
105
  ## Related topics
101
106
 
@@ -0,0 +1,57 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-changelog
6
+
7
+ `checks-changelog` writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
8
+
9
+ ## What it checks
10
+
11
+ It reads the version from `package.json` and treats that version as the release being prepared.
12
+ It lists every conventional commit the release closes, grouped as Features, Fixes, Performance, Reverts and Breaking changes.
13
+ It links each entry to its pull request under the repository address `package.json` names.
14
+ It keeps the date a released section already carries and dates a new section today.
15
+ It writes the whole file newest first, so the changelog is never edited by hand.
16
+ A repository with no tag yet releases from its first commit.
17
+ A release with no conventional commit worth listing keeps only its heading and its date.
18
+
19
+ ## What it reads
20
+
21
+ It reads `package.json`, `CHANGELOG.md` and the git history from the repository root.
22
+ It finds releases in the version bumps of `package.json` across all of `HEAD` ancestry, so a checkout without tags writes the same file.
23
+ It refuses a shallow checkout, since the releases reach back past its history.
24
+ It refuses a `package.json` with no repository address, since each entry links its pull request under it.
25
+ It refuses a repository address that is no `https` address once `git+`, a trailing slash and `.git` are dropped, since a pull request link needs one.
26
+
27
+ ## Arguments
28
+
29
+ ```sh
30
+ checks-changelog
31
+ ```
32
+
33
+ It takes no arguments.
34
+ Run it through the build, as the release workflow in [checks-release-notes](checks-release-notes.md) shows.
35
+
36
+ ## Exit codes
37
+
38
+ | Code | Result |
39
+ | --- | --- |
40
+ | 0 | The changelog was written. |
41
+ | 2 | The checkout is shallow, or `package.json` has no `https` repository address. |
42
+
43
+ ## Sample output
44
+
45
+ ```
46
+ checks-changelog: wrote 2 release(s) to CHANGELOG.md
47
+ ```
48
+
49
+ ## Opting out
50
+
51
+ Nothing runs it but the build of a repository that keeps a changelog.
52
+ A repository with no versioned releases leaves it out.
53
+
54
+ ## Related topics
55
+
56
+ - [checks-release-notes](checks-release-notes.md)
57
+ - [checks-release-report](checks-release-report.md)
@@ -0,0 +1,141 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-release-notes
6
+
7
+ `checks-release-notes` writes the `CHANGELOG.md` section of one version to a file for a GitHub release.
8
+
9
+ ## What it checks
10
+
11
+ It reads the section the version heads and trims the blank lines around it.
12
+ It writes the notes to the output path.
13
+ It fails when the changelog holds no section for the version or the section is empty.
14
+
15
+ ## What it reads
16
+
17
+ It reads `CHANGELOG.md` from the working directory.
18
+ It reads the version from its arguments, not from `package.json`.
19
+
20
+ ## Arguments
21
+
22
+ ```sh
23
+ checks-release-notes <version> <output>
24
+ ```
25
+
26
+ The version names the `CHANGELOG.md` section to write, as `0.22.0` with no leading `v`.
27
+ The output names the file to write.
28
+
29
+ ## Exit codes
30
+
31
+ | Code | Result |
32
+ | --- | --- |
33
+ | 0 | The notes were written. |
34
+ | 2 | The arguments do not parse, or `CHANGELOG.md` holds no notes for the version. |
35
+
36
+ ## Sample output
37
+
38
+ ```
39
+ Released 2026-09-27.
40
+
41
+ ### Features
42
+
43
+ - Add a release [#67](https://github.com/avi2d/checks/pull/67)
44
+ ```
45
+
46
+ ## Release workflow
47
+
48
+ Each consumer owns `.github/workflows/release.yml` and calls the kit bins from it.
49
+ The version lives in `package.json`, and the tag names the same version with a leading `v`.
50
+ The build writes the changelog, so the release pull request carries the notes before the tag exists.
51
+ A repository that publishes a package to npm releases with this workflow:
52
+
53
+ ```yaml
54
+ name: release
55
+ on:
56
+ push:
57
+ tags: ["v*"]
58
+ permissions:
59
+ contents: read
60
+ id-token: write
61
+ jobs:
62
+ publish:
63
+ runs-on: ubuntu-latest
64
+ steps:
65
+ - uses: actions/checkout@v5
66
+ with:
67
+ fetch-depth: 0
68
+ - uses: oven-sh/setup-bun@v2
69
+ - run: bun install --frozen-lockfile
70
+ - run: bun run build
71
+ - run: git diff --exit-code
72
+ - name: tag matches package version
73
+ run: test "v$(bun -p "require('./package.json').version")" = "$GITHUB_REF_NAME"
74
+ - uses: actions/setup-node@v4
75
+ with:
76
+ node-version: 24
77
+ registry-url: https://registry.npmjs.org
78
+ - run: npm publish
79
+ github-release:
80
+ needs: publish
81
+ runs-on: ubuntu-latest
82
+ permissions:
83
+ contents: write
84
+ steps:
85
+ - uses: actions/checkout@v5
86
+ with:
87
+ fetch-depth: 0
88
+ - uses: oven-sh/setup-bun@v2
89
+ - run: bun install --frozen-lockfile
90
+ - name: extract release notes
91
+ run: ./node_modules/.bin/checks-release-notes "${GITHUB_REF_NAME#v}" "$RUNNER_TEMP/release-notes.md"
92
+ - name: create the GitHub release
93
+ env:
94
+ GH_TOKEN: ${{ github.token }}
95
+ run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
96
+ ```
97
+
98
+ A repository that never publishes to npm releases with this workflow instead, since only `checks` publishes a package:
99
+
100
+ ```yaml
101
+ name: release
102
+ on:
103
+ push:
104
+ tags: ["v*"]
105
+ permissions:
106
+ contents: read
107
+ jobs:
108
+ github-release:
109
+ runs-on: ubuntu-latest
110
+ permissions:
111
+ contents: write
112
+ steps:
113
+ - uses: actions/checkout@v5
114
+ with:
115
+ fetch-depth: 0
116
+ - uses: oven-sh/setup-bun@v2
117
+ - run: bun install --frozen-lockfile
118
+ - run: bun run build
119
+ - run: git diff --exit-code
120
+ - name: tag matches package version
121
+ run: test "v$(bun -p "require('./package.json').version")" = "$GITHUB_REF_NAME"
122
+ - name: extract release notes
123
+ run: ./node_modules/.bin/checks-release-notes "${GITHUB_REF_NAME#v}" "$RUNNER_TEMP/release-notes.md"
124
+ - name: create the GitHub release
125
+ env:
126
+ GH_TOKEN: ${{ github.token }}
127
+ run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
128
+ ```
129
+
130
+ Cut a release by merging a pull request that holds only the version bump and the built changelog, then tagging the merge commit on the target branch and pushing the tag.
131
+ The workflow refuses a tag that disagrees with `package.json`, so the tag always names the section the notes come from.
132
+
133
+ ## Opting out
134
+
135
+ Nothing runs it but the release workflow of a repository that publishes GitHub releases.
136
+ A repository with no versioned releases leaves it out.
137
+
138
+ ## Related topics
139
+
140
+ - [checks-changelog](checks-changelog.md)
141
+ - [checks-release-report](checks-release-report.md)
@@ -0,0 +1,64 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-release-report
6
+
7
+ `checks-release-report` tells whether the history holds unreleased features or fixes since the last tag.
8
+
9
+ ## What it checks
10
+
11
+ It lists the conventional commits after the last tag reachable from `HEAD` that reads as a version such as `v0.2.0`.
12
+ It counts a commit when its subject falls in Features, Fixes, Performance, Reverts or Breaking changes, the groups `checks-changelog` writes.
13
+ It prints each unreleased subject on its own line under a count.
14
+ A repository with no tag yet reports every such commit in its history.
15
+ A history with no conventional release-worthy commit reports no unreleased changes.
16
+
17
+ ## What it reads
18
+
19
+ It reads the tags and the commit subjects from the git history.
20
+ It refuses a shallow checkout, since the tag it sees may not be the last one.
21
+ It passes over any other tag, since only a release tag reads as a version.
22
+
23
+ ## Arguments
24
+
25
+ ```sh
26
+ checks-release-report
27
+ ```
28
+
29
+ It takes no arguments.
30
+ Run it before cutting a tag to decide whether a release is due.
31
+
32
+ ## Exit codes
33
+
34
+ | Code | Result |
35
+ | --- | --- |
36
+ | 0 | No unreleased changes were found. |
37
+ | 1 | Unreleased changes were found. |
38
+ | 2 | The arguments do not parse, or the history cannot be read. |
39
+
40
+ ## Sample output
41
+
42
+ A history with unreleased changes prints the count and each subject:
43
+
44
+ ```
45
+ release-report: 2 unreleased change(s) since v0.1.0:
46
+ feat: price a bill (#4)
47
+ fix(parts): keep the order of parts (#3)
48
+ ```
49
+
50
+ A history with nothing to release prints one line:
51
+
52
+ ```
53
+ release-report: no unreleased changes since v0.1.0
54
+ ```
55
+
56
+ ## Opting out
57
+
58
+ Nothing runs it but a person or a scheduler deciding when to cut a release.
59
+ A repository with no versioned releases leaves it out.
60
+
61
+ ## Related topics
62
+
63
+ - [checks-changelog](checks-changelog.md)
64
+ - [checks-release-notes](checks-release-notes.md)