@avi2dg/checks 0.23.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,18 @@
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
+
5
17
  ## 0.23.0
6
18
 
7
19
  Released 2026-09-26.
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.
package/docs/design.md CHANGED
@@ -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)
@@ -16,9 +16,12 @@ The clone is staged beside its cache entry and moves into place only once it is
16
16
  Every later run verifies the link rather than trusting it, and does so without contacting the remote.
17
17
  It confirms the tree sits on the recorded commit.
18
18
  It confirms the manifest inside the tree still names the installed version.
19
- It confirms no write bit came back and no write landed outside the recorded commit.
19
+ It confirms no write bit is left once it has frozen the tree and no write landed outside the recorded commit.
20
20
  It never follows a link inside the tree, so no mode outside the cache is touched.
21
- Any failed confirmation fails the run.
21
+ A tool that clears the read only mode of `repos/<name>` changes the tree's top directory through the link, as the GitHub Actions runner does when it empties `$RUNNER_TEMP`.
22
+ A run that finds an owner write bit strips it from the tree again, then confirms no write landed outside the recorded commit.
23
+ A group or other write bit is never stripped, and fails the run before it reads the tree's status.
24
+ Any other failed confirmation fails the run, and so does a write the tree still holds once it is read only again.
22
25
  A failed library drops its `repos/<name>` link, so a reader falls back to `node_modules/<package>` rather than a tree the run could not vouch for.
23
26
  A missing tag, an unknown installed version or a manifest naming another version fails it too.
24
27
  A tag moved upstream after the first fetch is not followed, since a cached tree stays on its recorded commit.
@@ -68,6 +71,12 @@ checks-vendor: repos/effect still holds effect@4.0.0-rc.115, verified against it
68
71
 
69
72
  A later run reports the link it kept.
70
73
 
74
+ ```
75
+ checks-vendor: found 1 path writable by its owner, starting with /home/runner/.cache/avi2dg-checks/repos/github.com/Effect-TS/effect/effect@4.0.0-rc.115, and froze the tree again, so repos/effect still holds effect@4.0.0-rc.115, verified against its recorded commit
76
+ ```
77
+
78
+ A run that found an owner write bit reports how many paths carried one and the first of them.
79
+
71
80
  ## Wiring
72
81
 
73
82
  A consuming repository runs it from its `prepare` script, so every install pins and verifies the trees.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,6 +25,10 @@
25
25
  "scripts/git.ts",
26
26
  "scripts/gates.ts",
27
27
  "scripts/lint.ts",
28
+ "scripts/changelog.ts",
29
+ "scripts/changelog-write.ts",
30
+ "scripts/release-notes.ts",
31
+ "scripts/release-report.ts",
28
32
  "scripts/test-layout.ts",
29
33
  "scripts/test.ts",
30
34
  "scripts/test-skips.ts",
@@ -47,6 +51,7 @@
47
51
  "scripts/quarantine-clock.ts",
48
52
  "scripts/docs.ts",
49
53
  "scripts/vendor.ts",
54
+ "scripts/vendor-args.ts",
50
55
  "scripts/doc-outline.ts",
51
56
  "scripts/doc-rules.ts",
52
57
  "scripts/doc-references.ts",
@@ -67,6 +72,10 @@
67
72
  "./dependency-cruiser.config.js": "./dependency-cruiser.config.js",
68
73
  "./scripts/lint-coverage.sh": "./scripts/lint-coverage.sh",
69
74
  "./scripts/lint.ts": "./scripts/lint.ts",
75
+ "./scripts/changelog.ts": "./scripts/changelog.ts",
76
+ "./scripts/changelog-write.ts": "./scripts/changelog-write.ts",
77
+ "./scripts/release-notes.ts": "./scripts/release-notes.ts",
78
+ "./scripts/release-report.ts": "./scripts/release-report.ts",
70
79
  "./scripts/test-layout.ts": "./scripts/test-layout.ts",
71
80
  "./scripts/test.ts": "./scripts/test.ts",
72
81
  "./scripts/test-skips.ts": "./scripts/test-skips.ts",
