@avi2dg/checks 0.63.1 → 0.65.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.63.1",
3
+ "version": "0.65.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -44,6 +44,7 @@
44
44
  "src/delivery/release-pr.ts",
45
45
  "src/delivery/release-preflight.ts",
46
46
  "src/delivery/release-schedule.ts",
47
+ "src/delivery/schedule-policy.ts",
47
48
  "src/delivery/release-publish.ts",
48
49
  "src/delivery/release-push.ts",
49
50
  "src/delivery/release-tag.ts",
@@ -53,6 +54,7 @@
53
54
  "src/testing/block-screens.ts",
54
55
  "src/testing/test.ts",
55
56
  "src/testing/test-skips.ts",
57
+ "src/testing/affected-tests.ts",
56
58
  "src/core/swc.ts",
57
59
  "src/testing/test-report.ts",
58
60
  "src/testing/flake.ts",
@@ -76,11 +78,13 @@
76
78
  "src/delivery/workflow-references.ts",
77
79
  "src/delivery/bun-pin.ts",
78
80
  "src/delivery/mutation-job.ts",
81
+ "src/delivery/job-timeout.ts",
79
82
  "src/delivery/runner-policy.ts",
80
83
  "src/delivery/secrets.ts",
81
84
  "src/delivery/secrets-pack.ts",
82
85
  "src/delivery/pr-text.ts",
83
86
  "src/delivery/setup.ts",
87
+ "src/delivery/setup-workflow.ts",
84
88
  "src/delivery/gitleaks.ts",
85
89
  "src/delivery/gitleaks.toml",
86
90
  "src/quality/comment-matchers.ts",
@@ -4,9 +4,11 @@ import { defaultBranch, git } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
6
6
  import { findBunPinFaults, formatBunPinReport, readBunPinPolicy } from "./bun-pin.ts";
7
+ import { findLongJobs, formatLongJobReport } from "./job-timeout.ts";
7
8
  import { readScheduleFiles, readShellFiles } from "./mutation-job.ts";
8
9
  import { findScheduledReleases, formatScheduleReport } from "./release-schedule.ts";
9
10
  import { findRunnerFaults, formatRunnerReport, jobRuns, readRunnerConfig } from "./runner-policy.ts";
11
+ import { findScheduledRuns, formatScheduledRunReport } from "./schedule-policy.ts";
10
12
  import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
11
13
  import { findMissingWorkflows, formatMissingReport, isRecord, names, workflowJobs, type Workflow } from "./workflow-references.ts";
12
14
 
@@ -65,6 +67,7 @@ const TITLE_LINT = "bunx checks-commit-title";
65
67
  const TITLE_CALLS = ["bun src/delivery/commit-title.ts", "./node_modules/.bin/commitlint"];
66
68
  const ScriptsManifest = Schema.Struct({
67
69
  scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
70
+ private: Schema.optionalKey(Schema.Boolean),
68
71
  });
69
72
  // Without these a pull_request workflow never sees the commits a pull request pushes.
70
73
  const GATING_TYPES = ["opened", "synchronize"];
@@ -271,16 +274,19 @@ export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflo
271
274
  catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
272
275
  });
273
276
 
274
- const readScripts = Effect.fn("readScripts")(function* (root: string) {
277
+ const readManifest = Effect.fn("readManifest")(function* (root: string) {
275
278
  const fs = yield* FileSystem.FileSystem;
276
279
  const file = (yield* Path.Path).join(root, "package.json");
277
- const { scripts = {} } = (yield* fs.exists(file))
280
+ return (yield* fs.exists(file))
278
281
  ? yield* fs.readFileString(file).pipe(
279
282
  Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ScriptsManifest))),
280
283
  Effect.mapError((cause) => new WiringError({ message: `cannot read ${file}: ${cause.message}` })),
281
284
  )
282
285
  : ScriptsManifest.make({});
283
- return new Map(Object.entries(scripts));
286
+ });
287
+
288
+ const readScripts = Effect.fn("readScripts")(function* (root: string) {
289
+ return new Map(Object.entries((yield* readManifest(root)).scripts ?? {}));
284
290
  });
