@avi2dg/checks 0.24.1 → 0.24.2

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,15 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.24.2
6
+
7
+ Released 2026-09-27.
8
+
9
+ ### Fixes
10
+
11
+ - **scripts:** list commits merged in after a bump under that release [#82](https://github.com/avi2d/checks/pull/82)
12
+ - **scripts:** leave versions with no listable commits out of the changelog [#81](https://github.com/avi2d/checks/pull/81)
13
+
5
14
  ## 0.24.1
6
15
 
7
16
  Released 2026-09-27.
package/CONTRIBUTING.md CHANGED
@@ -41,7 +41,8 @@ To release a version:
41
41
  The build writes the version's section into `CHANGELOG.md` from the conventional commits since the last release, so the changelog is never edited by hand.
42
42
  1. Commit both as `chore: release <version>` and title the pull request the same.
43
43
  The squash merge lands the title as the commit's subject, and a `feat` or `fix` title would add an entry the committed changelog lacks.
44
- 1. Rebase the pull request onto `main` right before it merges, since a commit merged in between belongs to the release and the committed section would lack it.
44
+ 1. Merge `main` into the release branch and run `bun run build` again right before it merges, committing what it rewrote, since the build lists a commit merged in after the bump under the release.
45
+ Never rebase the release branch.
45
46
  1. Once it merges, tag that commit on `main` with the version and push the tag, since the version bump commit closes the release:
46
47
 
47
48
  ```sh
@@ -10,11 +10,15 @@ audience: consumers
10
10
 
11
11
  It reads the version from `package.json` and treats that version as the release being prepared.
12
12
  It lists every conventional commit the release closes, grouped as Features, Fixes, Performance, Reverts and Breaking changes.
13
+ It lists the commits a branch merged in after its bump under that bump, since the squash merge releases them there.
14
+ It leaves out what the branch added past its bump, since the squash merge folds it into the release commit.
15
+ Only a branch carrying its own unlanded bump lists the commits of an unlanded branch it merges, so such a branch takes in only main.
16
+ CI must build the pull request head commit, the `actions/checkout` ref `github.event.pull_request.head.sha`, and not the GitHub merge ref, since the merge ref puts main first and the branch's own bump off the first-parent chain.
13
17
  It links each entry to its pull request under the repository address `package.json` names.
14
18
  It keeps the date a released section already carries and dates a new section today.
15
19
  It writes the whole file newest first, so the changelog is never edited by hand.
16
20
  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.
21
+ A version with no conventional commit worth listing writes no section.
18
22
 
19
23
  ## What it reads
20
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, DateTime, Effect, FileSystem, Path, Schema } from "effect";
3
- import { cuts, releaseDates, renderChangelog, type Bump, type Cut, type Release } from "./changelog.ts";
3
+ import { cuts, hasEntries, namesPullRequest, releaseDates, renderChangelog, type Bump, type Cut, type Release } from "./changelog.ts";
4
4
  import { git } from "./git.ts";
5
5
  import { runMain } from "./main.ts";
6
6
 
@@ -69,8 +69,28 @@ const readPublished = Effect.fn("readPublished")(function* (root: string) {
69
69
  return new Set(tags.split("\n").flatMap((tag) => (tag.startsWith(TAG_PREFIX) ? [tag.slice(TAG_PREFIX.length)] : [])));
70
70
  });
71
71
 
72
- const subjectsOf = Effect.fn("subjectsOf")(function* (root: string, { version, date, through, after }: Cut) {
73
- const log = yield* git(["log", "--topo-order", "--format=%s", through, "--not", ...after], root);
72
+ const mergedTips = Effect.fn("mergedTips")(function* (root: string, through: string) {
73
+ // A bump that names its pull request or sits off the first-parent chain is main's release, so no squash merge of this branch releases more under it.
74
+ if (namesPullRequest((yield* git(["log", "-1", "--format=%s", through], root)).trim())) return [];
75
+ if (!(yield* git(["rev-list", "--first-parent", "HEAD"], root)).split("\n").includes(through)) return [];
76
+ const merges = yield* git(["log", "--merges", "--format=%P", `${through}..HEAD`], root);
77
+ const tips: string[] = [];
78
+ for (const [, ...parents] of merges.split("\n").filter((line) => line !== "").map((line) => line.split(" "))) {
79
+ for (const parent of parents) {
80
+ // A parent past the bump belongs to a later release, so only a parent beside it joins this one.
81
+ const past = yield* git(["merge-base", "--is-ancestor", through, parent], root).pipe(
82
+ Effect.as(true),
83
+ Effect.catchTag("GitFailure", () => Effect.succeed(false)),
84
+ );
85
+ if (!past) tips.push(parent);
86
+ }
87
+ }
88
+ return tips;
89
+ });
90
+
91
+ // The bump reaches no commit main gained after it, yet the squash merge releases them under it.
92
+ const subjectsOf = Effect.fn("subjectsOf")(function* (root: string, { version, date, through, after }: Cut, extra: readonly string[] = []) {
93
+ const log = yield* git(["log", "--topo-order", "--format=%s", through, ...extra, "--not", ...after], root);
74
94
  return { version, date, subjects: log.split("\n").filter((subject) => subject !== "") } satisfies Release;
75
95
  });
76
96
 
@@ -94,7 +114,11 @@ const write = Effect.gen(function* () {
94
114
  const recorded = (yield* fs.exists(target)) ? releaseDates(yield* fs.readFileString(target)) : new Map<string, string>();
95
115
  const pending = version === (yield* versionAt(root, "HEAD")) ? undefined : { sha: "HEAD", version, date: yield* today };
96
116
  const released = cuts(yield* readBumps(root), recorded, yield* readPublished(root), pending);
97
- const found = (yield* Effect.forEach(released, (cut) => subjectsOf(root, cut))).toReversed();
117
+ const tip = released.at(-1);
118
+ const extra = tip === undefined || tip.through === "HEAD" ? [] : yield* mergedTips(root, tip.through);
119
+ const found = (yield* Effect.forEach(released, (cut, index) => subjectsOf(root, cut, index === released.length - 1 ? extra : [])))
120
+ .filter(({ subjects }) => hasEntries(subjects))
121
+ .toReversed();
98
122
  yield* fs.writeFileString(target, renderChangelog(name, found, repositoryUrl));
99
123
  yield* Console.log(`${NAME}: wrote ${found.length} release(s) to ${CHANGELOG}`);
100
124
  return true;
@@ -28,6 +28,8 @@ type Entry = {
28
28
 
29
29
  const CONVENTIONAL = /^([a-z]+)(?:\(([^()]*)\))?(!)?: (\S.*)$/;
30
30
 
31
+ const PULL_REQUEST = / \(#(\d+)\)$/;
32
+
31
33
  const GROUP_OF_TYPE = new Map<string, ChangeGroup>([
32
34
  ["feat", "Features"],
33
35
  ["fix", "Fixes"],
@@ -42,11 +44,15 @@ export function groupOf(subject: string): ChangeGroup | undefined {
42
44
  return breaking === "!" ? "Breaking changes" : GROUP_OF_TYPE.get(type);
43
45
  }
44
46
 
47
+ export function namesPullRequest(subject: string): boolean {
48
+ return PULL_REQUEST.test(subject);
49
+ }
50
+
45
51
  function entryOf(subject: string, repositoryUrl: string): Entry | undefined {
46
52
  const group = groupOf(subject);
47
53
  if (group === undefined) return undefined;
48
54
  const [, , scope = "", , description = ""] = CONVENTIONAL.exec(subject) ?? [];
49
- const linkedDescription = description.replace(/ \(#(\d+)\)$/, (_, number: string) => ` [#${number}](${repositoryUrl}/pull/${number})`);
55
+ const linkedDescription = description.replace(PULL_REQUEST, (_, number: string) => ` [#${number}](${repositoryUrl}/pull/${number})`);
50
56
  return { group, text: scope === "" ? linkedDescription : `**${scope}:** ${linkedDescription}` };
51
57
  }
52
58
 
@@ -82,6 +88,10 @@ export function cuts(
82
88
  }));
