@avi2dg/checks 0.31.0 → 0.33.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -3
  3. package/dist/readability/index.js +18 -9
  4. package/docs/configs/commit-messages.md +5 -0
  5. package/docs/configs/typescript-rules.md +1 -1
  6. package/docs/design.md +42 -4
  7. package/docs/gates/checks-commit-identity.md +3 -0
  8. package/docs/gates/checks-docs.md +9 -3
  9. package/docs/gates/checks-lint.md +1 -1
  10. package/docs/gates/checks-mutation.md +2 -1
  11. package/docs/gates/checks-release-notes.md +10 -1
  12. package/docs/gates/checks-release-pr.md +185 -0
  13. package/docs/gates/checks-release-report.md +4 -2
  14. package/docs/gates/checks-release-tag.md +63 -0
  15. package/docs/gates/checks-secrets.md +93 -0
  16. package/docs/gates/checks-vendor.md +3 -3
  17. package/package.json +23 -6
  18. package/src/core/gates.ts +1 -0
  19. package/src/core/git.ts +1 -1
  20. package/src/core/lint.ts +1 -1
  21. package/src/delivery/commit-identity.ts +17 -1
  22. package/src/delivery/github.ts +38 -0
  23. package/src/delivery/gitleaks.toml +113 -0
  24. package/src/delivery/gitleaks.ts +111 -0
  25. package/src/delivery/release-pr.ts +196 -0
  26. package/src/delivery/release-report.ts +3 -30
  27. package/src/delivery/release-tag.ts +64 -0
  28. package/src/delivery/release.ts +79 -0
  29. package/src/delivery/secrets.ts +86 -0
  30. package/src/dependencies/osv-scanner.ts +3 -45
  31. package/src/dependencies/pinned-binary.ts +80 -0
  32. package/src/docs/doc-agents.ts +22 -20
  33. package/src/docs/doc-names.ts +1 -1
  34. package/src/docs/doc-references.ts +14 -7
  35. package/src/docs/docs.ts +15 -2
  36. package/src/docs/prose-matchers.ts +1 -1
  37. package/src/docs/sentence-length.ts +106 -0
  38. package/src/testing/flake.ts +1 -1
  39. package/src/testing/mutation.ts +1 -1
  40. package/src/testing/test.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.33.0