285
291
 
286
292
  export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
@@ -322,17 +328,24 @@ const wiring = Effect.gen(function* () {
322
328
  yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
323
329
  const missing = findMissingWorkflows(workflows);
324
330
  if (missing.length > 0) yield* Console.error(formatMissingReport(missing));
325
- const scripts = yield* readScripts(root);
331
+ const manifest = yield* readManifest(root);
332
+ const scripts = new Map(Object.entries(manifest.scripts ?? {}));
333
+ const visibility = yield* readVisibility;
326
334
  const runs = workflows.flatMap((workflow) => workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job)));
327
335
  const runners = yield* readRunnerConfig(root);
328
- const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs), runners }, workflows);
336
+ const faults = findRunnerFaults({ visibility, scripts, files: yield* readShellFiles(root, scripts, runs), runners }, workflows);
329
337
  if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
330
338
  const bunFaults = findBunPinFaults(yield* readBunPinPolicy(root, workflows), workflows);
331
339
  if (bunFaults.length > 0) yield* Console.error(formatBunPinReport(bunFaults));
332
340
  const scheduleFiles = yield* readScheduleFiles(root, scripts, runs);
333
- const scheduled = findScheduledReleases(workflows, { scripts, files: scheduleFiles });
341
+ const owner = { visibility, publishes: manifest.private !== true };
342
+ const scheduled = findScheduledReleases(workflows, { scripts, files: scheduleFiles }, owner);
334
343
  if (scheduled.length > 0) yield* Console.error(formatScheduleReport(scheduled));
335
- return gaps.length === 0 && faults.length === 0 && missing.length === 0 && bunFaults.length === 0 && scheduled.length === 0;
344
+ const scheduledRuns = findScheduledRuns(workflows, { scripts, files: scheduleFiles }, owner);
345
+ if (scheduledRuns.length > 0) yield* Console.error(formatScheduledRunReport(scheduledRuns));
346
+ const longJobs = findLongJobs(workflows);
347
+ if (longJobs.length > 0) yield* Console.error(formatLongJobReport(longJobs));
348
+ return [gaps, faults, missing, bunFaults, scheduled, scheduledRuns, longJobs].every((found) => found.length === 0);
336
349
  });
337
350
 
338
351
  if (import.meta.main) runMain("ci-wiring", wiring);
