@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,148 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Duration, Effect, Schema } from "effect";
3
+ import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
4
+ import { releasedVersionOf } from "./release.ts";
5
+ import { buildRelease, commitRelease, decodeRef, hasBranch, planRelease, pointBranch, refused } from "./release-commit.ts";
6
+ import { pushTag, tagHead, taggedOn } from "./release-tagging.ts";
7
+ import { git } from "../core/git.ts";
8
+ import { runMain, Usage } from "../core/main.ts";
9
+
10
+ const NAME = "checks-release-push";
11
+ const LABEL = "release-push";
12
+ const USAGE = "usage: release-push.ts <release-workflow> <workflow>...";
13
+ const UNTAGGED = "tag the release commit with checks-release-tag, whose refusal says what to fix";
14
+ const POLL = Duration.seconds(15);
15
+ const POLLS = 180;
16
+
17
+ const Run = Schema.Struct({ path: Schema.String, status: Schema.String, conclusion: Schema.NullOr(Schema.String), html_url: Schema.String });
18
+ type Run = typeof Run.Type;
19
+ const decodeRuns = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(Run) })));
20
+
21
+ export type Verdict =
22
+ | { readonly kind: "pending"; readonly waiting: readonly string[] }
23
+ | { readonly kind: "passed" }
24
+ | { readonly kind: "failed"; readonly failed: readonly string[] };
25
+
26
+ // GitHub lists the newest run first, so a rerun stands in for the run it repeats.
27
+ export function verdictOf(workflows: readonly string[], runs: readonly Run[]): Verdict {
28
+ const latest = workflows.map((workflow) => {
29
+ const path = `.github/workflows/${workflow}`;
30
+ return { workflow, run: runs.find((run) => run.path === path || run.path.startsWith(`${path}@`)) };
31
+ });
32
+ const failed = latest.flatMap(({ workflow, run }) =>
33
+ run?.status === "completed" && run.conclusion !== "success" ? [`${workflow} concluded ${run.conclusion ?? "with no conclusion"} at ${run.html_url}`] : [],
34
+ );
35
+ if (failed.length > 0) return { kind: "failed", failed };
36
+ const waiting = latest.flatMap(({ workflow, run }) => (run?.status === "completed" ? [] : [workflow]));
37
+ return waiting.length > 0 ? { kind: "pending", waiting } : { kind: "passed" };
38
+ }
39
+
40
+ const POLL_FAILURES = 4;
41
+
42
+ const awaitWorkflows = Effect.fn("awaitWorkflows")(function* (
43
+ workflows: readonly string[],
44
+ sha: string,
45
+ failed: (failed: readonly string[]) => string,
46
+ unfinished: (waiting: readonly string[]) => string,
47
+ ) {
48
+ const runs = `${REPOSITORY}/actions/runs?head_sha=${sha}&event=workflow_dispatch&per_page=100`;
49
+ let waiting = workflows;
50
+ let failures = 0;
51
+ for (let poll = 1; poll <= POLLS; poll += 1) {
52
+ const read = yield* gitHubJson(decodeRuns, "GET", runs).pipe(
53
+ Effect.map((decoded) => ({ done: true as const, runs: decoded.workflow_runs })),
54
+ Effect.catchTag("GitHubFailure", (failure) =>
55
+ failures + 1 >= POLL_FAILURES ? Effect.fail(failure) : Effect.succeed({ done: false as const }),
56
+ ),
57
+ );
58
+ if (!read.done) {
59
+ failures += 1;
60
+ yield* Effect.sleep(POLL);
61
+ continue;
62
+ }
63
+ failures = 0;
64
+ const verdict = verdictOf(workflows, read.runs);
65
+ if (verdict.kind === "passed") return;
66
+ if (verdict.kind === "failed") return yield* refused(failed(verdict.failed));
67
+ waiting = verdict.waiting;
68
+ yield* Effect.sleep(POLL);
69
+ }
70
+ return yield* refused(unfinished(waiting));
71
+ });
72
+
73
+ const awaitChecks = Effect.fn("awaitChecks")(function* (workflows: readonly string[], sha: string) {
74
+ return yield* awaitWorkflows(
75
+ workflows,
76
+ sha,
77
+ (failed) => `the checks failed on ${sha.slice(0, 12)}, so the release stays off the branch: ${failed.join(", ")}`,
78
+ (waiting) => `${waiting.join(", ")} did not finish on ${sha.slice(0, 12)} within ${Duration.format(Duration.times(POLL, POLLS))}, so the release stays off the branch`,
79
+ );
80
+ });
81
+
82
+ const awaitReleaseWorkflow = Effect.fn("awaitReleaseWorkflow")(function* (workflow: string, sha: string) {
83
+ return yield* awaitWorkflows(
84
+ [workflow],
85
+ sha,
86
+ (failed) => `the release workflow failed on ${sha.slice(0, 12)}: ${failed.join(", ")}`,
87
+ () => `${workflow} did not finish on ${sha.slice(0, 12)} within ${Duration.format(Duration.times(POLL, POLLS))}, though the branch already holds the release and the tag exists`,
88
+ );
89
+ });
90
+
91
+ type Landing = "landed" | "moved";
92
+
93
+ // GitHub refuses a branch update that is no fast-forward, which is how a branch that moved during the checks shows.
94
+ // A failed call may still have applied the update, so the branch's head decides what happened.
95
+ const land = Effect.fn("land")(function* (root: string, base: string, head: string, release: string) {
96
+ return yield* gitHubJson(decodeRef, "PATCH", `${REPOSITORY}/git/refs/heads/${base}`, { sha: release, force: false }).pipe(
97
+ Effect.as<Landing>("landed"),
98
+ Effect.catchTag("GitHubFailure", (failure) =>
99
+ git(["ls-remote", "origin", `refs/heads/${base}`], root).pipe(
100
+ Effect.flatMap((listed) =>
101
+ Effect.gen(function* () {
102
+ const now = listed.split("\t")[0];
103
+ if (now === release) return "landed";
104
+ if (now === head) return yield* Effect.fail(failure);
105
+ yield* git(["fetch", "--quiet", "--no-tags", "origin", `refs/heads/${base}`], root);
106
+ const ancestor = yield* git(["merge-base", "--is-ancestor", release, "FETCH_HEAD"], root).pipe(
107
+ Effect.as(true),
108
+ Effect.catchTag("GitFailure", () => Effect.succeed(false)),
109
+ );
110
+ return ancestor ? "landed" : "moved";
111
+ }),
112
+ ),
113
+ ),
114
+ ),
115
+ );
116
+ });
117
+
118
+ const releasePush = Effect.gen(function* () {
119
+ const [publish, ...checks] = process.argv.slice(2);
120
+ if (publish === undefined || checks.length === 0) return yield* new Usage({ message: USAGE });
121
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
122
+ if (releasedVersionOf((yield* git(["log", "-1", "--format=%s", "HEAD"], root)).trim()) !== undefined)
123
+ return yield* tagHead(LABEL, root, publish, LABEL, awaitReleaseWorkflow);
124
+ const plan = yield* planRelease(LABEL, root, UNTAGGED);
125
+ if (plan === undefined) return true;
126
+ const { base, branch, head, next, bumped } = plan;
127
+ const tag = `v${next}`;
128
+ const taken = yield* taggedOn(root, tag);
129
+ if (taken !== undefined) return yield* refused(`${tag} already tags ${taken.slice(0, 12)}, so ${next} cannot be released again`);
130
+ const release = yield* commitRelease(root, head, yield* buildRelease(NAME, root, bumped), next);
131
+ yield* pointBranch(branch, release, yield* hasBranch(root, branch));
132
+ yield* Effect.forEach(checks, (workflow) => dispatch(workflow, branch));
133
+ yield* Console.log(`${LABEL}: dispatched ${checks.join(", ")} on ${branch} to check ${next} at ${release.slice(0, 12)}`);
134
+ yield* awaitChecks(checks, release);
135
+ if ((yield* land(root, base, head, release)) === "moved") {
136
+ yield* Console.log(`${LABEL}: ${base} moved past ${head.slice(0, 12)} while the checks ran, so the run on its new head releases it`);
137
+ return true;
138
+ }
139
+ // The release commit exists only on GitHub, and pushing the tag needs it in the checkout.
140
+ yield* git(["fetch", "--quiet", "--no-tags", "origin", `refs/heads/${base}`], root);
141
+ yield* pushTag(root, release, tag, publish);
142
+ yield* Console.log(`${LABEL}: released ${next} as ${release.slice(0, 12)} on ${base}, tagged it ${tag}, and dispatched ${publish} on it`);
143
+ yield* awaitReleaseWorkflow(publish, release);
144
+ yield* Console.log(`${LABEL}: ${publish} finished on ${tag}`);
145
+ return true;
146
+ });
147
+
148
+ if (import.meta.main) runMain(NAME, releasePush);
@@ -1,64 +1,15 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import { dispatch, GitHubFailure } from "./github.ts";
4
- import { lastTag, releasedVersionOf, runBuild } from "./release.ts";
2
+ import { Effect } from "effect";
3
+ import { tagHead } from "./release-tagging.ts";
5
4
  import { git } from "../core/git.ts";