6
+
7
+ Released 2026-10-03.
8
+
9
+ ### Features
10
+
11
+ - **docs:** report sentences over trial length caps as advisory [#120](https://github.com/avi2d/checks/pull/120)
12
+
13
+ ### Fixes
14
+
15
+ - move effect to stable 4.0.0 [#122](https://github.com/avi2d/checks/pull/122)
16
+ - **complexity:** accept signed numbers and earlier props names in thin-astro defaults [#115](https://github.com/avi2d/checks/pull/115)
17
+ - **docs:** judge agent file entries and headings from a CommonMark parse [#116](https://github.com/avi2d/checks/pull/116)
18
+ - **docs:** fail link anchors the checks-docs reference rule could not check [#114](https://github.com/avi2d/checks/pull/114)
19
+
20
+ ## 0.32.0
21
+
22
+ Released 2026-09-29.
23
+
24
+ ### Features
25
+
26
+ - **delivery:** add checks-secrets gate that fails a range whose commits add a secret [#112](https://github.com/avi2d/checks/pull/112)
27
+ - **delivery:** open a release pull request daily and tag it when it merges [#111](https://github.com/avi2d/checks/pull/111)
28
+
5
29
  ## 0.31.0
6
30
 
7
31
  Released 2026-09-28.
package/README.md CHANGED
@@ -9,12 +9,12 @@ Each repository owns its workflows and native tool configs, as [Native settings]
9
9
  <!-- generated prerequisites: bun run build writes it from package.json, .bun-version and scripts/doc-blocks.ts -->
10
10
 
11
11
  - A git repository, whose history the range gates read.
12
- - Bun 1.3.13, which runs every bin.
12
+ - Bun 1.4.2, which runs every bin.
13
13
  - The peer dependencies, at the exact versions the kit pins:
14
14
  - `@effect/tsgo` 0.45.0
15
15
  - `@swc/core` 1.16.2
16
16
  - `dependency-cruiser` 18.4.0
17
- - `effect` 4.0.0-rc.115
17
+ - `effect` 4.0.0
18
18
  - `jscpd` 5.3.2
19
19
  - `oxlint` 1.83.0
20
20
  - `oxlint-tsgolint` 7.0.2002
@@ -31,7 +31,7 @@ To consume the kit from a repository:
31
31
  <!-- generated install: bun run build writes it from package.json and scripts/doc-blocks.ts -->
32
32
 
33
33
  ```sh
34
- bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0-rc.115 jscpd@5.3.2 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 typescript@7.0.2
34
+ bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0 jscpd@5.3.2 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 typescript@7.0.2
35
35
  ```
36
36
 
37
37
  <!-- end generated install -->
@@ -127,6 +127,7 @@ The table groups the gates by vector, the part of a repository each one judges.
127
127
  | docs | [`checks-docs`](docs/gates/checks-docs.md) | the range, and every agent file at the head commit | every repository |
128
128
  | delivery | [`checks-commit-identity`](docs/gates/checks-commit-identity.md) | the range | every repository |
129
129
  | delivery | [`checks-ci-wiring`](docs/gates/checks-ci-wiring.md) | the working tree | every repository |
130
+ | delivery | [`checks-secrets`](docs/gates/checks-secrets.md) | the range | every repository |
130
131
  | dependencies | [`checks-advisories`](docs/gates/checks-advisories.md) | the range | a repository tracking `bun.lock` |
131
132
 
132
133
  <!-- end generated gates -->
@@ -141,6 +142,8 @@ These bins run on their own:
141
142
  - [`checks-changelog`](docs/gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
142
143
  - [`checks-release-notes`](docs/gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
143
144
  - [`checks-release-report`](docs/gates/checks-release-report.md) tells whether the history holds unreleased features or fixes since the last tag.
145
+ - [`checks-release-pr`](docs/gates/checks-release-pr.md) opens or refreshes the pull request that releases the next version, and dispatches its checks.
146
+ - [`checks-release-tag`](docs/gates/checks-release-tag.md) tags a landed release commit with its version and dispatches the release workflow on the tag.
144
147
  - [`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/`.
145
148
 
146
149
  `checks-lint` has [its own page](docs/gates/checks-lint.md), which says which range it resolves.
@@ -400,11 +400,11 @@ var rule = {
400
400
  create(context) {
401
401
  const max = maxOf(context.options);
402
402
  const check = (node) => {
403
- const score2 = node.type === "StaticBlock" ? cognitiveComplexity(node, NO_NAMES) : cognitiveComplexity(node, selfNames(node));
404
- if (score2 <= max)
403
+ const score = node.type === "StaticBlock" ? cognitiveComplexity(node, NO_NAMES) : cognitiveComplexity(node, selfNames(node));
404
+ if (score <= max)
405
405
  return;
406
406
  const name = node.type === "StaticBlock" ? "static block" : `function \`${displayName(node)}\``;
407
- context.report({ node, message: `${name} has a cognitive complexity of ${score2}. Maximum allowed is ${max}.` });
407
+ context.report({ node, message: `${name} has a cognitive complexity of ${score}. Maximum allowed is ${max}.` });
408
408
  };
409
409
  return {
410
410
  FunctionDeclaration: check,
@@ -430,6 +430,9 @@ function isAstroProps(value) {
430
430
  const { object, property } = value;
431
431
  return object.type === "Identifier" && object.name === "Astro" && property.type === "Identifier" && property.name === "props";
432
432
  }
433
+ function isSignedNumber(value) {
434
+ return value.type === "UnaryExpression" && (value.operator === "-" || value.operator === "+") && value.argument.type === "Literal" && typeof value.argument.value === "number";
435
+ }
433
436
  function isPlainValue(value, bound) {
434
437
  return value.type === "Literal" || value.type === "Identifier" && bound.has(value.name);
435
438
  }
@@ -442,17 +445,23 @@ function readsProps(expression, bound) {
442
445
  return isAstroProps(value) || readsProps(value.object, bound);
443
446
  }
444
447
  function isPlainPattern(pattern, bound) {
448
+ return isPlainInOrder(pattern, bound, new Set(bound));
449
+ }
450
+ function isPlainInOrder(pattern, bound, seen) {
445
451
  if (pattern === null)
446
452
  return true;
447
453
  if (pattern.type === "RestElement")
448
- return isPlainPattern(pattern.argument, bound);
449
- if (pattern.type === "AssignmentPattern")
450
- return isPlainValue(pattern.right, bound) && isPlainPattern(pattern.left, bound);
454
+ return isPlainInOrder(pattern.argument, bound, seen);
455
+ if (pattern.type === "AssignmentPattern") {
456
+ return (isPlainValue(pattern.right, seen) || isSignedNumber(pattern.right)) && isPlainInOrder(pattern.left, bound, seen);
457
+ }
451
458
  if (pattern.type === "ArrayPattern")
452
- return pattern.elements.every((element) => isPlainPattern(element, bound));
453
- if (pattern.type === "Identifier")
459
+ return pattern.elements.every((element) => isPlainInOrder(element, bound, seen));
460
+ if (pattern.type === "Identifier") {
461
+ seen.add(pattern.name);
454
462
  return true;
455
- return pattern.properties.every((property) => property.type === "RestElement" ? isPlainPattern(property.argument, bound) : (!property.computed || isPlainValue(property.key, bound)) && isPlainPattern(property.value, bound));
463
+ }
464
+ return pattern.properties.every((property) => property.type === "RestElement" ? isPlainInOrder(property.argument, bound, seen) : (!property.computed || isPlainValue(property.key, bound)) && isPlainInOrder(property.value, bound, seen));
456
465
  }
457
466
  function isPropsRead(declarator, bound) {
458
467
  return declarator.init !== null && readsProps(declarator.init, bound) && isPlainPattern(declarator.id, bound);
@@ -23,6 +23,11 @@ It lints the pull request title and nothing else.
23
23
  The title is the enforced subject because a squash merge uses it as the main commit subject, and per-commit messages are not linted.
24
24
  GitHub appends ` (#N)` to the squashed subject, so the workflow lints the title with that suffix attached, and the header length limit applies to the landed subject, not the bare title.
25
25
  The workflow moves git's comment character off `#`, so a title starting with `#` is linted like any other.
26
+ The workflow also triggers on `workflow_dispatch`, since GitHub holds the `pull_request` runs of a release pull request the workflow token opens until a maintainer approves them, as [checks-release-pr](../gates/checks-release-pr.md) says.
27
+ A dispatched run carries no pull request, so the workflow reads the title of the one open pull request its branch heads, and fails when there is none.
28
+ That lookup needs the workflow's `pull-requests: read` permission.
29
+ It reports a dispatched run's result on the head commit as a `commitlint` status, since GitHub keeps a dispatched run's check off the pull request.
30
+ The kit's own `.github/workflows/commitlint.yml` shows the step.
26
31
 
27
32
  It never sees a commit's author or committer fields, nor the `Co-authored-by` trailer GitHub writes from a foreign author when it squashes, so it cannot enforce who a commit belongs to.
28
33
  [checks-commit-identity](../gates/checks-commit-identity.md) is that enforcement.
@@ -48,7 +48,7 @@ The base loads the kit's `data-shape` plugin from `dist/` with one rule for ever
48
48
  An override in `oxlintrc.json` turns on one rule of the kit's `readability` plugin in each `.astro` file:
49
49
 
50
50
  - `readability/thin-astro` refuses a statement in the frontmatter or a script block that is neither an import, a re-export from another module, a type or interface declaration, nor a variable read from `Astro.props`.
51
- - A default inside an `Astro.props` destructuring passes only when it is a literal or a name read from `Astro.props` earlier, so `const { title = "Home" } = Astro.props;` passes and a call or `await` in a default is refused.
51
+ - A default inside an `Astro.props` destructuring passes only when it is a literal, a `-` or `+` on a numeric literal, or a name read from `Astro.props` earlier, in the same pattern or a statement before it, so `const { title = "Home", heading = title } = Astro.props;` passes and a call or `await` in a default is refused.
52
52
  - Move a refused statement into a `.ts` file and import it, so the `.astro` file holds only imports, props and markup.
53
53
  - A script block loads client code with a side-effect import, as in `<script>import "../client.ts";</script>`, and the override turns off `import/no-unassigned-import` so that import passes.
54
54
  - A dynamic route re-exports `getStaticPaths` from a `.ts` file, as in `export { getStaticPaths } from "../lib/paths.ts";`.
package/docs/design.md CHANGED
@@ -67,7 +67,7 @@ So the fragment lists the rules in `files`, and in `include` beside every file u
67
67
 
68
68
  The source sits under `src/<vector>/`, one directory for each thing the kit judges a repository on: complexity, quality, testing, docs, delivery and dependencies.
69
69
  `src/core/` holds what every vector runs on.
70
- `scripts/` holds only the kit's own build, and nothing in it ships.
70
+ `scripts/` holds only the kit's own build and CI tooling, and nothing in it ships.
71
71
  Sorting files by what loads them would put both oxlint plugins at the root and every bin in one flat directory.
72
72
  Nothing would then say which gate a helper serves.
73
73
  A mutation runner's default scope covers `src/`, so the kit's own Stryker run mutates its source with no `mutate` list.
@@ -100,7 +100,7 @@ The `.ts` bins keep a `bun` shebang and need no build step, unlike the oxlint pl
100
100
  The bins are written in Effect.
101
101
  So `effect` is a peer dependency, and `@effect/platform-bun`, which only the bins use, is a dependency.
102
102
  `@effect/platform-node-shared` is a direct dependency only to pin its version.
103
- `@effect/platform-bun` asks for it with a `^` range, and a newer release candidate of it peers on a newer `effect` than consumers install.
103
+ `@effect/platform-bun` asks for it with a `^` range, so a newer minor of it could resolve and peer on a newer `effect` than the exact version consumers install.
104
104
  So the three packages move together at one exact version.
105
105
 
106
106
  ## checks-lint runs each gate as its own bin
@@ -162,8 +162,24 @@ So a checkout without tags, a fork, and a branch that merged `main` in all write
162
162
  A section keeps the date it was written, because the squash merge that lands the release commit may fall on another day.
163
163
  Entries come from commit subjects, which are the squash-merged pull request titles that commitlint holds to the conventional format.
164
164
  A commit body holds the branch's own messages, and nothing lints it, so no entry comes from a body.
165
- The changelog arrives in the release pull request, and no workflow pushes to the repository.
166
- The only write access the release path holds is the `github-release` job's `contents: write`, which creates or updates the GitHub release from the tag's `CHANGELOG.md` section.
165
+ The changelog arrives in the release pull request, and no workflow pushes to a branch a person works on.
166
+
167
+ ## A release is cut every day
168
+
169
+ A release waits for no quiet moment, since a busy repository always has work under way.
170
+ The daily release opens the release pull request from `main` whenever it holds a feature or a fix since the last tag, and the pull request merges through the repository's usual merge path once its checks pass.
171
+ `checks-release-pr` writes only to the `release/<branch>` branch it owns, and rebuilds that branch on `main` rather than merging `main` into it, so the changelog it carries is the one the build writes.
172
+ `checks-release-tag` writes only the `v*` tag of a release commit that already landed.
173
+ The `github-release` job's `contents: write` creates or updates the GitHub release from the tag's `CHANGELOG.md` section.
174
+
175
+ Both jobs act with the workflow token.
176
+ GitHub starts no run for a push that token makes, and holds the runs of a pull request it opens until a maintainer approves them.
177
+ A GitHub App or a personal token would start them, but either is a credential each repository stores and someone rotates.
178
+ So `checks-release-pr` dispatches the required checks on the release head, and `checks-release-tag` dispatches the release workflow on the tag, since a dispatch is the one run the token can start.
179
+ GitHub keeps a dispatched run's checks off the pull request, and branch protection does not count them.
180
+ So each dispatched job reports its result as a commit status named for the job, which a required check of that name counts.
181
+ Where a status and a check share a name, branch protection requires both, so the status never passes a pull request whose own check failed.
182
+ The cost is a `workflow_dispatch` trigger and a status step on each workflow a release needs, a title lint that reads its title from the open pull request, and the repository setting that lets the token open a pull request.
167
183
 
168
184
  ## The docs gate judges what a change touches
169
185
 
@@ -195,6 +211,10 @@ A score cannot fail a change without failing correct prose, and a suggestion tha
195
211
  `src/docs/prose-matchers.ts` imports nothing, so the gate and a write-time hook run one matcher and refuse in the same words.
196
212
  A hook bundle ships without `node_modules`, so a matcher that needed Vale or another package could not refuse at write time.
197
213
 
214
+ The entry rule and the rule against a `## Maintaining this file` section take their list items and headings from `commonmark`, the CommonMark reference parser.
215
+ A reader sees the blocks a renderer builds, and a line rule that guesses at blockquotes, HTML blocks and indented code misjudges each corner its guess misses.
216
+ Only the gate runs these rules, so the parser costs no hook anything, and the line scan reads each entry's code spans and links from that entry's line alone.
217
+
198
218
  A path, link or command on a line the range leaves alone still fails when the range broke it, for example by deleting the file it names.
199
219
  A reference goes stale far more often because the code it names moves than because its own line is edited.
200
220
  So a gate on edited lines alone would miss the usual break.
@@ -238,6 +258,24 @@ The kit caps each entry at 30 days and measures a range from the head's dates, a
238
258
  `--all` measures from the current time instead, because a head's dates never move in a repository that takes no commit, and an entry measured from them would never expire.
239
259
  The file is JSON because `Bun.TOML` cannot parse a TOML date.
240
260
 
261
+ ## Secrets fail in every commit of the range
262
+
263
+ `checks-secrets` scans each commit in the range rather than the files at the head.
264
+ A secret a commit adds stays in that commit after a later commit deletes it, and a pushed branch has already sent it to the forge.
265
+ So the fix is to rewrite the commit that added it, and to rotate the secret once it has left the machine.
266
+
267
+ In a merge commit the gate scans only the merge's own resolution, the difference from the merge git would make on its own.
268
+ A diff against the first parent would also scan what the merge brings in from the other parent, so a branch that merges main would fail on a secret main already holds, from before the range.
269
+ Git gives no such diff for an octopus merge and warns instead of failing, so the gate refuses to scan one rather than pass a commit it never read.
270
+
271
+ The gate runs gitleaks rather than a hand-written pattern list, because its default config already holds over 200 rules for the token shapes of common services.
272
+ The kit's own rules in `src/delivery/gitleaks.toml` add only the VPN keys and proxy links those rules miss.
273
+ The kit pins gitleaks by version and SHA-256 for the reason it pins OSV-Scanner.
274
+
275
+ No finding can be accepted.
276
+ A secret has no false positive worth keeping in git, because a placeholder carries the same shape without the value.
277
+ So the gate ignores a repository's `.gitleaks.toml`, `.gitleaksignore` and `gitleaks:allow` comments, which would each let one repository pass what another fails.
278
+
241
279
  ## Related topics
242
280
 
243
281
  - [checks](../README.md)
@@ -11,6 +11,9 @@ audience: consumers
11
11
  The author and committer of each commit must be allowed.
12
12
  A commit whose trailer block carries a `Co-authored-by` trailer, as git parses it, is refused.
13
13
  `GitHub <noreply@github.com>` is allowed as committer only.
14
+ `github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>` is allowed as author only of a commit whose subject is `chore: release <version>`, with or without the pull request number a squash merge adds.
15
+ GitHub attributes the release commit [checks-release-pr](checks-release-pr.md) makes to that bot, since it commits through the workflow token.
16
+ Such a commit may carry a `Co-authored-by` trailer naming that bot, since GitHub adds one to some squash merges of a pull request the bot opened, and every other trailer is refused.
14
17
 
15
18
  ## What it reads
16
19
 
@@ -110,6 +110,8 @@ A line holds one sentence, so a changed line is a changed sentence.
110
110
  A bold label that opens a line, as in `**Status.**`, heads the sentence after it and is not a sentence of its own.
111
111
  No rule reads fenced code, inline code, link destinations, URLs, HTML comments or front matter.
112
112
  Readability scores and word choice, such as easy, are not checked.
113
+ A sentence over the trial length cap is listed as advisory and never fails the run.
114
+ The cap is 20 words in an ordered list item and 25 words elsewhere.
113
115
 
114
116
  `src/docs/prose-matchers.ts` holds the rules and a synchronous `proseRefused()`, and imports nothing.
115
117
  The package exports it as `@avi2dg/checks/scripts/prose-matchers.ts`.
@@ -121,7 +123,8 @@ Each reference a living doc or an agent file names has to resolve at the head co
121
123
 
122
124
  - A path in inline code that ends in a file extension, such as `src/core/lint.ts`, names a file from the root or from the doc's directory.
123
125
  - A relative Markdown link names a file or a directory.
124
- Its anchor names a heading in that file, as GitHub derives the anchor, or an explicit `id`.
126
+ Its anchor names a heading in that Markdown or MDX file, as GitHub derives the anchor, or an explicit `id`.
127
+ An anchor into a directory or any other file fails, because it names no heading the gate can check.
125
128
  - A `bun run` command in code names a script in the nearest `package.json`, a bin in `node_modules/.bin`, or a file that exists.
126
129
  - A code span fails when some tracked file outside the docs held that exact text at the base, and none holds it at the head.
127
130
  A span the path check already fails on is not reported again.
@@ -150,7 +153,8 @@ audience: consumers
150
153
  An agent file holds the router its template sketches, and the rules below hold its shape whatever the range touches.
151
154
  A file over 3,000 characters fails.
152
155
  Move each part's notes into the people doc that covers that part, and delete what a check or the code already holds.
153
- A file that holds a `## Maintaining this file` section fails, because this gate holds the shape the section asked for.
156
+ A file that holds a `## Maintaining this file` section a reader sees fails, because this gate holds the shape the section asked for.
157
+ A heading inside an HTML comment, an HTML block or a code block is not such a section.
154
158
  Each entry names at least one of these, or it fails:
155
159
 
156
160
  - A path in inline code that git tracks at the head commit, a file or a directory, such as `package.json`, `LICENSE` or `.gitignore`.
@@ -159,7 +163,7 @@ Each entry names at least one of these, or it fails:
159
163
  - A `bun run` command.
160
164
 
161
165
  Whether the link or the command resolves is the reference rule's call, as [Paths, links and commands](#paths-links-and-commands) says, and it fails when the range adds or breaks one.
162
- An entry is any list item a reader sees, the items above the first section included.
166
+ An entry is any list item a reader sees as CommonMark renders the file, the items above the first section included.
163
167
  A list item inside an HTML comment, an HTML block or an indented code block is not an entry.
164
168
  A fresh file passes the ceiling, and its entries pass once each names the file that holds its detail.
165
169
 
@@ -205,6 +209,8 @@ docs: 6 violation(s):
205
209
  docs/parts.md:9: names `gates.lint`, which the range removed from every file outside the docs. Say what holds now, or drop the line
206
210
  docs: advisory, 1 doc file(s) the range leaves alone do not hold to their templates yet:
207
211
  docs/adr/0001-quality-gates.md: 5 violation(s)
212
+ docs: advisory, 1 sentence(s) over the trial length caps:
213
+ README.md:16: carries a 27-word descriptive sentence, over the 25-word cap (procedural means an ordered list item, capped at 20 words)
208
214
  docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files name were broken before the range:
209
215
  docs/parts.md:9: links to `suppliers.md#prices`, and `docs/suppliers.md` has no heading with that anchor
210
216
  ```
@@ -46,7 +46,7 @@ With two arguments, the base and head override range discovery.
46
46
 
47
47
  ```
48
48
  checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
49
- checks-lint: 1 of 12 gate(s) failed: checks-comment-gate
49
+ checks-lint: 1 of 13 gate(s) failed: checks-comment-gate
50
50
  ```
51
51
 
52
52
  <!-- end generated lint-sample -->
@@ -59,8 +59,9 @@ An `--incremental` run with no report stops before instrumenting with the missin
59
59
  ## When it runs
60
60
 
61
61
  A repository runs full baselines from the mutation workflow on `workflow_dispatch`.
62
+ A repository that picks a pull request scope from a baseline also runs that workflow on a nightly schedule.
62
63
  Run scoped checks locally during development.
63
- A scheduled run never starts one, because a baseline costs a full Stryker run.
64
+ A scheduled run costs a full Stryker run.
64
65
 
65
66
  ## Running it in CI
66
67
 
@@ -55,6 +55,7 @@ name: release
55
55
  on:
56
56
  push:
57
57
  tags: ["v*"]
58
+ workflow_dispatch:
58
59
  permissions:
59
60
  contents: read
60
61
  id-token: write
@@ -65,6 +66,8 @@ jobs:
65
66
  - uses: actions/checkout@v5
66
67
  with:
67
68
  fetch-depth: 0
69
+ - name: ref is a tag
70
+ run: test "$GITHUB_REF_TYPE" = tag
68
71
  - uses: oven-sh/setup-bun@v2
69
72
  - run: bun install --frozen-lockfile
70
73
  - run: bun run build
@@ -102,6 +105,7 @@ name: release
102
105
  on:
103
106
  push:
104
107
  tags: ["v*"]
108
+ workflow_dispatch:
105
109
  permissions:
106
110
  contents: read
107
111
  jobs:
@@ -113,6 +117,8 @@ jobs:
113
117
  - uses: actions/checkout@v5
114
118
  with:
115
119
  fetch-depth: 0
120
+ - name: ref is a tag
121
+ run: test "$GITHUB_REF_TYPE" = tag
116
122
  - uses: oven-sh/setup-bun@v2
117
123
  - run: bun install --frozen-lockfile
118
124
  - run: bun run build
@@ -127,7 +133,10 @@ jobs:
127
133
  run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
128
134
  ```
129
135
 
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.
136
+ A release is a pull request that holds only the version bump and the built changelog, which [checks-release-pr](checks-release-pr.md) opens once a day.
137
+ When it lands, [checks-release-tag](checks-release-tag.md) tags the merge commit and dispatches this workflow on the tag.
138
+ A tag the workflow token pushes starts no `push` run, so the workflow triggers on `workflow_dispatch` as well, and refuses a dispatch on a ref that is not a tag.
139
+ A tag a person pushes starts the same workflow through its `push` trigger.
131
140
  The workflow refuses a tag that disagrees with `package.json`, so the tag always names the section the notes come from.
132
141
 
133
142
  ## When it runs
@@ -0,0 +1,185 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-release-pr
6
+
7
+ `checks-release-pr` opens or refreshes the one pull request that releases the next version, and dispatches its checks on its head.
8
+
9
+ ## What it checks
10
+
11
+ It lists the unreleased changes the way [checks-release-report](checks-release-report.md) does, and does nothing when there are none.
12
+ It bumps the version `package.json` holds by the kit's rule:
13
+
14
+ | Unreleased changes | Below 1.0.0 | From 1.0.0 |
15
+ | --- | --- | --- |
16
+ | a breaking change | minor | major |
17
+ | a feature and no breaking change | minor | minor |
18
+ | only fixes, performance changes or reverts | patch | patch |
19
+
20
+ It refuses when the last release tag names a version other than the one `package.json` holds, since an untagged bump means a release landed that nothing published.
21
+ The refusal points at the `tag` job of `daily-release` on the release commit, whose own refusal says what to fix, rather than at a tag pushed by hand.
22
+ It refuses a version that is not a plain `major.minor.patch`.
23
+ It writes the next version into `package.json`, runs `bun run build` so the build writes `CHANGELOG.md`, and commits every tracked file the build changed as `chore: release <version>`.
24
+ The commit's one parent is `HEAD`.
25
+ It makes the commit through the GitHub API, which attributes it to `github-actions[bot]` and signs it, so the job sets no git identity.
26
+ It checks that the tree GitHub built matches the tree the build wrote.
27
+ It points the branch `release/<branch>` at the commit, where `<branch>` is the branch `HEAD` is on.
28
+ It opens a pull request from that branch into `<branch>` titled `chore: release <version>`, with the body `Release <version>.`, or retitles the open one to the new version.
29
+ It then dispatches each workflow its arguments name on the release branch.
30
+ GitHub holds the `pull_request` runs of a pull request the workflow token opens until a maintainer approves them, so the dispatch is what runs the required checks on the release head.
31
+ GitHub keeps a dispatched run's checks off the pull request, so each dispatched job reports its result as a commit status named for the job, which the required check of that name counts.
32
+ When the release branch already holds this version on top of `HEAD` and its pull request carries the right title, it pushes nothing and dispatches nothing.
33
+ It leaves the working tree as it found it.
34
+
35
+ ## What it reads
36
+
37
+ It reads the `v*` tags, the commit subjects since the last one and `package.json` from the checkout.
38
+ It refuses a shallow checkout, a detached `HEAD` and a working tree with changes to tracked files.
39
+ It reads the release branch from `origin` with `git ls-remote` and `git fetch`.
40
+ It calls the GitHub API through `gh api`, which takes the repository from the checkout's remote and the token from `GH_TOKEN`.
41
+ The token needs `contents: write`, `pull-requests: write` and `actions: write`.
42
+ The repository needs **Allow GitHub Actions to create and approve pull requests** turned on under its Actions settings, or GitHub refuses the pull request.
43
+
44
+ ## Arguments
45
+
46
+ ```sh
47
+ checks-release-pr <workflow>...
48
+ ```
49
+
50
+ Each argument names a workflow file under `.github/workflows/` whose jobs report the checks the default branch requires, such as `ci.yml` and `commitlint.yml`.
51
+ Each named workflow triggers on `workflow_dispatch`.
52
+ Name no workflow that runs something else on dispatch, such as a mutation baseline.
53
+
54
+ ## Exit codes
55
+
56
+ | Code | When |
57
+ | --- | --- |
58
+ | 0 | nothing is unreleased, or the release pull request is open on the current `HEAD` and its checks are dispatched |
59
+ | 2 | the arguments do not parse, a refusal above applies, the build fails, or a GitHub API call fails |
60
+
61
+ ## Sample output
62
+
63
+ A run that opens the pull request prints the build's own output, then one line:
64
+
65
+ ```
66
+ release-pr: opened https://github.com/acme/widget/pull/12 to release 0.4.0, and dispatched ci.yml, commitlint.yml on release/main
67
+ ```
68
+
69
+ A run on the same `HEAD` the next day prints one line:
70
+
71
+ ```
72
+ release-pr: https://github.com/acme/widget/pull/12 releases 0.4.0 from 3f2a9c81d0b4 and is current
73
+ ```
74
+
75
+ ## When it runs
76
+
77
+ The daily release workflow below runs it once a day on the default branch.
78
+ Run the workflow by hand with `gh workflow run daily-release` to refresh the release pull request sooner.
79
+
80
+ ## Running it in CI
81
+
82
+ A repository takes the daily release as `.github/workflows/daily-release.yml`.
83
+ The `pull-request` job runs on the schedule, and the `tag` job runs when a release commit lands on `main`, as [checks-release-tag](checks-release-tag.md) says:
84
+
85
+ ```yaml
86
+ name: daily-release
87
+ on:
88
+ schedule:
89
+ - cron: "29 3 * * *"
90
+ workflow_dispatch:
91
+ push:
92
+ branches: [main]
93
+ permissions:
94
+ contents: read
95
+ jobs:
96
+ pull-request:
97
+ if: github.event_name != 'push'
98
+ runs-on: ubuntu-latest
99
+ timeout-minutes: 10
100
+ concurrency:
101
+ group: release-pull-request
102
+ cancel-in-progress: false
103
+ permissions:
104
+ contents: write
105
+ pull-requests: write
106
+ actions: write
107
+ steps:
108
+ - uses: actions/checkout@v5
109
+ with:
110
+ fetch-depth: 0
111
+ - uses: oven-sh/setup-bun@v2
112
+ with:
113
+ bun-version-file: .bun-version
114
+ - run: bun install --frozen-lockfile
115
+ - name: open or refresh the release pull request
116
+ env:
117
+ GH_TOKEN: ${{ github.token }}
118
+ run: ./node_modules/.bin/checks-release-pr ci.yml commitlint.yml
119
+ tag:
120
+ if: "github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore: release ')"
121
+ runs-on: ubuntu-latest
122
+ timeout-minutes: 10
123
+ permissions:
124
+ contents: write
125
+ actions: write
126
+ steps:
127
+ - uses: actions/checkout@v5
128
+ with:
129
+ fetch-depth: 0
130
+ - uses: oven-sh/setup-bun@v2
131
+ with:
132
+ bun-version-file: .bun-version
133
+ - run: bun install --frozen-lockfile
134
+ - name: tag the release
135
+ env:
136
+ GH_TOKEN: ${{ github.token }}
137
+ run: ./node_modules/.bin/checks-release-tag release.yml
138
+ ```
139
+
140
+ A public repository keeps `runs-on: ubuntu-latest`.
141
+ A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || fromJSON('["self-hosted","Linux","X64","winbox"]') }}` on both jobs, as its other workflows do.
142
+ A repository whose build needs more than Bun adds the steps its `.github/workflows/ci.yml` runs before `bun run build` to both jobs, and nothing after it.
143
+ Neither job runs a test suite or a mutation run.
144
+ On a hosted runner the `pull-request` job takes about 25 seconds, which GitHub bills as one minute, whether it opens, refreshes or leaves the pull request alone.
145
+ A private repository runs it on its self-hosted runner, which bills no minutes.
146
+ The `tag` job runs only when a release lands, and a job its `if` skips bills nothing.
147
+ The checks it dispatches are the release pull request's own required checks, and they run again only when `main` moves under it.
148
+ The pull request also lists its own `pull_request` runs as waiting for approval.
149
+ Nothing requires them, and approving them runs the same checks again.
150
+
151
+ The daily release needs the repository's other workflows to accept the dispatch:
152
+
153
+ - `.github/workflows/ci.yml` and `.github/workflows/commitlint.yml` trigger on `workflow_dispatch`, grant `statuses: write`, and end each required job with the step below.
154
+ `commitlint.yml` also grants `pull-requests: read`, which the title lookup needs.
155
+ - The title lint reads the title of the one open pull request its branch heads when the event carries none, as [Commit messages](../configs/commit-messages.md) says.
156
+ - `.github/workflows/release.yml` triggers on `workflow_dispatch` and refuses a ref that is not a tag, as [checks-release-notes](checks-release-notes.md) shows.
157
+ - **Allow GitHub Actions to create and approve pull requests** is on, which the call after the step sets.
158
+ - A release pull request holds current `main` when it merges.
159
+ One that merges behind `main` lands a changelog short of the commits `main` gained, and `checks-release-tag` refuses to tag it.
160
+
161
+ The step reports a dispatched run's result as a commit status on the head commit:
162
+
163
+ ```yaml
164
+ - name: report the result on the head commit
165
+ if: always() && github.event_name == 'workflow_dispatch'
166
+ env:
167
+ GH_TOKEN: ${{ github.token }}
168
+ STATE: ${{ job.status == 'success' && 'success' || 'failure' }}
169
+ run: gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" -f state="$STATE" -f context="$GITHUB_JOB" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --silent
170
+ ```
171
+
172
+ The status takes the job's id as its name, so a required job's id is the check name the branch requires.
173
+ Where a status and a check share a name, branch protection requires both, so the status never passes a pull request whose own check failed.
174
+ The call turns the repository setting on:
175
+
176
+ ```sh
177
+ gh api --method PUT repos/<owner>/<repo>/actions/permissions/workflow -F can_approve_pull_request_reviews=true
178
+ ```
179
+
180
+ ## Related topics
181
+
182
+ - [checks-release-report](checks-release-report.md)
183
+ - [checks-release-tag](checks-release-tag.md)
184
+ - [checks-changelog](checks-changelog.md)
185
+ - [checks-release-notes](checks-release-notes.md)
@@ -27,7 +27,7 @@ checks-release-report
27
27
  ```
28
28
 
29
29
  It takes no arguments.
30
- Run it before cutting a tag to decide whether a release is due.
30
+ Run it to see whether a release is due and what it holds.
31
31
 
32
32
  ## Exit codes
33
33
 
@@ -55,10 +55,12 @@ release-report: no unreleased changes since v0.1.0
55
55
 
56
56
  ## When it runs
57
57
 
58
- Only a person or a scheduler deciding when to cut a release runs it.
58
+ A person runs it to see what the next release holds.
59
+ [checks-release-pr](checks-release-pr.md) reads the same changes before it opens the release pull request.
59
60
  A repository with no versioned releases does not need it.
60
61
 
61
62
  ## Related topics
62
63
 
63
64
  - [checks-changelog](checks-changelog.md)
64
65
  - [checks-release-notes](checks-release-notes.md)
66
+ - [checks-release-pr](checks-release-pr.md)
@@ -0,0 +1,63 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-release-tag
6
+
7
+ `checks-release-tag` tags a landed release commit with its version and dispatches the release workflow on the tag.
8
+
9
+ ## What it checks
10
+
11
+ It reads the subject of `HEAD`, and does nothing unless the subject is `chore: release <version>`, with or without the ` (#N)` a squash merge adds.
12
+ It refuses a release commit whose `package.json` holds another version.
13
+ It tags `HEAD` as `v<version>` and pushes the tag to `origin`.
14
+ A tag the workflow token pushes starts no `push` workflow, so it then dispatches the workflow its argument names on the tag.
15
+ It does nothing when `v<version>` already tags `HEAD`, so a rerun after a release never publishes it twice.
16
+ When the dispatch fails after the push, it names `gh workflow run <workflow> --ref v<version>`.
17
+ A rerun then finds the tag and does nothing, so that command is what dispatches the release workflow by hand.
18
+ It refuses when `v<version>` already tags another commit.
19
+ Before it pushes the tag, it runs `bun run build` and refuses when the build rewrites a committed file.
20
+ That happens when a release pull request merged behind `main`, so its `CHANGELOG.md` lacks the commits `main` gained.
21
+ The refusal says to open a `chore: cancel the unpublished <version>` pull request that returns `package.json` to the last tag's version and commits what `bun run build` then writes to `CHANGELOG.md`.
22
+ The build reads the returned version as a revert, so it drops the unpublished section, and the next `daily-release` run cuts the release again with every change since the last tag.
23
+
24
+ ## What it reads
25
+
26
+ It reads the subject of `HEAD` and `package.json` from the checkout, and the tag from `origin` with `git ls-remote`.
27
+ The build it runs reads the whole history, so the checkout fetches all of it.
28
+ It pushes the tag with the credentials the checkout holds.
29
+ It calls the GitHub API through `gh api`, which takes the repository from the checkout's remote and the token from `GH_TOKEN`.
30
+ The token needs `contents: write` and `actions: write`.
31
+
32
+ ## Arguments
33
+
34
+ ```sh
35
+ checks-release-tag <workflow>
36
+ ```
37
+
38
+ The argument names the release workflow file under `.github/workflows/`, such as `release.yml`.
39
+ That workflow triggers on `workflow_dispatch` and keeps its own guards, as [checks-release-notes](checks-release-notes.md) shows.
40
+
41
+ ## Exit codes
42
+
43
+ | Code | When |
44
+ | --- | --- |
45
+ | 0 | `HEAD` is no release commit, its tag already tags it, or the tag is pushed and the release workflow dispatched |
46
+ | 2 | the arguments do not parse, `package.json` disagrees with the subject, the tag tags another commit, the build fails or rewrites a committed file, or the push or a GitHub API call fails |
47
+
48
+ ## Sample output
49
+
50
+ A run that tags the release prints the build's own output, then one line:
51
+
52
+ ```
53
+ release-tag: tagged 3f2a9c81d0b4 as v0.4.0, and dispatched release.yml on it
54
+ ```
55
+
56
+ ## When it runs
57
+
58
+ The `tag` job of the daily release workflow runs it on each push to `main` whose head commit reads as a release, as [checks-release-pr](checks-release-pr.md#running-it-in-ci) shows.
59
+
60
+ ## Related topics
61
+
62
+ - [checks-release-pr](checks-release-pr.md)
63
+ - [checks-release-notes](checks-release-notes.md)