@@ -0,0 +1,30 @@
1
+ import { workflowJobs, type Workflow } from "./workflow-references.ts";
2
+
3
+ export type LongJob = {
4
+ readonly location: string;
5
+ readonly fault: string;
6
+ };
7
+
8
+ const LIMIT = 60;
9
+ const SET = `set timeout-minutes to a number of at most ${LIMIT}`;
10
+
11
+ function timeoutFault(timeout: unknown): string | undefined {
12
+ if (timeout === undefined) return "the job sets no timeout-minutes, so a hung run holds its runner for GitHub's 360 minute default; " + SET;
13
+ if (typeof timeout !== "number") return "its timeout-minutes cannot be shown to stay within an hour before the run; " + SET;
14
+ return timeout > LIMIT ? `its timeout-minutes of ${timeout} lets the job run for hours; ${SET}` : undefined;
15
+ }
16
+
17
+ // A job that calls a reusable workflow cannot set timeout-minutes, and the called workflow's own jobs are judged where it lives.
18
+ export function findLongJobs(workflows: readonly Workflow[]): readonly LongJob[] {
19
+ return workflows.flatMap((workflow) =>
20
+ workflowJobs(workflow).flatMap(([id, job]) => {
21
+ if (job["uses"] !== undefined) return [];
22
+ const fault = timeoutFault(job["timeout-minutes"]);
23
+ return fault === undefined ? [] : [{ location: `${workflow.path} job ${id}`, fault }];
24
+ }),
25
+ );
26
+ }
27
+
28
+ export function formatLongJobReport(jobs: readonly LongJob[]): string {
29
+ return [`ci-wiring: ${jobs.length} job(s) can run for hours:`, ...jobs.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
30
+ }
@@ -12,7 +12,7 @@ const LABEL = "release-push";
12
12
  const USAGE = "usage: release-push.ts <release-workflow> <workflow>...";
13
13
  const UNTAGGED = "tag the release commit with checks-release-tag, whose refusal says what to fix";
14
14
  const POLL = Duration.seconds(15);
15
- const POLLS = 180;
15
+ const POLLS = 88;
16
16
 
17
17
  const Run = Schema.Struct({ path: Schema.String, status: Schema.String, conclusion: Schema.NullOr(Schema.String), html_url: Schema.String });
18
18
  type Run = typeof Run.Type;
@@ -5,6 +5,7 @@ import { isRecord, locate, names, workflowJobs, type Workflow } from "./workflow
5
5
 
6
6
  export type ScheduledRelease = {
7
7
  readonly location: string;
8
+ readonly fault: string;
8
9
  };
9
10
 
10
11
  export type ScheduleContext = {
@@ -12,18 +13,26 @@ export type ScheduleContext = {
12
13
  readonly files: ReadonlyMap<string, string>;
13
14
  };
14
15
 
15
- const EMPTY: ScheduleContext = { scripts: new Map(), files: new Map() };
16
+ export type ScheduleOwner = {
17
+ readonly visibility: "private" | "public" | "unknown";
18
+ readonly publishes: boolean;
19
+ };
16
20
 
17
21
  const RELEASE_BINS = ["pr", "push", "tag", "publish"].flatMap((bin) => [`checks-release-${bin}`, `release-${bin}.ts`]);
22
+ const PUBLISH_BINS = ["checks-release-publish", "release-publish.ts"];
23
+ const NUMBER = /^\d+$/;
24
+ const BY_HAND = "drop the schedule, so a release runs only when someone starts it";
25
+
26
+ function isBinIn(bins: readonly string[], word: string): boolean {
27
+ return bins.some((name) => word === name || word.endsWith(`/${name}`));
28
+ }
18
29
 
19
- function isReleaseBin(word: string): boolean {
20
- return RELEASE_BINS.some((name) => word === name || word.endsWith(`/${name}`));
30
+ function isPublishCommand([head = "", ...rest]: Command): boolean {
31
+ return (head === "npm" && rest[0] === "publish") || isBinIn(PUBLISH_BINS, head);
21
32
  }
22
33
 
23
34
  function isReleaseCommand(program: Command): boolean {
24
- const [head = "", ...rest] = program;
25
- if (head === "npm" && rest[0] === "publish") return true;
26
- return isReleaseBin(head);
35
+ return isPublishCommand(program) || isBinIn(RELEASE_BINS, program[0] ?? "");
27
36
  }
28
37
 
29
38
  function localUses(job: Readonly<Record<string, unknown>>): string | undefined {
@@ -31,9 +40,10 @@ function localUses(job: Readonly<Record<string, unknown>>): string | undefined {
31
40
  return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
32
41
  }
33
42
 
34
- function workflowReleases(
43
+ function workflowRuns(
35
44
  workflow: Workflow,
36
45
  context: ScheduleContext,
46
+ isBin: (program: Command) => boolean,
37
47
  byPath: ReadonlyMap<string, Workflow>,
38
48
  walkedWorkflows: readonly string[],
39
49
  ): boolean {
@@ -41,32 +51,57 @@ function workflowReleases(
41
51
  if (
42
52
  workflowJobs(workflow)
43
53
  .flatMap(([, job]) => jobRuns(workflow, job))
44
- .some((run) => runsBin(run, context.scripts, files, isReleaseCommand, true))
54
+ .some((run) => runsBin(run, context.scripts, files, isBin, true))
45
55
  )
46
56
  return true;
47
57
  return workflowJobs(workflow).some(([, job]) => {
48
58
  const called = localUses(job);
49
59
  if (called === undefined || walkedWorkflows.includes(called)) return false;
50
60
  const next = byPath.get(called);
51
- return next !== undefined && workflowReleases(next, context, byPath, [...walkedWorkflows, called]);
61
+ return next !== undefined && workflowRuns(next, context, isBin, byPath, [...walkedWorkflows, called]);
52
62
  });
53
63
  }
54
64
 
55
- function schedules(document: unknown): boolean {
65
+ function crons(document: unknown): readonly unknown[] {
66
+ const on = isRecord(document) ? document["on"] : undefined;
67
+ const schedule = isRecord(on) ? on["schedule"] : undefined;
68
+ return Array.isArray(schedule) ? schedule.map((entry) => (isRecord(entry) ? entry["cron"] : undefined)) : [];
69
+ }
70
+
71
+ // At most once a day is one minute and one hour, whatever the day, month and weekday fields allow.
72
+ export function atMostDaily(cron: unknown): boolean {
73
+ if (typeof cron !== "string") return false;
74
+ const fields = cron.trim().split(/\s+/);
75
+ return fields.length === 5 && NUMBER.test(fields[0] ?? "") && NUMBER.test(fields[1] ?? "");
76
+ }
77
+
78
+ // A run outside CI cannot read whether the repository is private, so only a package.json that sets private refuses there.
79
+ function releaseFault(workflow: Workflow, owner: ScheduleOwner, publishesToNpm: boolean): string | undefined {
80
+ if (owner.visibility === "private" || !owner.publishes || !publishesToNpm) return "only a public package that publishes to npm releases on a schedule; " + BY_HAND;
81
+ const entries = crons(workflow.document);
82
+ if (entries.length === 1 && entries.every(atMostDaily)) return undefined;
83
+ return 'the release runs more than once a day; keep one cron at a minute and an hour, such as "7 3 * * *"';
84
+ }
85
+
86
+ export function schedules(document: unknown): boolean {
56
87
  const on = isRecord(document) ? document["on"] : undefined;
57
88
  return isRecord(on) ? "schedule" in on : (names(on) ?? []).includes("schedule");
58
89
  }
59
90
 
60
- export function findScheduledReleases(workflows: readonly Workflow[], context: ScheduleContext = EMPTY): readonly ScheduledRelease[] {
91
+ export function releasesOnSchedule(workflows: readonly Workflow[], context: ScheduleContext): readonly Workflow[] {
61
92
  const byPath = new Map(workflows.map((workflow) => [workflow.path, workflow] as const));
62
- return workflows
63
- .filter((workflow) => schedules(workflow.document) && workflowReleases(workflow, context, byPath, [workflow.path]))
64
- .map((workflow) => ({ location: locate(workflow.path, workflow.text.split("\n"))(["on"], "schedule", "on schedule") }));
93
+ return workflows.filter((workflow) => schedules(workflow.document) && workflowRuns(workflow, context, isReleaseCommand, byPath, [workflow.path]));
94
+ }
95
+
96
+ export function findScheduledReleases(workflows: readonly Workflow[], context: ScheduleContext, owner: ScheduleOwner): readonly ScheduledRelease[] {
97
+ const byPath = new Map(workflows.map((workflow) => [workflow.path, workflow] as const));
98
+ const publishesToNpm = workflows.some((workflow) => workflowRuns(workflow, context, isPublishCommand, byPath, [workflow.path]));
99
+ return releasesOnSchedule(workflows, context).flatMap((workflow) => {
100
+ const fault = releaseFault(workflow, owner, publishesToNpm);
101
+ return fault === undefined ? [] : [{ location: locate(workflow.path, workflow.text.split("\n"))(["on"], "schedule", "on schedule"), fault }];
102
+ });
65
103
  }
66
104
 
67
105
  export function formatScheduleReport(scheduled: readonly ScheduledRelease[]): string {
68
- return [
69
- `ci-wiring: ${scheduled.length} workflow(s) release on a schedule:`,
70
- ...scheduled.map(({ location }) => ` ${location}: drop the schedule, so a release runs only when someone starts it`),
71
- ].join("\n");
106
+ return [`ci-wiring: ${scheduled.length} workflow(s) release on a schedule:`, ...scheduled.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
72
107
  }
@@ -0,0 +1,64 @@
1
+ import { runsBin } from "./mutation-job.ts";
2
+ import { releasesOnSchedule, schedules } from "./release-schedule.ts";
3
+ import { jobRuns } from "./runner-policy.ts";
4
+ import { invokes, plainCommand, type Command } from "./shell-command.ts";
5
+ import { isRecord, locate, workflowJobs, type Workflow } from "./workflow-references.ts";
6
+ import type { ScheduleContext, ScheduleOwner } from "./release-schedule.ts";
7
+
8
+ export type ScheduledRun = {
9
+ readonly location: string;
10
+ readonly fault: string;
11
+ };
12
+
13
+ const ADVISORY_BINS = ["checks-advisories", "advisories.ts"];
14
+ const INSTALL = ["bun", "install"];
15
+ const NUMBER = /^\d+$/;
16
+ const WEEKDAY = /^(?:[0-7]|sun|mon|tue|wed|thu|fri|sat)$/i;
17
+ const BY_HAND = "drop the schedule, so it runs only when someone starts it with gh workflow run";
18
+
19
+ function isAdvisoryBin([head = ""]: Command): boolean {
20
+ return ADVISORY_BINS.some((name) => head === name || head.endsWith(`/${name}`));
21
+ }
22
+
23
+ function crons(document: unknown): readonly unknown[] {
24
+ const on = isRecord(document) ? document["on"] : undefined;
25
+ const schedule = isRecord(on) ? on["schedule"] : undefined;
26
+ return Array.isArray(schedule) ? schedule.map((entry) => (isRecord(entry) ? entry["cron"] : undefined)) : [];
27
+ }
28
+
29
+ // Once a week is one minute, one hour and one weekday, on any day of any month.
30
+ export function weekly(cron: unknown): boolean {
31
+ if (typeof cron !== "string") return false;
32
+ const [minute = "", hour = "", day = "", month = "", weekday = "", ...rest] = cron.trim().split(/\s+/);
33
+ return rest.length === 0 && NUMBER.test(minute) && NUMBER.test(hour) && day === "*" && month === "*" && WEEKDAY.test(weekday);
34
+ }
35
+
36
+ function scansAdvisoriesOnly(workflow: Workflow, context: ScheduleContext): boolean {
37
+ const files = (file: string) => context.files.get(file);
38
+ const runs = workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job));
39
+ const scans = runs.filter((run) => runsBin(run, context.scripts, files, isAdvisoryBin, true));
40
+ return scans.length > 0 && runs.every((run) => scans.includes(run) || invokes(plainCommand(run.script), INSTALL));
41
+ }
42
+
43
+ function advisoryFault(workflow: Workflow, owner: ScheduleOwner): string | undefined {
44
+ if (owner.visibility === "private") return "a private repository scans advisories by hand; " + BY_HAND;
45
+ if (!owner.publishes) return "a package.json that sets private publishes nothing to npm, so it scans advisories by hand; " + BY_HAND;
46
+ const entries = crons(workflow.document);
47
+ if (entries.length === 1 && entries.every(weekly)) return undefined;
48
+ return 'the advisory scan runs more than once a week; keep one cron at a minute, an hour and a weekday, such as "15 5 * * 1"';
49
+ }
50
+
51
+ // A workflow that releases on a schedule is the release rule's to judge, so this rule passes over it.
52
+ export function findScheduledRuns(workflows: readonly Workflow[], context: ScheduleContext, owner: ScheduleOwner): readonly ScheduledRun[] {
53
+ const releasing = new Set(releasesOnSchedule(workflows, context));
54
+ return workflows.flatMap((workflow) => {
55
+ if (!schedules(workflow.document) || releasing.has(workflow)) return [];
56
+ const fault = scansAdvisoriesOnly(workflow, context) ? advisoryFault(workflow, owner) : BY_HAND;
57
+ if (fault === undefined) return [];
58
+ return [{ location: locate(workflow.path, workflow.text.split("\n"))(["on"], "schedule", "on schedule"), fault }];
59
+ });
60
+ }
61
+
62
+ export function formatScheduledRunReport(scheduled: readonly ScheduledRun[]): string {
63
+ return [`ci-wiring: ${scheduled.length} workflow(s) run on a schedule:`, ...scheduled.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
64
+ }
@@ -0,0 +1,43 @@
1
+ const HOSTED_FALLBACK = "ubuntu-latest";
2
+ const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
3
+
4
+ export function ciWorkflow(isPrivate: boolean, hasBuild: boolean): string {
5
+ const lines = [
6
+ "name: ci",
7
+ "",
8
+ "on:",
9
+ " pull_request:",
10
+ " types: [opened, synchronize]",
11
+ "",
12
+ "concurrency:",
13
+ " group: ci-${{ github.event.pull_request.number }}",
14
+ " cancel-in-progress: true",
15
+ "",
16
+ "jobs:",
17
+ " checks:",
18
+ " runs-on: " + (isPrivate ? HOSTED_DEFAULT : HOSTED_FALLBACK),
19
+ " timeout-minutes: 30",
20
+ " steps:",
21
+ " - uses: actions/checkout@v5",
22
+ " with:",
23
+ " fetch-depth: 0",
24
+ " - uses: oven-sh/setup-bun@v2",
25
+ " with:",
26
+ " bun-version-file: .bun-version",
27
+ " - run: bun install --frozen-lockfile",
28
+ ];
29
+ if (hasBuild) {
30
+ lines.push(" - run: bun run build", " - run: git diff --exit-code");
31
+ }
32
+ lines.push(" - run: bun run lint", " - run: bun run typecheck", " - run: bun run test");
33
+ lines.push(
34
+ " - id: title",
35
+ " name: lint the title",
36
+ " env:",
37
+ " PR_TITLE: ${{ github.event.pull_request.title }}",
38
+ " GH_TOKEN: ${{ github.token }}",
39
+ " run: bunx checks-commit-title",
40
+ "",
41
+ );
42
+ return lines.join("\n");
43
+ }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { runMain, Usage } from "../core/main.ts";
4
+ import { ciWorkflow } from "./setup-workflow.ts";
4
5
  import { TEMPLATES } from "../docs/doc-templates.ts";
5
6
  import { scriptOptions } from "../testing/test.ts";
6
7
  import { withServiceOverrides, type ServiceOverride } from "../quality/effect-scope.ts";
@@ -10,8 +11,6 @@ import severities from "../quality/presets/effect.language-service.json" with {
10
11
  const NAME = "checks-setup";
11
12
  const USAGE = "usage: " + NAME + " [--private]";
12
13
  const PRIVATE = "--private";
13
- const HOSTED_FALLBACK = "ubuntu-latest";
14
- const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
15
14
  const IGNORE_ENTRY = "node_modules/";
16
15
  const KIT_ID = "@avi2dg/checks";
17
16
 
@@ -116,42 +115,6 @@ const KNIP_CONFIG = [
116
115
  "",
117
116
  ].join("\n");
118
117
 
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
118
  const kitText = Effect.fn("kitText")(function* (file: string) {
156
119
  const system = yield* FileSystem.FileSystem;
157
120
  const path = yield* Path.Path;
@@ -385,7 +348,7 @@ const check = Effect.gen(function* () {
385
348
  : OXLINT_CONFIG;
386
349
  yield* setupTsconfig(root, oxlintText);
387
350
  yield* setupFile(root, "knip.config.ts", KNIP_CONFIG);
388
- yield* setupFile(root, ".github/workflows/ci.yml", workflow(isPrivate, outcome.hasBuild));
351
+ yield* setupFile(root, ".github/workflows/ci.yml", ciWorkflow(isPrivate, outcome.hasBuild));
389
352
  for (const kind of Object.keys(TEMPLATES)) {
390
353
  yield* setupFile(root, "templates/" + kind + ".md", yield* kitText("dist/templates/" + kind + ".md"));
391
354
  }