@@ -104,6 +113,9 @@
104
113
  },
105
114
  "bin": {
106
115
  "checks-lint": "scripts/lint.ts",
116
+ "checks-changelog": "scripts/changelog-write.ts",
117
+ "checks-release-notes": "scripts/release-notes.ts",
118
+ "checks-release-report": "scripts/release-report.ts",
107
119
  "checks-lint-coverage": "scripts/lint-coverage.sh",
108
120
  "checks-test-layout": "scripts/test-layout.ts",
109
121
  "checks-test": "scripts/test.ts",
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, DateTime, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { cuts, releaseDates, renderChangelog, type Bump, type Cut, type Release } from "./changelog.ts";
4
+ import { git } from "./git.ts";
5
+ import { runMain } from "./main.ts";
6
+
7
+ const NAME = "checks-changelog";
8
+ const CHANGELOG = "CHANGELOG.md";
9
+ const MANIFEST = "package.json";
10
+ const FIELD = "\x1f";
11
+ const TAG_PREFIX = "v";
12
+
13
+ class ChangelogUnreadable extends Schema.TaggedError<ChangelogUnreadable>()("ChangelogUnreadable", {
14
+ message: Schema.String,
15
+ }) {}
16
+
17
+ const decodeManifestJson = Schema.decodeUnknownEffect(
18
+ Schema.fromJsonString(
19
+ Schema.Struct({
20
+ name: Schema.String,
21
+ version: Schema.String,
22
+ repository: Schema.optional(Schema.Union([Schema.String, Schema.Struct({ url: Schema.optional(Schema.String) })])),
23
+ }),
24
+ ),
25
+ );
26
+
27
+ const decodeManifest = (text: string, source: string) =>
28
+ decodeManifestJson(text).pipe(Effect.mapError((cause) => new ChangelogUnreadable({ message: `${source}: ${cause.message}` })));
29
+
30
+ const today = DateTime.nowInCurrentZone.pipe(DateTime.withCurrentZoneLocal, Effect.map(DateTime.formatIsoDate));
31
+
32
+ function repositoryWebUrl(repository: string): string | undefined {
33
+ const url = repository
34
+ .replace(/^git\+/, "")
35
+ .replace(/\/$/, "")
36
+ .replace(/\.git$/, "");
37
+ return url.startsWith("https://") ? url : undefined;
38
+ }
39
+
40
+ const versionAt = Effect.fn("versionAt")(function* (root: string, sha: string) {
41
+ return (yield* decodeManifest(yield* git(["show", `${sha}:${MANIFEST}`], root), `${MANIFEST} at ${sha}`)).version;
42
+ });
43
+
44
+ const readBumps = Effect.fn("readBumps")(function* (root: string) {
45
+ const log = yield* git(
46
+ ["log", "--topo-order", "--reverse", "--full-history", "--no-merges", '-G"version"', "--format=%H%x1f%as%x1f%P", "HEAD", "--", MANIFEST],
47
+ root,
48
+ );
49
+ const bumps: Bump[] = [];
50
+ for (const line of log.split("\n")) {
51
+ const [sha, date, parent] = line.split(FIELD);
52
+ if (sha === undefined || date === undefined || parent === undefined) continue;
53
+ const version = yield* versionAt(root, sha);
54
+ if (parent === "" || (yield* versionAt(root, parent)) !== version) bumps.push({ sha, version, date });
55
+ }
56
+ return bumps;
57
+ });
58
+
59
+ const readPublished = Effect.fn("readPublished")(function* (root: string) {
60
+ const tags = yield* git(["for-each-ref", "--format=%(refname:strip=2)", `refs/tags/${TAG_PREFIX}*`], root);
61
+ return new Set(tags.split("\n").flatMap((tag) => (tag.startsWith(TAG_PREFIX) ? [tag.slice(TAG_PREFIX.length)] : [])));
62
+ });
63
+
64
+ const subjectsOf = Effect.fn("subjectsOf")(function* (root: string, { version, date, through, after }: Cut) {
65
+ const log = yield* git(["log", "--topo-order", "--format=%s", through, "--not", ...after], root);
66
+ return { version, date, subjects: log.split("\n").filter((subject) => subject !== "") } satisfies Release;
67
+ });
68
+
69
+ const write = Effect.gen(function* () {
70
+ const fs = yield* FileSystem.FileSystem;
71
+ const path = yield* Path.Path;
72
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
73
+ if ((yield* git(["rev-parse", "--is-shallow-repository"], root)).trim() === "true") {
74
+ return yield* new ChangelogUnreadable({ message: "the checkout is shallow, so the releases reach back past its history; fetch all of it" });
75
+ }
76
+ const target = path.join(root, CHANGELOG);
77
+ const { name, version, repository } = yield* decodeManifest(yield* fs.readFileString(path.join(root, MANIFEST)), MANIFEST);
78
+ const url = typeof repository === "string" ? repository : repository?.url;
79
+ if (url === undefined) {
80
+ return yield* new ChangelogUnreadable({ message: `${MANIFEST} has no repository.url, which the changelog links each pull request under` });
81
+ }
82
+ const repositoryUrl = repositoryWebUrl(url);
83
+ if (repositoryUrl === undefined) {
84
+ return yield* new ChangelogUnreadable({ message: `${MANIFEST} repository ${url} is no https address, which the changelog links each pull request under` });
85
+ }
86
+ const recorded = (yield* fs.exists(target)) ? releaseDates(yield* fs.readFileString(target)) : new Map<string, string>();
87
+ const pending = version === (yield* versionAt(root, "HEAD")) ? undefined : { sha: "HEAD", version, date: yield* today };
88
+ const released = cuts(yield* readBumps(root), recorded, yield* readPublished(root), pending);
89
+ const found = (yield* Effect.forEach(released, (cut) => subjectsOf(root, cut))).toReversed();
90
+ yield* fs.writeFileString(target, renderChangelog(name, found, repositoryUrl));
91
+ yield* Console.log(`${NAME}: wrote ${found.length} release(s) to ${CHANGELOG}`);
92
+ return true;
93
+ });
94
+
95
+ if (import.meta.main) runMain(NAME, write);
@@ -0,0 +1,110 @@
1
+ import { firstText, parseOutline, VERSION } from "./doc-outline.ts";
2
+ import { RELEASED } from "./doc-rules.ts";
3
+ import { CHANGE_GROUPS, type ChangeGroup } from "./doc-templates.ts";
4
+
5
+ export type Bump = {
6
+ readonly sha: string;
7
+ readonly version: string;
8
+ readonly date: string;
9
+ };
10
+
11
+ export type Cut = {
12
+ readonly version: string;
13
+ readonly date: string;
14
+ readonly through: string;
15
+ readonly after: readonly string[];
16
+ };
17
+
18
+ export type Release = {
19
+ readonly version: string;
20
+ readonly date: string;
21
+ readonly subjects: readonly string[];
22
+ };
23
+
24
+ type Entry = {
25
+ readonly group: ChangeGroup;
26
+ readonly text: string;
27
+ };
28
+
29
+ const CONVENTIONAL = /^([a-z]+)(?:\(([^()]*)\))?(!)?: (\S.*)$/;
30
+
31
+ const GROUP_OF_TYPE = new Map<string, ChangeGroup>([
32
+ ["feat", "Features"],
33
+ ["fix", "Fixes"],
34
+ ["perf", "Performance"],
35
+ ["revert", "Reverts"],
36
+ ]);
37
+
38
+ // Only the subject is linted, and a squash merge's body is its branch's commit messages,
39
+ // so a breaking change is read from the subject's `!` and never from a footer.
40
+ export function groupOf(subject: string): ChangeGroup | undefined {
41
+ const [, type = "", , breaking] = CONVENTIONAL.exec(subject) ?? [];
42
+ return breaking === "!" ? "Breaking changes" : GROUP_OF_TYPE.get(type);
43
+ }
44
+
45
+ function entryOf(subject: string, repositoryUrl: string): Entry | undefined {
46
+ const group = groupOf(subject);
47
+ if (group === undefined) return undefined;
48
+ const [, , scope = "", , description = ""] = CONVENTIONAL.exec(subject) ?? [];
49
+ const linkedDescription = description.replace(/ \(#(\d+)\)$/, (_, number: string) => ` [#${number}](${repositoryUrl}/pull/${number})`);
50
+ return { group, text: scope === "" ? linkedDescription : `**${scope}:** ${linkedDescription}` };
51
+ }
52
+
53
+ // A bump not newer than the release before it is a revert: it cancels every release above the version it
54
+ // returns to, except a published one.
55
+ function standing(bumps: readonly Bump[], published: ReadonlySet<string>): readonly Bump[] {
56
+ return bumps.reduce<readonly Bump[]>((kept, bump) => {
57
+ const before = kept.at(-1);
58
+ if (before === undefined || Bun.semver.order(bump.version, before.version) === 1) return [...kept, bump];
59
+ return kept.filter(({ version }) => published.has(version) || Bun.semver.order(version, bump.version) !== 1);
60
+ }, []);
61
+ }
62
+
63
+ // The changelog records what was released: a version older than the newest it lists and absent from it
64
+ // was never published, so its commits roll into the next release.
65
+ export function cuts(
66
+ bumps: readonly Bump[],
67
+ recorded: ReadonlyMap<string, string>,
68
+ published: ReadonlySet<string>,
69
+ pending: Bump | undefined,
70
+ ): readonly Cut[] {
71
+ const newest = [...recorded.keys()].toSorted(Bun.semver.order).at(-1);
72
+ const candidates = standing(pending === undefined ? bumps : [...bumps, pending], published);
73
+ const released = candidates.filter(
74
+ ({ version }, index) =>
75
+ index === candidates.length - 1 || newest === undefined || recorded.has(version) || Bun.semver.order(version, newest) !== -1,
76
+ );
77
+ return released.map(({ sha, version, date }, index) => ({
78
+ version,
79
+ date: recorded.get(version) ?? date,
80
+ through: sha,
81
+ after: released.slice(0, index).map((earlier) => earlier.sha),
82
+ }));
83
+ }
84
+
85
+ function renderRelease({ version, date, subjects }: Release, repositoryUrl: string): readonly string[] {
86
+ const entries = subjects.flatMap((subject) => entryOf(subject, repositoryUrl) ?? []);
87
+ const groups = CHANGE_GROUPS.flatMap((group) => {
88
+ const listed = entries.filter((entry) => entry.group === group).map(({ text }) => `- ${text}`);
89
+ return listed.length === 0 ? [] : [`### ${group}`, listed.join("\n")];
90
+ });
91
+ return [`## ${version}`, `Released ${date}.`, ...groups];
92
+ }
93
+
94
+ export function renderChangelog(name: string, found: readonly Release[], repositoryUrl: string): string {
95
+ const blocks = [
96
+ "# Changelog",
97
+ `Every release of \`${name}\`, newest first, written by the release from its conventional commits.`,
98
+ ...found.flatMap((release) => renderRelease(release, repositoryUrl)),
99
+ ];
100
+ return `${blocks.join("\n\n")}\n`;
101
+ }
102
+
103
+ export function releaseDates(text: string): ReadonlyMap<string, string> {
104
+ return new Map(
105
+ parseOutline(text).sections.flatMap(({ heading, body }) => {
106
+ const date = RELEASED.exec(firstText(body)?.text.trim() ?? "")?.[1];
107
+ return VERSION.test(heading.title) && date !== undefined ? [[heading.title, date] as const] : [];
108
+ }),
109
+ );
110
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bun
2
+ import { Effect, FileSystem, Schema } from "effect";
3
+ import { runMain, Usage } from "./main.ts";
4
+
5
+ export class ReleaseSectionUnavailable extends Schema.TaggedError<ReleaseSectionUnavailable>()("ReleaseSectionUnavailable", {
6
+ message: Schema.String,
7
+ }) {}
8
+
9
+ export function extractReleaseNotes(changelog: string, version: string): Effect.Effect<string, ReleaseSectionUnavailable> {
10
+ const lines = changelog.split(/\r?\n/);
11
+ const heading = lines.indexOf(`## ${version}`);
12
+ if (heading === -1) return Effect.fail(new ReleaseSectionUnavailable({ message: `CHANGELOG.md has no section for ${version}` }));
13
+
14
+ const next = lines.findIndex((line, index) => index > heading && line.startsWith("## "));
15
+ const body = lines.slice(heading + 1, next === -1 ? lines.length : next);
16
+ let start = 0;
17
+ let end = body.length;
18
+ while (start < end && body[start]?.trim() === "") start += 1;
19
+ while (end > start && body[end - 1]?.trim() === "") end -= 1;
20
+ const notes = body.slice(start, end).join("\n");
21
+ return notes === ""
22
+ ? Effect.fail(new ReleaseSectionUnavailable({ message: `CHANGELOG.md has an empty section for ${version}` }))
23
+ : Effect.succeed(notes);
24
+ }
25
+
26
+ const USAGE = "usage: release-notes.ts <version> <output>";
27
+
28
+ const releaseNotes = Effect.gen(function* () {
29
+ const [version, output, ...extra] = process.argv.slice(2);
30
+ if (version === undefined || output === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
31
+ const fs = yield* FileSystem.FileSystem;
32
+ const notes = yield* extractReleaseNotes(yield* fs.readFileString("CHANGELOG.md"), version);
33
+ yield* fs.writeFileString(output, notes);
34
+ return true;
35
+ });
36
+
37
+ if (import.meta.main) runMain("checks-release-notes", releaseNotes);
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, Schema } from "effect";
3
+ import { groupOf } from "./changelog.ts";
4
+ import { git } from "./git.ts";
5
+ import { runMain, Usage } from "./main.ts";
6
+
7
+ export class ReleaseReportUnreadable extends Schema.TaggedError<ReleaseReportUnreadable>()("ReleaseReportUnreadable", {
8
+ message: Schema.String,
9
+ }) {}
10
+
11
+ const USAGE = "usage: release-report.ts";
12
+ const RELEASE_TAG = "v[0-9]*.[0-9]*.[0-9]*";
13
+
14
+ export function unreleasedOf(subjects: readonly string[]): readonly string[] {
15
+ return subjects.filter((subject) => groupOf(subject) !== undefined);
16
+ }
17
+
18
+ export function formatReport(unreleased: readonly string[], since: string | undefined): string {
19
+ const where = since === undefined ? "with no tag yet" : `since ${since}`;
20
+ if (unreleased.length === 0) return `release-report: no unreleased changes ${where}`;
21
+ return [`release-report: ${unreleased.length} unreleased change(s) ${where}:`, ...unreleased.map((subject) => ` ${subject}`)].join("\n");
22
+ }
23
+
24
+ const lastTag = Effect.fn("lastTag")(function* (root: string) {
25
+ const described = yield* git(["describe", "--tags", "--abbrev=0", `--match=${RELEASE_TAG}`, "HEAD"], root).pipe(
26
+ Effect.map((tag) => tag.trim()),
27
+ Effect.catchTag("GitFailure", () => Effect.succeed("")),
28
+ );
29
+ return described === "" ? undefined : described;
30
+ });
31
+
32
+ const subjectsSince = Effect.fn("subjectsSince")(function* (root: string, since: string | undefined) {
33
+ const range = since === undefined ? "HEAD" : `${since}..HEAD`;
34
+ const log = yield* git(["log", "--topo-order", "--no-merges", "--format=%s", range], root);
35
+ return log.split("\n").filter((subject) => subject !== "");
36
+ });
37
+
38
+ const report = Effect.gen(function* () {
39
+ if (process.argv.slice(2).length > 0) return yield* new Usage({ message: USAGE });
40
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
41
+ if ((yield* git(["rev-parse", "--is-shallow-repository"], root)).trim() === "true") {
42
+ return yield* new ReleaseReportUnreadable({ message: "the checkout is shallow, so the tag it sees may not be the last one; fetch all of it" });
43
+ }
44
+ const since = yield* lastTag(root);
45
+ const unreleased = unreleasedOf(yield* subjectsSince(root, since));
46
+ yield* Console.log(formatReport(unreleased, since));
47
+ return unreleased.length === 0;
48
+ });
49
+
50
+ if (import.meta.main) runMain("checks-release-report", report);
@@ -0,0 +1,54 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { Usage } from "./main.ts";
3
+
4
+ export const NAME = "checks-vendor";
5
+ export const OPENER = "--library";
6
+ const USAGE = `usage: ${NAME} [--library <name> --package <package> --repository <remote> --tag <template> [--path <manifest>]]...`;
7
+
8
+ const Library = Schema.Struct({
9
+ name: Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)),
10
+ package: Schema.NonEmptyString,
11
+ repository: Schema.NonEmptyString,
12
+ tag: Schema.String.check(Schema.isPattern(/\{version\}/)),
13
+ path: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^(?:[\w.@+-]+\/)*[\w.@+-]+\.\w+$/))),
14
+ });
15
+ export type Library = typeof Library.Type;
16
+
17
+ const Libraries = Schema.Array(Library).check(
18
+ Schema.makeFilter((libraries) => {
19
+ const names = libraries.map(({ name }) => name);
20
+ const repeated = names.find((name, index) => names.indexOf(name) !== index);
21
+ return repeated === undefined || `--library ${repeated} appears more than once`;
22
+ }),
23
+ );
24
+
25
+ const FIELDS = new Map<string, keyof Library>([
26
+ ["--package", "package"],
27
+ ["--repository", "repository"],
28
+ ["--tag", "tag"],
29
+ ["--path", "path"],
30
+ ]);
31
+
32
+ function misuse(message: string): Usage {
33
+ return new Usage({ message: `${message}\n${USAGE}` });
34
+ }
35
+
36
+ export const librariesFrom = Effect.fn("librariesFrom")(function* (args: readonly string[]) {
37
+ const groups: Record<string, string>[] = [];
38
+ for (let index = 0; index < args.length; index += 2) {
39
+ const flag = args[index] ?? "";
40
+ const value = args[index + 1];
41
+ if (value === undefined || value.startsWith("--")) return yield* misuse(`${flag} takes a value`);
42
+ if (flag === OPENER) {
43
+ groups.push({ name: value });
44
+ continue;
45
+ }
46
+ const field = FIELDS.get(flag);
47
+ const current = groups.at(-1);
48
+ if (field === undefined) return yield* misuse(`${flag} is not an argument`);
49
+ if (current === undefined) return yield* misuse(`${flag} comes before any ${OPENER}`);
50
+ if (field in current) return yield* misuse(`${flag} appears twice for ${OPENER} ${current["name"]}`);
51
+ current[field] = value;
52
+ }
53
+ return yield* Schema.decodeUnknownEffect(Libraries)(groups).pipe(Effect.mapError((cause) => misuse(cause.message)));
54
+ });
package/scripts/vendor.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
3
  import { git } from "./git.ts";
