@avi2dg/checks 0.52.1 → 0.54.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 (35) hide show
  1. package/CHANGELOG.md +8 -569
  2. package/docs/bins.md +5 -0
  3. package/docs/configs/commit-messages.md +5 -4
  4. package/docs/design.md +28 -6
  5. package/docs/gates/checks-ci-wiring.md +1 -1
  6. package/docs/gates/checks-commit-identity.md +1 -1
  7. package/docs/gates/checks-commit-title.md +5 -4
  8. package/docs/gates/checks-copy.md +2 -2
  9. package/docs/gates/checks-night-run.md +4 -4
  10. package/docs/gates/checks-pr-text.md +90 -0
  11. package/docs/gates/checks-release-notes.md +39 -4
  12. package/docs/gates/checks-release-pr.md +6 -4
  13. package/docs/gates/checks-release-preflight.md +94 -0
  14. package/docs/gates/checks-release-publish.md +63 -0
  15. package/docs/gates/checks-release-push.md +162 -0
  16. package/docs/gates/checks-release-report.md +2 -1
  17. package/docs/gates/checks-release-tag.md +2 -0
  18. package/docs/gates/checks-secrets.md +90 -1
  19. package/docs/gates/checks-setup.md +76 -0
  20. package/docs/gates/checks-vendor.md +3 -3
  21. package/package.json +14 -1
  22. package/src/delivery/commit-title.ts +14 -7
  23. package/src/delivery/gitleaks.ts +24 -9
  24. package/src/delivery/pr-text.ts +77 -0
  25. package/src/delivery/release-commit.ts +152 -0
  26. package/src/delivery/release-pr.ts +9 -132
  27. package/src/delivery/release-preflight.ts +177 -0
  28. package/src/delivery/release-publish.ts +73 -0
  29. package/src/delivery/release-push.ts +148 -0
  30. package/src/delivery/release-tag.ts +4 -53
  31. package/src/delivery/release-tagging.ts +74 -0
  32. package/src/delivery/secrets-pack.ts +133 -0
  33. package/src/delivery/secrets.ts +5 -4
  34. package/src/delivery/setup.ts +399 -0
  35. package/src/delivery/workflow-references.ts +2 -2
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { runMain, Usage } from "../core/main.ts";
4
+ import { TEMPLATES } from "../docs/doc-templates.ts";
5
+ import { scriptOptions } from "../testing/test.ts";
6
+ import { withServiceOverrides, type ServiceOverride } from "../quality/effect-scope.ts";
7
+ import { SOURCES, TESTS } from "../quality/presets/oxlint.ts";
8
+ import severities from "../quality/presets/effect.language-service.json" with { type: "json" };
9
+
10
+ const NAME = "checks-setup";
11
+ const USAGE = "usage: " + NAME + " [--private]";
12
+ const PRIVATE = "--private";
13
+ const HOSTED_FALLBACK = "ubuntu-latest";
14
+ const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
15
+ const IGNORE_ENTRY = "node_modules/";
16
+ const KIT_ID = "@avi2dg/checks";
17
+
18
+ class SetupError extends Schema.TaggedError<SetupError>()("SetupError", {
19
+ message: Schema.String,
20
+ }) {}
21
+
22
+ const KitManifest = Schema.Struct({ version: Schema.String });
23
+ const decodeKitManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(KitManifest));
24
+
25
+ const JsonRecord = Schema.Record(Schema.String, Schema.Unknown);
26
+ const decodeRecord = Schema.decodeUnknownEffect(JsonRecord);
27
+
28
+ const DEFAULT_SCRIPTS: Record<string, string> = {
29
+ lint: "oxlint && checks-lint",
30
+ typecheck: "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
31
+ test: "checks-test",
32
+ };
33
+
34
+ const OXLINT_CONFIG = [
35
+ "import { defineConfig } from \"@avi2dg/checks/oxlint\";",
36
+ "",
37
+ "export default defineConfig({ effect: true });",
38
+ "",
39
+ ].join("\n");
40
+
41
+ const TSCONFIG_BASE = [
42
+ "{",
43
+ " \"extends\": \"@avi2dg/checks/tsconfig.effect.json\",",
44
+ " \"compilerOptions\": { \"types\": [\"bun\"] }",
45
+ "}",
46
+ "",
47
+ ].join("\n");
48
+
49
+ const DEFAULT_OVERRIDES: readonly ServiceOverride[] = [
50
+ { include: [...SOURCES], exclude: [...TESTS], options: severities },
51
+ ];
52
+
53
+ function quotedStrings(text: string): readonly string[] {
54
+ const found: string[] = [];
55
+ const pattern = new RegExp("\"((?:[^\"\\\\]|\\\\.)*)\"|'((?:[^'\\\\]|\\\\.)*)'", "g");
56
+ for (;;) {
57
+ const match = pattern.exec(text);
58
+ if (match === null) return found;
59
+ found.push(match[1] ?? match[2] ?? "");
60
+ }
61
+ }
62
+
63
+ function delimitedInner(text: string, at: number, openChar: string, closeChar: string): string | undefined {
64
+ const open = text.indexOf(openChar, at);
65
+ if (open === -1) return undefined;
66
+ let depth = 0;
67
+ let quote: string | undefined;
68
+ for (let index = open; index < text.length; index += 1) {
69
+ const char = text[index];
70
+ if (quote !== undefined) {
71
+ if (char === "\\") index += 1;
72
+ else if (char === quote) quote = undefined;
73
+ } else if (char === "\"" || char === "'") {
74
+ quote = char;
75
+ } else if (char === openChar) {
76
+ depth += 1;
77
+ } else if (char === closeChar) {
78
+ depth -= 1;
79
+ if (depth === 0) return text.slice(open + 1, index);
80
+ }
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function arrayAfter(text: string, key: string): readonly string[] | undefined {
86
+ const at = text.search(new RegExp(key + "\\s*:"));
87
+ if (at === -1) return undefined;
88
+ const inner = delimitedInner(text, at, "[", "]");
89
+ if (inner === undefined) return undefined;
90
+ return quotedStrings(inner);
91
+ }
92
+
93
+ function blockAfter(text: string, at: number): string | undefined {
94
+ return delimitedInner(text, at, "{", "}");
95
+ }
96
+
97
+ const effectScopeOf = (text: string): Effect.Effect<readonly ServiceOverride[], SetupError> => {
98
+ const match = new RegExp("effect\\s*:\\s*(true|false|\\{)").exec(text);
99
+ if (match === null || match[1] === "true") return Effect.succeed(DEFAULT_OVERRIDES);
100
+ if (match[1] === "false") return Effect.succeed([]);
101
+ const block = blockAfter(text, match.index);
102
+ if (block === undefined) return Effect.succeed(DEFAULT_OVERRIDES);
103
+ const files = arrayAfter(block, "files");
104
+ const excludeFiles = arrayAfter(block, "excludeFiles") ?? [];
105
+ if (files !== undefined && files.length === 0) {
106
+ return Effect.fail(new SetupError({ message: "cannot read the effect scope of oxlint.config.ts, give files at least one glob or remove the file so setup writes a fresh config" }));
107
+ }
108
+ if (files === undefined) return Effect.succeed([{ include: [...SOURCES], exclude: [...TESTS, ...excludeFiles], options: severities }]);
109
+ return Effect.succeed([{ include: files, exclude: excludeFiles, options: severities }]);
110
+ };
111
+
112
+ const KNIP_CONFIG = [
113
+ "import { defineConfig } from \"@avi2dg/checks/knip\";",
114
+ "",
115
+ "export default defineConfig({ entry: [\"src/index.ts\"] });",
116
+ "",
117
+ ].join("\n");
118
+
119
+ function workflow(isPrivate: boolean, hasBuild: boolean): string {
120
+ const lines = [
121
+ "name: ci",
122
+ "",
123
+ "on:",
124
+ " pull_request:",
125
+ " types: [opened, synchronize]",
126
+ "",
127
+ "jobs:",
128
+ " checks:",
129
+ " runs-on: " + (isPrivate ? HOSTED_DEFAULT : HOSTED_FALLBACK),
130
+ " steps:",
131
+ " - uses: actions/checkout@v5",
132
+ " with:",
133
+ " fetch-depth: 0",
134
+ " - uses: oven-sh/setup-bun@v2",
135
+ " with:",
136
+ " bun-version-file: .bun-version",
137
+ " - run: bun install --frozen-lockfile",
138
+ ];
139
+ if (hasBuild) {
140
+ lines.push(" - run: bun run build", " - run: git diff --exit-code");
141
+ }
142
+ lines.push(" - run: bun run lint", " - run: bun run typecheck", " - run: bun run test");
143
+ lines.push(
144
+ " - id: title",
145
+ " name: lint the title",
146
+ " env:",
147
+ " PR_TITLE: ${{ github.event.pull_request.title }}",
148
+ " GH_TOKEN: ${{ github.token }}",
149
+ " run: bunx checks-commit-title",
150
+ "",
151
+ );
152
+ return lines.join("\n");
153
+ }
154
+
155
+ const kitText = Effect.fn("kitText")(function* (file: string) {
156
+ const system = yield* FileSystem.FileSystem;
157
+ const path = yield* Path.Path;
158
+ const full = path.join(path.join(import.meta.dir, "..", ".."), file);
159
+ return yield* system.readFileString(full).pipe(
160
+ Effect.mapError((cause) => new SetupError({ message: "cannot read the kit " + file + " at " + full + ": " + cause.message })),
161
+ );
162
+ });
163
+
164
+ const setupFile = Effect.fn("setupFile")(function* (root: string, relative: string, content: string) {
165
+ const system = yield* FileSystem.FileSystem;
166
+ const path = yield* Path.Path;
167
+ const full = path.join(root, relative);
168
+ if (yield* system.exists(full)) {
169
+ yield* Console.log(NAME + ": kept " + relative + ", already there");
170
+ return;
171
+ }
172
+ yield* system.makeDirectory(path.dirname(full), { recursive: true });
173
+ yield* system.writeFileString(full, content);
174
+ yield* Console.log(NAME + ": wrote " + relative);
175
+ });
176
+
177
+ const setupIgnore = Effect.fn("setupIgnore")(function* (root: string) {
178
+ const system = yield* FileSystem.FileSystem;
179
+ const full = (yield* Path.Path).join(root, ".gitignore");
180
+ if (!(yield* system.exists(full))) {
181
+ yield* system.writeFileString(full, IGNORE_ENTRY + "\n");
182
+ yield* Console.log(NAME + ": wrote .gitignore");
183
+ return;
184
+ }
185
+ const text = yield* system.readFileString(full).pipe(
186
+ Effect.mapError((cause) => new SetupError({ message: "cannot read .gitignore: " + cause.message })),
187
+ );
188
+ if (text.split("\n").some((line) => line.trim() === IGNORE_ENTRY)) {
189
+ yield* Console.log(NAME + ": kept .gitignore, already there");
190
+ return;
191
+ }
192
+ const end = text === "" || text.endsWith("\n") ? text : text + "\n";
193
+ yield* system.writeFileString(full, end + IGNORE_ENTRY + "\n");
194
+ yield* Console.log(NAME + ": added " + IGNORE_ENTRY + " to .gitignore");
195
+ });
196
+
197
+ type MutableRecord = Record<string, unknown>;
198
+
199
+ const parseRecord = (
200
+ parse: () => unknown,
201
+ toParseFault: (cause: unknown) => SetupError,
202
+ shapeFault: SetupError,
203
+ ): Effect.Effect<MutableRecord, SetupError> =>
204
+ Effect.try({ try: parse, catch: (cause) => toParseFault(cause) }).pipe(
205
+ Effect.flatMap((parsed) => decodeRecord(parsed).pipe(Effect.map((decoded) => ({ ...decoded })), Effect.mapError(() => shapeFault))),
206
+ );
207
+
208
+ const parseTsconfigRecord = (current: string): Effect.Effect<MutableRecord, SetupError> =>
209
+ parseRecord(
210
+ (): unknown => Bun.JSONC.parse(current),
211
+ () => new SetupError({ message: "cannot read tsconfig.json as JSONC, fix the file or remove it so setup writes a fresh config" }),
212
+ new SetupError({ message: "cannot read tsconfig.json as JSONC, fix the file or remove it so setup writes a fresh config: expected an object" }),
213
+ );
214
+
215
+ const setupTsconfig = Effect.fn("setupTsconfig")(function* (root: string, oxlintText: string) {
216
+ const system = yield* FileSystem.FileSystem;
217
+ const path = yield* Path.Path;
218
+ const full = path.join(root, "tsconfig.json");
219
+ const present = yield* system.exists(full);
220
+ const current = present
221
+ ? yield* system.readFileString(full).pipe(Effect.mapError((cause) => new SetupError({ message: "cannot read tsconfig.json: " + cause.message })))
222
+ : TSCONFIG_BASE;
223
+ yield* parseTsconfigRecord(current);
224
+ const overrides = yield* effectScopeOf(oxlintText);
225
+ const next = withServiceOverrides(current, overrides);
226
+ if (present && next === current) {
227
+ yield* Console.log(NAME + ": kept tsconfig.json, already there");
228
+ return;
229
+ }
230
+ if (!present) yield* system.makeDirectory(path.dirname(full), { recursive: true });
231
+ yield* system.writeFileString(full, next);
232
+ yield* Console.log(NAME + ": wrote tsconfig.json");
233
+ });
234
+
235
+ function scriptFault(scripts: Record<string, unknown>): string | undefined {
236
+ const lint = scripts["lint"];
237
+ if (typeof lint === "string" && (!lint.includes("oxlint") || !lint.includes("checks-lint"))) {
238
+ return "package.json scripts.lint must run oxlint and the layout check: use \"oxlint && checks-lint\"";
239
+ }
240
+ const test = scripts["test"];
241
+ if (typeof test === "string") {
242
+ const options = scriptOptions(test);
243
+ if (options === undefined || options.tier !== undefined || options.full === true) {
244
+ return "package.json scripts.test must be \"checks-test\", optionally with --parallel=<workers>, found " + JSON.stringify(test);
245
+ }
246
+ }
247
+ const typecheck = scripts["typecheck"];
248
+ if (typeof typecheck === "string" && (!typecheck.includes("tsc") || !typecheck.includes("effect-tsgo"))) {
249
+ return "package.json scripts.typecheck must run tsc and the Effect diagnostics: use \"tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict\"";
250
+ }
251
+ return undefined;
252
+ }
253
+
254
+ const parsePackageRecord = (raw: string): Effect.Effect<MutableRecord, SetupError> =>
255
+ parseRecord(
256
+ (): unknown => JSON.parse(raw),
257
+ (cause) => new SetupError({ message: "cannot read package.json as JSON, fix the file or remove it so setup writes a fresh manifest: " + String(cause) }),
258
+ new SetupError({ message: "cannot read package.json as JSON, fix the file or remove it so setup writes a fresh manifest: expected an object" }),
259
+ );
260
+
261
+ const fieldRecord = (record: MutableRecord, field: string, message: string): Effect.Effect<MutableRecord, SetupError> => {
262
+ const found = record[field];
263
+ if (found === undefined) return Effect.succeed({});
264
+ return decodeRecord(found).pipe(Effect.map((decoded) => ({ ...decoded })), Effect.mapError(() => new SetupError({ message })));
265
+ };
266
+
267
+ const scriptsOf = (record: MutableRecord): Effect.Effect<MutableRecord, SetupError> => fieldRecord(record, "scripts", "cannot read package.json as JSON, fix the file or remove it so setup writes a fresh manifest: expected scripts to be an object");
268
+
269
+ const depsOf = (record: MutableRecord): Effect.Effect<MutableRecord, SetupError> => fieldRecord(record, "devDependencies", "cannot read package.json as JSON, fix the file or remove it so setup writes a fresh manifest: expected devDependencies to be an object");
270
+
271
+ function withKitDefaults(scripts: MutableRecord): MutableRecord {
272
+ const next: MutableRecord = { ...scripts };
273
+ for (const [name, command] of Object.entries(DEFAULT_SCRIPTS)) {
274
+ if (typeof next[name] !== "string") next[name] = command;
275
+ }
276
+ return next;
277
+ }
278
+
279
+ function finalizePackage(
280
+ record: MutableRecord,
281
+ scripts: MutableRecord,
282
+ devDependencies: MutableRecord,
283
+ isPrivate: boolean,
284
+ fallbackName: string,
285
+ ): { readonly merged: MutableRecord; readonly hasBuild: boolean; readonly hasOwner: boolean } {
286
+ const merged: MutableRecord = { ...record, scripts, devDependencies };
287
+ if (typeof merged["name"] !== "string") merged["name"] = fallbackName;
288
+ if (merged["type"] === undefined) merged["type"] = "module";
289
+ if (isPrivate) merged["private"] = true;
290
+ const hasOwner = "author" in merged || "contributors" in merged;
291
+ const hasBuild = typeof scripts["build"] === "string";
292
+ return { merged, hasBuild, hasOwner };
293
+ }
294
+
295
+ const writeFreshPackage = Effect.fn("writeFreshPackage")(function* (
296
+ root: string,
297
+ isPrivate: boolean,
298
+ version: string,
299
+ fallbackName: string,
300
+ ) {
301
+ const system = yield* FileSystem.FileSystem;
302
+ const path = yield* Path.Path;
303
+ const full = path.join(root, "package.json");
304
+ const manifest = {
305
+ name: fallbackName,
306
+ type: "module",
307
+ ...(isPrivate ? { private: true } : {}),
308
+ scripts: { ...DEFAULT_SCRIPTS },
309
+ devDependencies: { [KIT_ID]: version },
310
+ };
311
+ yield* system.makeDirectory(path.dirname(full), { recursive: true });
312
+ yield* system.writeFileString(full, JSON.stringify(manifest, undefined, 2) + "\n");
313
+ yield* Console.log(NAME + ": wrote package.json");
314
+ return { hasBuild: false, hasOwner: false };
315
+ });
316
+
317
+ const updatePackage = Effect.fn("updatePackage")(function* (
318
+ root: string,
319
+ raw: string,
320
+ record: MutableRecord,
321
+ isPrivate: boolean,
322
+ fallbackName: string,
323
+ version: string,
324
+ ) {
325
+ const system = yield* FileSystem.FileSystem;
326
+ const full = (yield* Path.Path).join(root, "package.json");
327
+ const baseScripts = yield* scriptsOf(record);
328
+ const baseDeps = yield* depsOf(record);
329
+ const devDependencies: MutableRecord = { ...baseDeps, [KIT_ID]: version };
330
+ const fault = scriptFault(baseScripts);
331
+ if (fault !== undefined) return yield* new SetupError({ message: fault });
332
+ const scripts = withKitDefaults(baseScripts);
333
+ const finalized = finalizePackage(record, scripts, devDependencies, isPrivate, fallbackName);
334
+ const next = JSON.stringify(finalized.merged, undefined, 2) + "\n";
335
+ if (next === raw) {
336
+ yield* Console.log(NAME + ": kept package.json, already there");
337
+ return { hasBuild: finalized.hasBuild, hasOwner: finalized.hasOwner };
338
+ }
339
+ yield* system.writeFileString(full, next);
340
+ yield* Console.log(NAME + ": wrote package.json");
341
+ return { hasBuild: finalized.hasBuild, hasOwner: finalized.hasOwner };
342
+ });
343
+ const setupPackage = Effect.fn("setupPackage")(function* (
344
+ root: string,
345
+ isPrivate: boolean,
346
+ version: string,
347
+ fallbackName: string,
348
+ ) {
349
+ const system = yield* FileSystem.FileSystem;
350
+ const full = (yield* Path.Path).join(root, "package.json");
351
+ if (!(yield* system.exists(full))) {
352
+ return yield* writeFreshPackage(root, isPrivate, version, fallbackName);
353
+ }
354
+ const raw = yield* system.readFileString(full).pipe(
355
+ Effect.mapError((cause) => new SetupError({ message: "cannot read package.json: " + cause.message })),
356
+ );
357
+ const record = yield* parsePackageRecord(raw);
358
+ return yield* updatePackage(root, raw, record, isPrivate, fallbackName, version);
359
+ });
360
+
361
+ const check = Effect.gen(function* () {
362
+ const args = process.argv.slice(2);
363
+ const unknown = args.find((arg) => arg !== PRIVATE);
364
+ if (unknown !== undefined) return yield* new Usage({ message: unknown + " is not an argument\n" + USAGE });
365
+ const isPrivate = args.includes(PRIVATE);
366
+ const root = yield* Effect.sync(() => process.cwd());
367
+ const path = yield* Path.Path;
368
+
369
+ const version = (yield* decodeKitManifest(yield* kitText("package.json")).pipe(
370
+ Effect.mapError((cause) => new SetupError({ message: "cannot read the kit version: " + cause.message })),
371
+ )).version;
372
+ const outcome = yield* setupPackage(root, isPrivate, version, path.basename(root));
373
+
374
+ yield* setupFile(root, ".bun-version", yield* kitText(".bun-version"));
375
+ yield* setupFile(root, "bunfig.toml", yield* kitText("bunfig.toml"));
376
+ yield* setupIgnore(root);
377
+ const oxlintFull = path.join(root, "oxlint.config.ts");
378
+ const system = yield* FileSystem.FileSystem;
379
+ const oxlintRetained = yield* system.exists(oxlintFull);
380
+ yield* setupFile(root, "oxlint.config.ts", OXLINT_CONFIG);
381
+ const oxlintText = oxlintRetained
382
+ ? yield* system.readFileString(oxlintFull).pipe(
383
+ Effect.mapError((cause) => new SetupError({ message: "cannot read oxlint.config.ts: " + cause.message })),
384
+ )
385
+ : OXLINT_CONFIG;
386
+ yield* setupTsconfig(root, oxlintText);
387
+ yield* setupFile(root, "knip.config.ts", KNIP_CONFIG);
388
+ yield* setupFile(root, ".github/workflows/ci.yml", workflow(isPrivate, outcome.hasBuild));
389
+ for (const kind of Object.keys(TEMPLATES)) {
390
+ yield* setupFile(root, "templates/" + kind + ".md", yield* kitText("dist/templates/" + kind + ".md"));
391
+ }
392
+ if (isPrivate) yield* Console.log(NAME + ": set the CI_RUNS_ON variable to a self-hosted runner label so the new jobs land there");
393
+ if (!outcome.hasOwner) {
394
+ yield* Console.log(NAME + ": declare the repository owners in package.json author or contributors before the first commit");
395
+ }
396
+ return true;
397
+ });
398
+
399
+ if (import.meta.main) runMain(NAME, check);
@@ -38,8 +38,8 @@ type Locator = (keys: readonly Key[], needle: string, fallback: string) => strin
38
38
  type Node = { readonly from: number; readonly to: number; readonly column: number };
39
39
 
40
40
  const WORKFLOWS = ".github/workflows";
41
- const RELEASE_BINS = ["checks-release-pr", "checks-release-tag"];
42
- const RELEASE_SOURCES = ["release-pr.ts", "release-tag.ts"];
41
+ const RELEASE_BINS = ["checks-release-pr", "checks-release-push", "checks-release-tag"];
42
+ const RELEASE_SOURCES = ["release-pr.ts", "release-push.ts", "release-tag.ts"];
43
43
  const REUSABLE_PREFIX = "./.github/workflows/";
44
44
  // GitHub substitutes an expression into the script before the shell reads it.
45
45
  const EXPRESSION = /\$\{\{[\s\S]*?\}\}/g;