83
89
  }
84
90
 
91
+ export function hasEntries(subjects: readonly string[]): boolean {
92
+ return subjects.some((subject) => groupOf(subject) !== undefined);
93
+ }
94
+
85
95
  function renderRelease({ version, date, subjects }: Release, repositoryUrl: string): readonly string[] {
86
96
  const entries = subjects.flatMap((subject) => entryOf(subject, repositoryUrl) ?? []);
87
97
  const groups = CHANGE_GROUPS.flatMap((group) => {
@@ -91,7 +91,7 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
91
91
  title: { type: "fixed", text: "Changelog" },
92
92
  lead: ["Every release of <package>, newest first, written by the release from its conventional commits."],
93
93
  sections: [
94
- open("<version>", "version", REQUIRED, ["Released <YYYY-MM-DD>."], {
94
+ open("<version>", "version", optional("no release holds a conventional commit worth listing yet"), ["Released <YYYY-MM-DD>."], {
95
95
  subsections: CHANGE_GROUPS.map((group) =>
96
96
  fixed(group, optional("the release holds no such commit"), ["- **<the commit's scope, when it has one>:** <its description>"]),
97
97
  ),
@@ -4,6 +4,8 @@ Every release of <package>, newest first, written by the release from its conven
4
4
 
5
5
  ## <version>
6
6
 
7
+ <Leave this section out when no release holds a conventional commit worth listing yet.>
8
+
7
9
  Released <YYYY-MM-DD>.
8
10
 
9
11
  ### Breaking changes