4
- import { runMain, Usage } from "./main.ts";
4
+ import { runMain } from "./main.ts";
5
+ import { librariesFrom, NAME, OPENER, type Library } from "./vendor-args.ts";
5
6
 
6
- const NAME = "checks-vendor";
7
- const USAGE = `usage: ${NAME} [--library <name> --package <package> --repository <remote> --tag <template> [--path <manifest>]]...`;
8
7
  const CACHE_HOME = ".cache/avi2dg-checks";
9
8
  const RECORD_SUFFIX = ".commit";
10
9
  const LINKS = "repos";
@@ -21,55 +20,6 @@ export class Unreachable extends Schema.TaggedError<Unreachable>()("Unreachable"
21
20
  message: Schema.String,
22
21
  }) {}
23
22
 
24
- const Library = Schema.Struct({
25
- name: Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)),
26
- package: Schema.NonEmptyString,
27
- repository: Schema.NonEmptyString,
28
- tag: Schema.String.check(Schema.isPattern(/\{version\}/)),
29
- path: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^(?:[\w.@+-]+\/)*[\w.@+-]+\.\w+$/))),
30
- });
31
- type Library = typeof Library.Type;
32
-
33
- const Libraries = Schema.Array(Library).check(
34
- Schema.makeFilter((libraries) => {
35
- const names = libraries.map(({ name }) => name);
36
- const repeated = names.find((name, index) => names.indexOf(name) !== index);
37
- return repeated === undefined || `--library ${repeated} appears more than once`;
38
- }),
39
- );
40
-
41
- const OPENER = "--library";
42
- const FIELDS = new Map<string, keyof Library>([
43
- ["--package", "package"],
44
- ["--repository", "repository"],
45
- ["--tag", "tag"],
46
- ["--path", "path"],
47
- ]);
48
-
49
- function misuse(message: string): Usage {
50
- return new Usage({ message: `${message}\n${USAGE}` });
51
- }
52
-
53
- const librariesFrom = Effect.fn("librariesFrom")(function* (args: readonly string[]) {
54
- const groups: Record<string, string>[] = [];
55
- for (let index = 0; index < args.length; index += 2) {
56
- const flag = args[index] ?? "";
57
- const value = args[index + 1];
58
- if (value === undefined || value.startsWith("--")) return yield* misuse(`${flag} takes a value`);
59
- if (flag === OPENER) {
60
- groups.push({ name: value });
61
- continue;
62
- }
63
- const field = FIELDS.get(flag);
64
- const current = groups.at(-1);
65
- if (field === undefined) return yield* misuse(`${flag} is not an argument`);
66
- if (current === undefined) return yield* misuse(`${flag} comes before any ${OPENER}`);
67
- if (field in current) return yield* misuse(`${flag} appears twice for ${OPENER} ${current["name"]}`);
68
- current[field] = value;
69
- }
70
- return yield* Schema.decodeUnknownEffect(Libraries)(groups).pipe(Effect.mapError((cause) => misuse(cause.message)));
71
- });
72
-
73
23
  export function tagFor(template: string, version: string): string {
74
24
  return template.replaceAll(VERSION_TOKEN, version);
75
25
  }