6
5
  import { runMain, Usage } from "../core/main.ts";
7
6
 
8
- class TagRefused extends Schema.TaggedError<TagRefused>()("TagRefused", {
9
- message: Schema.String,
10
- }) {}
11
-
12
- const NAME = "checks-release-tag";
13
7
  const USAGE = "usage: release-tag.ts <workflow>";
14
- const MANIFEST = "package.json";
15
-
16
- const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
17
-
18
- // An annotated tag lists its own object first and the commit it tags on a `^{}` line after it.
19
- export function taggedCommitOf(listed: string): string | undefined {
20
- const lines = listed.split("\n").filter((line) => line !== "");
21
- const peeled = lines.find((line) => line.endsWith("^{}")) ?? lines[0];
22
- return peeled?.split("\t")[0];
23
- }
24
8
 
25
9
  const releaseTag = Effect.gen(function* () {
26
10
  const [workflow, ...extra] = process.argv.slice(2);
27
11
  if (workflow === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
28
- const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
29
- const [head = "", subject = ""] = (yield* git(["log", "-1", "--format=%H%n%s", "HEAD"], root)).split("\n");
30
- const version = releasedVersionOf(subject);
31
- if (version === undefined) {
32
- yield* Console.log(`release-tag: ${head.slice(0, 12)} is no release commit`);
33
- return true;
34
- }
35
- const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
36
- const { version: held } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => new TagRefused({ message: `${MANIFEST}: ${cause.message}` })));
37
- if (held !== version) return yield* new TagRefused({ message: `${head.slice(0, 12)} releases ${version}, but ${MANIFEST} holds ${held}` });
38
- const tag = `v${version}`;
39
- const tagged = taggedCommitOf(yield* git(["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`], root));
40
- if (tagged === head) {
41
- yield* Console.log(`release-tag: ${tag} already tags ${head.slice(0, 12)}`);
42
- return true;
43
- }
44
- if (tagged !== undefined) return yield* new TagRefused({ message: `${tag} already tags ${tagged.slice(0, 12)}, not ${head.slice(0, 12)}` });
45
- yield* runBuild(root);
46
- const rebuilt = (yield* git(["diff", "--name-only"], root)).trim().split("\n").filter((file) => file !== "");
47
- if (rebuilt.length > 0) {
48
- const since = yield* lastTag(root);
49
- const returned = since === undefined ? "the version it held before this release" : since.slice(1);
50
- return yield* new TagRefused({
51
- message: `the build rewrites ${rebuilt.join(", ")} at ${head.slice(0, 12)}, which the release workflow's build check refuses; open a \`chore: cancel the unpublished ${version}\` pull request that returns ${MANIFEST} to ${returned} and commits what \`bun run build\` then writes to CHANGELOG.md, and the next daily-release run cuts the release again with every change since ${since ?? "the first commit"}`,
52
- });
53
- }
54
- yield* git(["push", "--quiet", "origin", `${head}:refs/tags/${tag}`], root);
55
- yield* dispatch(workflow, tag).pipe(
56
- Effect.mapError(
57
- (failure) => new GitHubFailure({ message: `${failure.message}; ${tag} is pushed, so dispatch the release by hand with \`gh workflow run ${workflow} --ref ${tag}\`` }),
58
- ),
59
- );
60
- yield* Console.log(`release-tag: tagged ${head.slice(0, 12)} as ${tag}, and dispatched ${workflow} on it`);
61
- return true;
12
+ return yield* tagHead("release-tag", (yield* git(["rev-parse", "--show-toplevel"])).trim(), workflow, "daily-release");
62
13
  });
63
14
 
64
- if (import.meta.main) runMain(NAME, releaseTag);
15
+ if (import.meta.main) runMain("checks-release-tag", releaseTag);
@@ -0,0 +1,74 @@
1
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
+ import { dispatch, GitHubFailure } from "./github.ts";
3
+ import { ChildProcessSpawner } from "effect/process";
4
+ import { lastTag, releasedVersionOf, runBuild } from "./release.ts";
5
+ import { git } from "../core/git.ts";
6
+
7
+ class TagRefused extends Schema.TaggedError<TagRefused>()("TagRefused", {
8
+ message: Schema.String,
9
+ }) {}
10
+
11
+ const MANIFEST = "package.json";
12
+
13
+ const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
14
+
15
+ // An annotated tag lists its own object first and the commit it tags on a `^{}` line after it.
16
+ export function taggedCommitOf(listed: string): string | undefined {
17
+ const lines = listed.split("\n").filter((line) => line !== "");
18
+ const peeled = lines.find((line) => line.endsWith("^{}")) ?? lines[0];
19
+ return peeled?.split("\t")[0];
20
+ }
21
+
22
+ export const taggedOn = Effect.fn("taggedOn")(function* (root: string, tag: string) {
23
+ return taggedCommitOf(yield* git(["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`], root));
24
+ });
25
+
26
+ export const pushTag = Effect.fn("pushTag")(function* (root: string, sha: string, tag: string, workflow: string) {
27
+ yield* git(["push", "--quiet", "origin", `${sha}:refs/tags/${tag}`], root);
28
+ yield* dispatch(workflow, tag).pipe(
29
+ Effect.mapError(
30
+ (failure) => new GitHubFailure({ message: `${failure.message}; ${tag} is pushed, so dispatch the release by hand with \`gh workflow run ${workflow} --ref ${tag}\`` }),
31
+ ),
32
+ );
33
+ });
34
+
35
+ export const tagHead = Effect.fn("tagHead")(function* (
36
+ label: string,
37
+ root: string,
38
+ workflow: string,
39
+ cuttingWorkflow: string,
40
+ awaitRelease?: (workflow: string, sha: string) => Effect.Effect<void, { readonly message: string }, ChildProcessSpawner.ChildProcessSpawner>,
41
+ ) {
42
+ const [head = "", subject = ""] = (yield* git(["log", "-1", "--format=%H%n%s", "HEAD"], root)).split("\n");
43
+ const version = releasedVersionOf(subject);
44
+ if (version === undefined) {
45
+ yield* Console.log(`${label}: ${head.slice(0, 12)} is no release commit`);
46
+ return true;
47
+ }
48
+ const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
49
+ const { version: held } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => new TagRefused({ message: `${MANIFEST}: ${cause.message}` })));
50
+ if (held !== version) return yield* new TagRefused({ message: `${head.slice(0, 12)} releases ${version}, but ${MANIFEST} holds ${held}` });
51
+ const tag = `v${version}`;
52
+ const tagged = yield* taggedOn(root, tag);
53
+ if (tagged === head) {
54
+ yield* Console.log(`${label}: ${tag} already tags ${head.slice(0, 12)}`);
55
+ return true;
56
+ }
57
+ if (tagged !== undefined) return yield* new TagRefused({ message: `${tag} already tags ${tagged.slice(0, 12)}, not ${head.slice(0, 12)}` });
58
+ yield* runBuild(root);
59
+ const rebuilt = (yield* git(["diff", "--name-only"], root)).trim().split("\n").filter((file) => file !== "");
60
+ if (rebuilt.length > 0) {
61
+ const since = yield* lastTag(root);
62
+ const returned = since === undefined ? "the version it held before this release" : since.slice(1);
63
+ return yield* new TagRefused({
64
+ message: `the build rewrites ${rebuilt.join(", ")} at ${head.slice(0, 12)}, which the release workflow's build check refuses; open a \`chore: cancel the unpublished ${version}\` pull request that returns ${MANIFEST} to ${returned} and commits what \`bun run build\` then writes to CHANGELOG.md, and the next ${cuttingWorkflow} run cuts the release again with every change since ${since ?? "the first commit"}`,
65
+ });
66
+ }
67
+ yield* pushTag(root, head, tag, workflow);
68
+ yield* Console.log(`${label}: tagged ${head.slice(0, 12)} as ${tag}, and dispatched ${workflow} on it`);
69
+ if (awaitRelease !== undefined) {
70
+ yield* awaitRelease(workflow, head);
71
+ yield* Console.log(`${label}: ${workflow} finished on ${tag}`);
72
+ }
73
+ return true;
74
+ });
@@ -0,0 +1,133 @@
1
+ import { Config, ConfigProvider, Effect, FileSystem, Option, Path, Predicate, Schema } from "effect";
2
+
3
+ const PACK_VARIABLE = "CHECKS_SECRETS_PACK";
4
+
5
+ const OCTET_BITS = 8;
6
+ const OCTET_VALUES = 256;
7
+ const ADDRESS_BITS = 32;
8
+ const ANY_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
9
+ const IPV4_RANGE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/;
10
+
11
+ class PackUnreadable extends Schema.TaggedError<PackUnreadable>()("PackUnreadable", {
12
+ message: Schema.String,
13
+ }) {}
14
+
15
+ type Ipv4Range = { readonly octets: readonly number[]; readonly prefix: number };
16
+
17
+ function ipv4RangeOf(text: string): Option.Option<Ipv4Range> {
18
+ const match = IPV4_RANGE.exec(text);
19
+ if (match === null) return Option.none();
20
+ const [, ...groups] = match;
21
+ const octets = groups.slice(0, 4).map(Number);
22
+ const prefix = groups[4] === undefined ? ADDRESS_BITS : Number(groups[4]);
23
+ if (octets.some((octet) => octet >= OCTET_VALUES) || prefix > ADDRESS_BITS) return Option.none();
24
+ return Option.some({ octets, prefix });
25
+ }
26
+
27
+ // A term reaches gitleaks inside a TOML string, which refuses a raw control character.
28
+ const Term = Schema.String.check(Schema.makeFilter((term: string) => (/\S/.test(term) && !/\p{Cc}/u.test(term)) || "is blank or holds a control character"));
29
+
30
+ const IpRange = Schema.String.check(Schema.makeFilter((text: string) => Option.isSome(ipv4RangeOf(text)) || "is not an IPv4 address or CIDR range"));
31
+
32
+ const Terms = Schema.optionalKey(Schema.Array(Term));
33
+
34
+ const Pack = Schema.Struct({
35
+ names: Terms,
36
+ domains: Terms,
37
+ hosts: Terms,
38
+ runners: Terms,
39
+ ipRanges: Schema.optionalKey(Schema.Array(IpRange)),
40
+ });
41
+
42
+ type Pack = typeof Pack.Type;
43
+
44
+ const decodePack = Schema.decodeUnknownEffect(Schema.fromJsonString(Pack));
45
+
46
+ type Rule = { readonly id: string; readonly description: string };
47
+
48
+ const TERM_RULES = [
49
+ ["names", { id: "private-name", description: "A name form the private pack lists" }],
50
+ ["domains", { id: "private-domain", description: "A domain the private pack lists" }],
51
+ ["hosts", { id: "private-host", description: "A host name the private pack lists" }],
52
+ ["runners", { id: "private-runner", description: "A runner name the private pack lists" }],
53
+ ] as const satisfies readonly (readonly [Exclude<keyof Pack, "ipRanges">, Rule])[];
54
+
55
+ const IP_RANGE_RULE: Rule = { id: "private-ip-range", description: "An address in an IP range the private pack lists" };
56
+
57
+ function escaped(text: string): string {
58
+ return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
59
+ }
60
+
61
+ // RE2's \b is an ASCII word boundary, so it never holds beside a non-ASCII letter or a symbol.
62
+ function boundaryAt(character: string | undefined): string {
63
+ return character !== undefined && /\w/.test(character) ? "\\b" : "";
64
+ }
65
+
66
+ function termPattern(term: string): string {
67
+ const trimmed = term.trim();
68
+ return `${boundaryAt(trimmed[0])}${trimmed.split(/[\s\p{Zs}]+/u).map(escaped).join("[\\s\\p{Zs}]+")}${boundaryAt(trimmed.at(-1))}`;
69
+ }
70
+
71
+ function octetPattern(octet: number, fixedBits: number): string {
72
+ if (fixedBits === OCTET_BITS) return String(octet);
73
+ if (fixedBits === 0) return ANY_OCTET;
74
+ const span = 2 ** (OCTET_BITS - fixedBits);
75
+ const low = octet - (octet % span);
76
+ return `(?:${Array.from({ length: span }, (_, step) => String(low + step)).join("|")})`;
77
+ }
78
+
79
+ function ipRangePattern({ octets, prefix }: Ipv4Range): string {
80
+ const fixedBits = (index: number) => Math.max(0, Math.min(OCTET_BITS, prefix - index * OCTET_BITS));
81
+ return `\\b${octets.map((octet, index) => octetPattern(octet, fixedBits(index))).join("\\.")}\\b`;
82
+ }
83
+
84
+ // JSON writes each string as a TOML basic string reads it, escapes included.
85
+ function ruleToml({ id, description }: Rule, patterns: readonly string[]): readonly string[] {
86
+ if (patterns.length === 0) return [];
87
+ const regex = `(?i)(?:${patterns.join("|")})`;
88
+ return [["[[rules]]", `id = ${JSON.stringify(id)}`, `description = ${JSON.stringify(description)}`, `regex = ${JSON.stringify(regex)}`].join("\n")];
89
+ }
90
+
91
+ function packRules(pack: Pack): string {
92
+ const ranges = (pack.ipRanges ?? []).flatMap((text) => Option.toArray(ipv4RangeOf(text)));
93
+ return [
94
+ ...TERM_RULES.flatMap(([kind, rule]) => ruleToml(rule, (pack[kind] ?? []).map(termPattern))),
95
+ ...ruleToml(IP_RANGE_RULE, ranges.map(ipRangePattern)),
96
+ ].join("\n\n");
97
+ }
98
+
99
+ // The path can carry a user name and the pack carries the terms, so no message here names either.
100
+ const packRulesAt = Effect.fn("packRulesAt")(function* (file: string) {
101
+ const unreadable = (why: string) => new PackUnreadable({ message: `the private pack ${PACK_VARIABLE} names ${why}, so the gate cannot scan for its terms` });
102
+ if (file === "") return yield* new PackUnreadable({ message: `${PACK_VARIABLE} is set but empty, so it names no private pack and the gate cannot scan for its terms` });
103
+ const read = yield* (yield* FileSystem.FileSystem).readFileString(file).pipe(
104
+ Effect.mapError((cause) => unreadable(Predicate.isTagged(cause.reason, "NotFound") ? "no file that exists" : "a file the gate cannot read")),
105
+ );
106
+ const pack = yield* decodePack(read, { onExcessProperty: "error" }).pipe(
107
+ Effect.mapError(() => unreadable("is not the JSON object docs/gates/checks-secrets.md describes")),
108
+ );
109
+ const rules = packRules(pack);
110
+ if (rules === "") return yield* unreadable("lists no term");
111
+ return rules;
112
+ });
113
+
114
+ type ScanConfig = { readonly config: string; readonly notice: string };
115
+
116
+ export const packConfig = Effect.fn("packConfig")(function* (name: string, scratch: string, file: Option.Option<string>) {
117
+ const path = yield* Path.Path;
118
+ const kitConfig = path.join(import.meta.dir, "gitleaks.toml");
119
+ if (Option.isNone(file)) {
120
+ return { config: kitConfig, notice: `${name}: ${PACK_VARIABLE} is unset, so the kit's rules ran without a private pack` } satisfies ScanConfig;
121
+ }
122
+ const rules = yield* packRulesAt(file.value);
123
+ const fs = yield* FileSystem.FileSystem;
124
+ const config = path.join(scratch, "gitleaks.toml");
125
+ yield* fs.writeFileString(config, `${yield* fs.readFileString(kitConfig)}\n${rules}\n`);
126
+ return { config, notice: `${name}: the kit's rules and the private pack ${PACK_VARIABLE} names ran` } satisfies ScanConfig;
127
+ });
128
+
129
+ export const scanConfig = Effect.fn("scanConfig")(function* (name: string, scratch: string) {
130
+ // Config reads an empty value as unset, and a CI secret that is missing expands to one, so the pack would silently drop.
131
+ const file = yield* Config.option(Config.String(PACK_VARIABLE)).parse(ConfigProvider.fromEnv({ preserveEmptyStrings: true }));
132
+ return yield* packConfig(name, scratch, file);
133
+ });
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
+ import { Console, Effect, FileSystem, Schema } from "effect";
3
3
  import { commitOf, git, refArgs } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { cacheRoot } from "../dependencies/cache-root.ts";
6
6
  import { pinnedGitleaks, scanCommits, type Leak } from "./gitleaks.ts";
7
+ import { scanConfig } from "./secrets-pack.ts";
7
8
 
8
9
  const NAME = "secrets";
9
10
  const USAGE = "usage: secrets.ts <ref> | <base-ref> <head-ref>";
@@ -70,16 +71,16 @@ export function report(leaks: readonly Leak[]): string {
70
71
 
71
72
  const secrets = Effect.gen(function* () {
72
73
  const fs = yield* FileSystem.FileSystem;
73
- const path = yield* Path.Path;
74
74
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
75
75
  const logOptions = yield* logOptionsOf(process.argv.slice(2), root);
76
- const binary = yield* pinnedGitleaks(yield* cacheRoot());
77
- const config = path.join(import.meta.dir, "gitleaks.toml");
78
76
  const ignoreDir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-" });
77
+ const { config, notice } = yield* scanConfig(NAME, yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-pack-" }));
78
+ const binary = yield* pinnedGitleaks(yield* cacheRoot());
79
79
  const gitDir = (yield* git(["rev-parse", "--absolute-git-dir"], root)).trim();
80
80
  const reported = yield* scanCommits({ binary, gitDir, logOptions, config, ignoreDir });
81
81
  const leaks = yield* Effect.forEach(reported, (leak) => atRepositoryPath(leak, root));
82
82
  yield* Console.log(report(leaks));
83
+ yield* Console.log(notice);
83
84
  return leaks.length === 0;
84
85
  }).pipe(Effect.scoped);
85
86