@@ -171,6 +121,11 @@ function clearing(dir: string): string {
171
121
  return `clear it with \`chmod -R u+w ${dir} && rm -rf ${dir}\` and rerun ${NAME}`;
172
122
  }
173
123
 
124
+ function writableAt(dir: string, paths: readonly string[], by: string): string {
125
+ const [first = dir] = paths;
126
+ return `${paths.length === 1 ? "1 path" : `${paths.length} paths`} writable by ${by}, starting with ${first}`;
127
+ }
128
+
174
129
  const freeze = Effect.fn("freeze")(function* (dir: string) {
175
130
  const fs = yield* FileSystem.FileSystem;
176
131
  for (const { entry, mode } of yield* modes(dir)) {
@@ -212,7 +167,8 @@ const recorded = Effect.fn("recorded")(function* (dir: string) {
212
167
  return Option.some(text.trim());
213
168
  });
214
169
 
215
- const verify = Effect.fn("verify")(function* (dir: string, library: Library, installed: string, tag: string) {
170
+ // Returns the paths still carrying the owner write bit and fails on every other finding, reading the status only once none are left.
171
+ const inspect = Effect.fn("inspect")(function* (dir: string, library: Library, installed: string, tag: string) {
216
172
  const held = yield* recorded(dir);
217
173
  if (Option.isNone(held)) {
218
174
  return yield* new VendorError({ message: `${recordOf(dir)} is missing, so ${dir} has no commit to hold to; ${clearing(dir)}` });
@@ -223,13 +179,13 @@ const verify = Effect.fn("verify")(function* (dir: string, library: Library, ins
223
179
  return yield* new VendorError({ message: `${dir} sits on ${head}, not the recorded ${record}; ${clearing(dir)}` });
224
180
  }
225
181
  yield* checkVersion(dir, library, installed, tag);
226
- const tampered = (yield* modes(dir)).filter(({ mode }) => (mode & WRITE_BITS) !== 0).map(({ entry }) => entry);
227
- if (tampered.length > 0) {
228
- const [first = dir] = tampered;
229
- return yield* new VendorError({
230
- message: `${dir} leaves ${tampered.length} paths writable, starting with ${first}; ${clearing(dir)}`,
231
- });
182
+ const found = yield* modes(dir);
183
+ const shared = found.filter(({ mode }) => (mode & WRITE_BITS & ~OWNER_WRITE) !== 0).map(({ entry }) => entry);
184
+ if (shared.length > 0) {
185
+ return yield* new VendorError({ message: `${dir} leaves ${writableAt(dir, shared, "group or others")}; ${clearing(dir)}` });
232
186
  }
187
+ const writable = found.filter(({ mode }) => (mode & OWNER_WRITE) !== 0).map(({ entry }) => entry);
188
+ if (writable.length > 0) return writable;
233
189
  const status = yield* git(["status", "--porcelain", "--ignored"], dir).pipe(
234
190
  Effect.mapError((cause) => new VendorError({ message: `${dir} reports no status: ${cause.message}` })),
235
191
  );
@@ -237,6 +193,7 @@ const verify = Effect.fn("verify")(function* (dir: string, library: Library, ins
237
193
  const [first = ""] = status.trim().split("\n");
238
194
  return yield* new VendorError({ message: `${dir} holds writes outside the recorded commit, starting with ${first}; ${clearing(dir)}` });
239
195
  }
196
+ return writable;
240
197
  });
241
198
 
242
199
  const ensureLink = Effect.fn("ensureLink")(function* (root: string, library: Library, dir: string) {
@@ -315,8 +272,26 @@ const land = Effect.fn("land")(function* (library: Library, installed: string, t
315
272
  return yield* stage(staging, library, installed, tag, dir).pipe(Effect.ensuring(discard(staging)));
316
273
  });
317
274
 
318
- const vend = Effect.fn("vend")(function* (root: string, cache: string, library: Library) {
275
+ function verified(library: Library, tag: string): string {
276
+ return `${LINKS}/${library.name} still holds ${tag}, verified against its recorded commit`;
277
+ }
278
+
279
+ const settle = Effect.fn("settle")(function* (library: Library, installed: string, tag: string, dir: string) {
319
280
  const fs = yield* FileSystem.FileSystem;
281
+ if (!(yield* fs.exists(dir)) && (yield* land(library, installed, tag, dir))) {
282
+ return `cloned ${tag} from ${library.repository} and linked ${LINKS}/${library.name}`;
283
+ }
284
+ const writable = yield* inspect(dir, library, installed, tag);
285
+ if (writable.length === 0) return verified(library, tag);
286
+ yield* freeze(dir);
287
+ const left = yield* inspect(dir, library, installed, tag);
288
+ if (left.length > 0) {
289
+ return yield* new VendorError({ message: `${dir} leaves ${writableAt(dir, left, "its owner")}; ${clearing(dir)}` });
290
+ }
291
+ return `found ${writableAt(dir, writable, "its owner")}, and froze the tree again, so ${verified(library, tag)}`;
292
+ });
293
+
294
+ const vend = Effect.fn("vend")(function* (root: string, cache: string, library: Library) {
320
295
  const path = yield* Path.Path;
321
296
  const installed = yield* manifestVersion(
322
297
  path.join(root, "node_modules", ...library.package.split("/"), "package.json"),
@@ -324,12 +299,7 @@ const vend = Effect.fn("vend")(function* (root: string, cache: string, library:
324
299
  );
325
300
  const tag = tagFor(library.tag, installed);
326
301
  const dir = path.join(cache, LINKS, ...remoteSegments(library.repository), tag);
327
- if (!(yield* fs.exists(dir)) && (yield* land(library, installed, tag, dir))) {
328
- yield* Console.error(`${NAME}: cloned ${tag} from ${library.repository} and linked ${LINKS}/${library.name}`);
329
- } else {
330
- yield* verify(dir, library, installed, tag);
331
- yield* Console.error(`${NAME}: ${LINKS}/${library.name} still holds ${tag}, verified against its recorded commit`);
332
- }
302
+ yield* Console.error(`${NAME}: ${yield* settle(library, installed, tag, dir)}`);
333
303
  yield* ensureLink(root, library, dir);
334
304
  });
335
305