@avi2dg/checks 0.52.1 → 0.53.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 +5 -574
- package/docs/bins.md +4 -0
- package/docs/configs/commit-messages.md +5 -4
- package/docs/design.md +28 -6
- package/docs/gates/checks-ci-wiring.md +1 -1
- package/docs/gates/checks-commit-identity.md +1 -1
- package/docs/gates/checks-commit-title.md +5 -4
- package/docs/gates/checks-copy.md +2 -2
- package/docs/gates/checks-night-run.md +4 -4
- package/docs/gates/checks-pr-text.md +90 -0
- package/docs/gates/checks-release-notes.md +6 -4
- package/docs/gates/checks-release-pr.md +3 -4
- package/docs/gates/checks-release-publish.md +63 -0
- package/docs/gates/checks-release-push.md +153 -0
- package/docs/gates/checks-release-report.md +2 -1
- package/docs/gates/checks-release-tag.md +2 -0
- package/docs/gates/checks-secrets.md +90 -1
- package/docs/gates/checks-setup.md +76 -0
- package/docs/gates/checks-vendor.md +3 -3
- package/package.json +12 -1
- package/src/delivery/commit-title.ts +14 -7
- package/src/delivery/gitleaks.ts +24 -9
- package/src/delivery/pr-text.ts +77 -0
- package/src/delivery/release-commit.ts +152 -0
- package/src/delivery/release-pr.ts +9 -132
- package/src/delivery/release-publish.ts +73 -0
- package/src/delivery/release-push.ts +148 -0
- package/src/delivery/release-tag.ts +4 -53
- package/src/delivery/release-tagging.ts +74 -0
- package/src/delivery/secrets-pack.ts +133 -0
- package/src/delivery/secrets.ts +5 -4
- package/src/delivery/setup.ts +399 -0
- package/src/delivery/workflow-references.ts +2 -2
|
@@ -5,6 +5,7 @@ audience: consumers
|
|
|
5
5
|
# checks-secrets
|
|
6
6
|
|
|
7
7
|
`checks-secrets` is the gate that fails a range whose commits add a secret, such as an API token, a private key, a VPN key or a proxy link that carries its credential.
|
|
8
|
+
A private pack adds terms no public file may carry, such as a person's name, an employer's domain, host names and IP ranges.
|
|
8
9
|
|
|
9
10
|
## What it checks
|
|
10
11
|
|
|
@@ -39,12 +40,90 @@ These rules do not catch:
|
|
|
39
40
|
No setting accepts a finding.
|
|
40
41
|
It ignores a repository's `.gitleaks.toml`, its `.gitleaksignore` and every `gitleaks:allow` comment.
|
|
41
42
|
It skips the paths gitleaks' default config skips, such as lockfiles, images and `node_modules/`.
|
|
43
|
+
It scans what a commit adds to its files, and not its message or its author, which [checks-commit-identity](checks-commit-identity.md) judges.
|
|
44
|
+
|
|
45
|
+
## The private pack
|
|
46
|
+
|
|
47
|
+
The private pack is a JSON file of terms that lives outside every public repository, and `CHECKS_SECRETS_PACK` names its path.
|
|
48
|
+
The kit ships no pack and no term, so a public repository can run the pack without carrying what it hides.
|
|
49
|
+
With `CHECKS_SECRETS_PACK` unset the gate runs the kit's rules alone, exactly as it does without a pack, and its last line says so.
|
|
50
|
+
With it set the gate adds a rule for each kind of term the pack lists, and fails a range that adds any of them.
|
|
51
|
+
|
|
52
|
+
Each key is optional, and each holds a list of strings:
|
|
53
|
+
|
|
54
|
+
| Key | Rule | What it matches |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `names` | `private-name` | a person's name form, such as a full name, a handle or a login |
|
|
57
|
+
| `domains` | `private-domain` | an employer's or any other domain, also inside an email address or a subdomain |
|
|
58
|
+
| `hosts` | `private-host` | a host name |
|
|
59
|
+
| `runners` | `private-runner` | a CI runner's name |
|
|
60
|
+
| `ipRanges` | `private-ip-range` | an IPv4 address inside a CIDR range, such as `192.0.2.0/24`, or a single IPv4 address |
|
|
61
|
+
|
|
62
|
+
A pack with obviously fake terms reads like this:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"names": ["Pat Example", "pexample"],
|
|
67
|
+
"domains": ["employer.example"],
|
|
68
|
+
"hosts": ["build-01.corp.example"],
|
|
69
|
+
"runners": ["runner-blue-7"],
|
|
70
|
+
"ipRanges": ["192.0.2.0/24", "198.51.100.7"]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A term matches in any case, and the spaces inside it match any run of ASCII whitespace or Unicode space separators.
|
|
75
|
+
A term's end that is an ASCII letter, digit or underscore matches only at a word boundary, so `pexample` does not match inside `pexamples`.
|
|
76
|
+
An address matches only as a whole, so `192.0.2.0/24` does not match `1192.0.2.5`.
|
|
77
|
+
An IPv6 range has no key, so a pack lists its prefix under `hosts` as its addresses are written, such as `2001:db8:`.
|
|
78
|
+
The gitleaks default allowlist skips paths such as lockfiles, go.mod and go.sum, images and documents such as PDFs.
|
|
79
|
+
It also skips node_modules and some Go and Ruby vendor paths, so the pack terms are not searched there.
|
|
80
|
+
A lockfile's resolved URLs can carry a private registry host, so keep such a host out of lockfiles, go.mod and go.sum by other means.
|
|
81
|
+
Commit messages are not scanned by the pack, and checks-commit-identity covers only the author, the committer and Co-authored-by trailers.
|
|
82
|
+
|
|
83
|
+
The gate exits 2, and scans nothing, when `CHECKS_SECRETS_PACK` is set but the pack cannot serve:
|
|
84
|
+
|
|
85
|
+
- the variable is set to an empty value, as a CI secret that is missing expands
|
|
86
|
+
- the file is missing, unreadable or a directory
|
|
87
|
+
- the file is not JSON or holds a key the table does not name
|
|
88
|
+
- a term is blank or holds a control character, or a range is not IPv4
|
|
89
|
+
- the pack lists no term at all
|
|
90
|
+
|
|
91
|
+
No report or error prints a matched term text or the pack path, because a public CI log would publish either.
|
|
92
|
+
A finding names its file, its line and its rule, and the description of each pack rule says only which kind of term matched.
|
|
93
|
+
A finding location prints as it stands, such as a file name or a link.
|
|
94
|
+
So a term inside a file name or a repository name shows there.
|
|
95
|
+
That location is already public in the scanned commit.
|
|
96
|
+
|
|
97
|
+
The pack lives where only its owner reads it.
|
|
98
|
+
On a laptop it is a file outside every checkout, and the shell profile exports its path:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
export CHECKS_SECRETS_PACK="$HOME/.config/checks/secrets-pack.json"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
In GitHub Actions a repository or organisation secret holds the JSON, and the job writes it to a file before the gate runs:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
- name: write the private pack
|
|
108
|
+
env:
|
|
109
|
+
PACK: ${{ secrets.CHECKS_SECRETS_PACK }}
|
|
110
|
+
run: |
|
|
111
|
+
printf '%s' "$PACK" > "$RUNNER_TEMP/secrets-pack.json"
|
|
112
|
+
echo "CHECKS_SECRETS_PACK=$RUNNER_TEMP/secrets-pack.json" >> "$GITHUB_ENV"
|
|
113
|
+
- run: bun run lint
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A fork's `pull_request` run gets no secret, so the file is empty and the gate exits 2 rather than pass without the pack.
|
|
117
|
+
Unlike [checks-pr-text](checks-pr-text.md), this gate has no skip: a job that writes the pack from an unset secret exits 2.
|
|
118
|
+
[checks-pr-text](checks-pr-text.md) scans a pull request's title, body and comments with the same pack.
|
|
42
119
|
|
|
43
120
|
## What it reads
|
|
44
121
|
|
|
45
122
|
It reads the commits of the range from git, not the working tree.
|
|
46
123
|
It needs git 2.36 or newer, which diffs a merge against the merge git would make on its own, and it exits 2 on an older git.
|
|
47
124
|
It reads its rules from `src/delivery/gitleaks.toml` in the installed package.
|
|
125
|
+
It reads the path of the private pack from `CHECKS_SECRETS_PACK`.
|
|
126
|
+
It writes the kit's rules and the pack's into one config in a temporary directory it deletes when the scan ends.
|
|
48
127
|
|
|
49
128
|
It runs the gitleaks release `src/delivery/gitleaks.ts` pins, by the SHA-256 of each platform's archive and of the binary inside it.
|
|
50
129
|
On first use it downloads the archive from the scanner's GitHub release and unpacks the binary into `~/.cache/avi2dg-checks/gitleaks/<version>/`.
|
|
@@ -69,7 +148,7 @@ With one it scans that commit alone.
|
|
|
69
148
|
| --- | --- |
|
|
70
149
|
| 0 | no commit in the range adds a secret |
|
|
71
150
|
| 1 | a commit in the range adds a secret |
|
|
72
|
-
| 2 | a ref does not resolve, or a commit it scans is an octopus merge, or no verified scanner is at hand, or git is older than 2.36, or gitleaks fails |
|
|
151
|
+
| 2 | a ref does not resolve, or a commit it scans is an octopus merge, or no verified scanner is at hand, or git is older than 2.36, or gitleaks fails, or `CHECKS_SECRETS_PACK` is set and the pack cannot serve |
|
|
73
152
|
|
|
74
153
|
## Sample output
|
|
75
154
|
|
|
@@ -78,6 +157,15 @@ secrets: the range adds 3 secret(s); put a placeholder such as <private-key> in
|
|
|
78
157
|
vpn/links.txt:1 proxy-userinfo-link in e19e45c2: A proxy share link that carries its credential before the @, such as vless://, trojan://, socks5:// or wireguard://
|
|
79
158
|
vpn/wg0.conf:2 wireguard-key in e19e45c2: A WireGuard or AmneziaWG private or pre-shared key
|
|
80
159
|
vpn/wg0.conf:7 wireguard-key in e19e45c2: A WireGuard or AmneziaWG private or pre-shared key
|
|
160
|
+
secrets: CHECKS_SECRETS_PACK is unset, so the kit's rules ran without a private pack
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
With a pack set, a finding of the pack names the kind of term and never the term:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
secrets: the range adds 1 secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:
|
|
167
|
+
docs/setup.md:12 private-domain in 4f1c09ab: A domain the private pack lists
|
|
168
|
+
secrets: the kit's rules and the private pack CHECKS_SECRETS_PACK names ran
|
|
81
169
|
```
|
|
82
170
|
|
|
83
171
|
The report never prints a secret.
|
|
@@ -90,4 +178,5 @@ A local `bun run lint` runs it over the commits the branch adds, so it fails bef
|
|
|
90
178
|
## Related topics
|
|
91
179
|
|
|
92
180
|
- [checks-lint](checks-lint.md)
|
|
181
|
+
- [checks-pr-text](checks-pr-text.md)
|
|
93
182
|
- [Why it is shaped this way](../design.md)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
audience: consumers
|
|
4
|
+
---
|
|
5
|
+
# checks-setup
|
|
6
|
+
|
|
7
|
+
`checks-setup` writes the kit config, the CI workflow and the doc templates into a repository that does not hold them yet.
|
|
8
|
+
|
|
9
|
+
## What it checks
|
|
10
|
+
|
|
11
|
+
A run writes each missing file and brings the files it finds up to what the checks need.
|
|
12
|
+
It adds the `node_modules/` line to `.gitignore` when the file lacks it.
|
|
13
|
+
It adds the kit entry and any missing script to a `package.json` it finds.
|
|
14
|
+
It replaces the kit version pin with its own version, and a `--private` run sets `private` to `true`.
|
|
15
|
+
It stops when a `lint` script it finds does not run oxlint and the layout check, and names the script to fix.
|
|
16
|
+
It stops when a `typecheck` script it finds does not run the Effect diagnostics, and names the script to fix.
|
|
17
|
+
It stops when a `test` script it finds is not the kit suite, and names the script to fix.
|
|
18
|
+
It copies the fleet pin into `.bun-version` and the preset into `bunfig.toml`.
|
|
19
|
+
It writes `oxlint.config.ts`, `tsconfig.json` and `knip.config.ts` from the kit builders, with Effect rules on.
|
|
20
|
+
It writes the Effect paths of the `oxlint.config.ts` it finds or writes into the language service entry of `tsconfig.json`.
|
|
21
|
+
The pair passes `checks-effect-scope --check` from the first write.
|
|
22
|
+
It writes `package.json` with the kit in `devDependencies` and with `lint`, `typecheck` and `test` scripts.
|
|
23
|
+
It writes `.github/workflows/ci.yml`, which installs with `bun install --frozen-lockfile` and then runs the `build` script when the manifest holds one.
|
|
24
|
+
The workflow always runs `bun run lint`, `bun run typecheck` and `bun run test`.
|
|
25
|
+
A `build` script there also brings a `git diff --exit-code` step.
|
|
26
|
+
Other scripts a `package.json` it finds already holds stay, but the workflow never schedules them.
|
|
27
|
+
The workflow always ends with a `bunx checks-commit-title` step for the pull request title.
|
|
28
|
+
A `--private` run pins each job to `${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}`.
|
|
29
|
+
Setting the `CI_RUNS_ON` variable to a self-hosted runner label then lands the jobs there.
|
|
30
|
+
A public run pins each job to `ubuntu-latest`.
|
|
31
|
+
It copies each kit template under `dist/templates/` into `templates/`, so a new doc file starts from a copy there.
|
|
32
|
+
It names every file it wrote and every file it kept.
|
|
33
|
+
It tells the owner to declare the repository owners in `package.json` `author` or `contributors` before the first commit.
|
|
34
|
+
|
|
35
|
+
## What it reads
|
|
36
|
+
|
|
37
|
+
It reads the working tree, which is the repository it sets up.
|
|
38
|
+
It reads a `package.json` it finds for the fields and scripts it keeps, replaces or refuses.
|
|
39
|
+
It reads the kit install for `.bun-version`, `bunfig.toml`, its version and each template.
|
|
40
|
+
|
|
41
|
+
## Arguments
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
checks-setup [--private]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
With no arguments the repository is public, and its jobs run on `ubuntu-latest`.
|
|
48
|
+
With `--private` the jobs read the `CI_RUNS_ON` variable, and the run names the value to set.
|
|
49
|
+
|
|
50
|
+
## Exit codes
|
|
51
|
+
|
|
52
|
+
| Code | When |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| 0 | every file is written or already there |
|
|
55
|
+
| 2 | an argument is unknown, a file it needs cannot be read, or a `lint`, `typecheck` or `test` script it finds does not run the checks |
|
|
56
|
+
|
|
57
|
+
## Sample output
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
checks-setup: wrote .bun-version
|
|
61
|
+
checks-setup: kept oxlint.config.ts, already there
|
|
62
|
+
checks-setup: declare the repository owners in package.json author or contributors before the first commit
|
|
63
|
+
checks-setup: set the CI_RUNS_ON variable to a self-hosted runner label so the new jobs land there
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## When it runs
|
|
67
|
+
|
|
68
|
+
A repository owner runs it once in a new repository, before the first install.
|
|
69
|
+
Declare the repository owners in `package.json` `author` or `contributors` before the first commit.
|
|
70
|
+
Running it again with the same kit version changes nothing.
|
|
71
|
+
A later kit release arrives through [Upgrade the kit](../how-to/upgrade-the-kit.md).
|
|
72
|
+
|
|
73
|
+
## Related topics
|
|
74
|
+
|
|
75
|
+
- [Set up a repository](../how-to/set-up-a-repository.md)
|
|
76
|
+
- [Upgrade the kit](../how-to/upgrade-the-kit.md)
|
|
@@ -60,19 +60,19 @@ With no arguments it pins nothing.
|
|
|
60
60
|
## Sample output
|
|
61
61
|
|
|
62
62
|
```
|
|
63
|
-
checks-vendor: cloned effect
|
|
63
|
+
checks-vendor: cloned effect@<version> from https://github.com/Effect-TS/effect.git and linked repos/effect
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
A fresh fetch reports the tag it cloned and the link it made.
|
|
67
67
|
|
|
68
68
|
```
|
|
69
|
-
checks-vendor: repos/effect still holds effect
|
|
69
|
+
checks-vendor: repos/effect still holds effect@<version>, verified against its recorded commit
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
A later run reports the link it kept.
|
|
73
73
|
|
|
74
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
|
|
75
|
+
checks-vendor: found 1 path writable by its owner, starting with /home/runner/.cache/avi2dg-checks/repos/github.com/Effect-TS/effect/effect@<version>, and froze the tree again, so repos/effect still holds effect@<version>, verified against its recorded commit
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
A run that found an owner write bit reports how many paths carried one and the first of them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "avi2d",
|
|
@@ -39,8 +39,12 @@
|
|
|
39
39
|
"src/delivery/release-notes.ts",
|
|
40
40
|
"src/delivery/release-report.ts",
|
|
41
41
|
"src/delivery/release.ts",
|
|
42
|
+
"src/delivery/release-commit.ts",
|
|
42
43
|
"src/delivery/release-pr.ts",
|
|
44
|
+
"src/delivery/release-publish.ts",
|
|
45
|
+
"src/delivery/release-push.ts",
|
|
43
46
|
"src/delivery/release-tag.ts",
|
|
47
|
+
"src/delivery/release-tagging.ts",
|
|
44
48
|
"src/delivery/github.ts",
|
|
45
49
|
"src/testing/test-layout.ts",
|
|
46
50
|
"src/testing/test.ts",
|
|
@@ -70,6 +74,9 @@
|
|
|
70
74
|
"src/delivery/mutation-job.ts",
|
|
71
75
|
"src/delivery/runner-policy.ts",
|
|
72
76
|
"src/delivery/secrets.ts",
|
|
77
|
+
"src/delivery/secrets-pack.ts",
|
|
78
|
+
"src/delivery/pr-text.ts",
|
|
79
|
+
"src/delivery/setup.ts",
|
|
73
80
|
"src/delivery/gitleaks.ts",
|
|
74
81
|
"src/delivery/gitleaks.toml",
|
|
75
82
|
"src/quality/comment-matchers.ts",
|
|
@@ -157,6 +164,8 @@
|
|
|
157
164
|
"checks-release-notes": "src/delivery/release-notes.ts",
|
|
158
165
|
"checks-release-report": "src/delivery/release-report.ts",
|
|
159
166
|
"checks-release-pr": "src/delivery/release-pr.ts",
|
|
167
|
+
"checks-release-publish": "src/delivery/release-publish.ts",
|
|
168
|
+
"checks-release-push": "src/delivery/release-push.ts",
|
|
160
169
|
"checks-release-tag": "src/delivery/release-tag.ts",
|
|
161
170
|
"checks-lint-coverage": "src/quality/lint-coverage.sh",
|
|
162
171
|
"checks-test-layout": "src/testing/test-layout.ts",
|
|
@@ -184,6 +193,8 @@
|
|
|
184
193
|
"checks-vendor": "src/dependencies/vendor.ts",
|
|
185
194
|
"checks-advisories": "src/dependencies/advisories.ts",
|
|
186
195
|
"checks-secrets": "src/delivery/secrets.ts",
|
|
196
|
+
"checks-pr-text": "src/delivery/pr-text.ts",
|
|
197
|
+
"checks-setup": "src/delivery/setup.ts",
|
|
187
198
|
"checks-frontend-syntax": "src/quality/frontend-syntax.ts",
|
|
188
199
|
"checks-no-plain-js": "src/quality/no-plain-js.ts",
|
|
189
200
|
"checks-browser": "src/quality/browser/browser.ts",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
-
import { collect } from "../core/git.ts";
|
|
3
|
+
import { collect, git } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
|
|
6
6
|
const NAME = "commit-title";
|
|
@@ -41,22 +41,29 @@ const dispatchedTitle = Effect.fn("dispatchedTitle")(function* () {
|
|
|
41
41
|
"api",
|
|
42
42
|
`repos/${repository}/pulls?head=${owner}:${ref}&state=open`,
|
|
43
43
|
"--jq",
|
|
44
|
-
`if length == 1 then .[0].title else error("
|
|
44
|
+
`if length == 1 then .[0].title elif length == 0 then empty else error("more than one open pull request heads the branch") end`,
|
|
45
45
|
]);
|
|
46
46
|
const title = found.stdout.trim();
|
|
47
|
-
if (found.exitCode !== 0 || title === "
|
|
47
|
+
if (found.exitCode !== 0 || title === "null") {
|
|
48
48
|
const report = found.stderr.trim() === "" ? found.stdout.trim() : found.stderr.trim();
|
|
49
49
|
if (report !== "") yield* Console.error(report);
|
|
50
50
|
return undefined;
|
|
51
51
|
}
|
|
52
|
-
return title;
|
|
52
|
+
if (title !== "") return landedTitle(title);
|
|
53
|
+
// With no pull request the head commit lands as it is, so its own subject is the one to lint.
|
|
54
|
+
return (yield* git(["log", "-1", "--format=%s", "HEAD"])).trim();
|
|
53
55
|
});
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
// GitHub appends the pull request number to the squashed subject.
|
|
58
|
+
function landedTitle(title: string): string {
|
|
59
|
+
return `${title} (#0000)`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lintTitle = Effect.fn("lintTitle")(function* (subject: string) {
|
|
56
63
|
const system = yield* FileSystem.FileSystem;
|
|
57
64
|
const path = yield* Path.Path;
|
|
58
65
|
const file = path.join(yield* system.makeTempDirectoryScoped({ prefix: "checks-commit-title-" }), "pr-title");
|
|
59
|
-
yield* system.writeFileString(file,
|
|
66
|
+
yield* system.writeFileString(file, subject);
|
|
60
67
|
// A title opening with the git comment character lints as empty without this: commentChar moves off the hash.
|
|
61
68
|
const linted = yield* collect(process.execPath, [yield* cliOf(), "--config", yield* configOf(), "--edit", file], undefined, {
|
|
62
69
|
env: { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.commentChar", GIT_CONFIG_VALUE_0: "\x01" },
|
|
@@ -68,7 +75,7 @@ const lintTitle = Effect.fn("lintTitle")(function* (title: string) {
|
|
|
68
75
|
|
|
69
76
|
const check = Effect.scoped(Effect.gen(function* () {
|
|
70
77
|
const direct = yield* Config.String("PR_TITLE").pipe(Config.withDefault(""));
|
|
71
|
-
if (direct !== "") return yield* lintTitle(direct);
|
|
78
|
+
if (direct !== "") return yield* lintTitle(landedTitle(direct));
|
|
72
79
|
const event = yield* Config.String("GITHUB_EVENT_NAME").pipe(Config.withDefault(""));
|
|
73
80
|
if (event === "workflow_dispatch") {
|
|
74
81
|
const title = yield* dispatchedTitle();
|
package/src/delivery/gitleaks.ts
CHANGED
|
@@ -65,23 +65,27 @@ export type Leak = typeof Leak.Type;
|
|
|
65
65
|
|
|
66
66
|
const decodeGitleaksReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Leak)));
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
type Scanner = {
|
|
69
69
|
readonly binary: string;
|
|
70
|
-
readonly gitDir: string;
|
|
71
|
-
readonly logOptions: string;
|
|
72
70
|
readonly config: string;
|
|
73
71
|
readonly ignoreDir: string;
|
|
74
72
|
};
|
|
75
73
|
|
|
74
|
+
export type Scan = Scanner & {
|
|
75
|
+
readonly gitDir: string;
|
|
76
|
+
readonly logOptions: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type DirectoryScan = Scanner & {
|
|
80
|
+
readonly dir: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
76
83
|
// gitleaks exits 1 both on a leak and on a fatal error, so it exits 0 on a leak here and the report alone tells them apart.
|
|
77
84
|
// It exits 0 with an empty report when git log fails, and logs only errors here, so any line it logs fails the scan.
|
|
78
|
-
// It reads a .gitleaksignore at its source whatever --gitleaks-ignore-path says, so
|
|
79
|
-
|
|
85
|
+
// It reads a .gitleaksignore at its source whatever --gitleaks-ignore-path says, so a source holds no file a repository or an author can name.
|
|
86
|
+
const scanned = Effect.fn("scanned")(function* (source: readonly string[], { binary, config, ignoreDir }: Scanner) {
|
|
80
87
|
const args = [
|
|
81
|
-
|
|
82
|
-
gitDir,
|
|
83
|
-
"--log-opts",
|
|
84
|
-
logOptions,
|
|
88
|
+
...source,
|
|
85
89
|
"--config",
|
|
86
90
|
config,
|
|
87
91
|
"--gitleaks-ignore-path",
|
|
@@ -109,3 +113,14 @@ export const scanCommits = Effect.fn("scanCommits")(function* ({ binary, gitDir,
|
|
|
109
113
|
Effect.mapError((cause) => new GitleaksError({ message: `cannot read the report gitleaks wrote: ${cause.message}` })),
|
|
110
114
|
);
|
|
111
115
|
});
|
|
116
|
+
|
|
117
|
+
type Scanned = ReturnType<typeof scanned>;
|
|
118
|
+
|
|
119
|
+
// The source is the git directory, where no tracked file lands.
|
|
120
|
+
export function scanCommits({ gitDir, logOptions, ...scanner }: Scan): Scanned {
|
|
121
|
+
return scanned(["git", gitDir, "--log-opts", logOptions], scanner);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function scanDirectory({ dir, ...scanner }: DirectoryScan): Scanned {
|
|
125
|
+
return scanned(["dir", dir], scanner);
|
|
126
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { runMain, Usage } from "../core/main.ts";
|
|
4
|
+
import { cacheRoot } from "../dependencies/cache-root.ts";
|
|
5
|
+
import { pinnedGitleaks, scanDirectory, type Leak } from "./gitleaks.ts";
|
|
6
|
+
import { gh, gitHubJson, GitHubFailure, REPOSITORY } from "./github.ts";
|
|
7
|
+
import { scanConfig } from "./secrets-pack.ts";
|
|
8
|
+
|
|
9
|
+
const NAME = "pr-text";
|
|
10
|
+
const USAGE = "usage: pr-text.ts <pull-request-number>";
|
|
11
|
+
|
|
12
|
+
const PullRequest = Schema.Struct({ title: Schema.String, body: Schema.NullOr(Schema.String) });
|
|
13
|
+
|
|
14
|
+
const Comment = Schema.Struct({ id: Schema.Int, html_url: Schema.String, body: Schema.NullOr(Schema.String) });
|
|
15
|
+
|
|
16
|
+
const decodePullRequest = Schema.decodeUnknownEffect(Schema.fromJsonString(PullRequest));
|
|
17
|
+
|
|
18
|
+
const decodeCommentPages = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.Array(Comment))));
|
|
19
|
+
|
|
20
|
+
export type Text = { readonly file: string; readonly place: string; readonly body: string };
|
|
21
|
+
|
|
22
|
+
const COMMENT_KINDS = [
|
|
23
|
+
{ file: "issue-comment", parent: "issues", list: "comments" },
|
|
24
|
+
{ file: "review", parent: "pulls", list: "reviews" },
|
|
25
|
+
{ file: "review-comment", parent: "pulls", list: "comments" },
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
const numberOf = Effect.fn("numberOf")(function* (args: readonly string[]) {
|
|
29
|
+
const [number, ...rest] = args;
|
|
30
|
+
if (number === undefined || rest.length > 0 || !/^[1-9]\d*$/.test(number)) return yield* new Usage({ message: USAGE });
|
|
31
|
+
return number;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const commentsOf = Effect.fn("commentsOf")(function* (path: string) {
|
|
35
|
+
const pages = yield* gh(["api", "--paginate", "--slurp", path], `gh api --paginate ${path}`);
|
|
36
|
+
return (yield* decodeCommentPages(pages).pipe(Effect.mapError((cause) => new GitHubFailure({ message: `gh api ${path} answered ${cause.message}` })))).flat();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// The title and the body come from the API rather than the event, which carries them as they stood when the event fired.
|
|
40
|
+
const textsOf = Effect.fn("textsOf")(function* (number: string) {
|
|
41
|
+
const { title, body } = yield* gitHubJson(decodePullRequest, "GET", `${REPOSITORY}/pulls/${number}`);
|
|
42
|
+
const comments = yield* Effect.forEach(COMMENT_KINDS, (kind) =>
|
|
43
|
+
commentsOf(`${REPOSITORY}/${kind.parent}/${number}/${kind.list}`).pipe(
|
|
44
|
+
Effect.map((found) => found.map(({ id, html_url, body: text }) => ({ file: `${kind.file}-${id}`, place: html_url, body: text ?? "" }))),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
return [{ file: "title", place: "the title", body: title }, { file: "body", place: "the body", body: body ?? "" }, ...comments.flat()] satisfies readonly Text[];
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function report(number: string, texts: readonly Text[], leaks: readonly Leak[]): string {
|
|
51
|
+
if (leaks.length === 0) return `${NAME}: pull request ${number} carries no secret and no private term`;
|
|
52
|
+
const order = new Map(texts.map(({ file }, index) => [file, index]));
|
|
53
|
+
const placeOf = new Map(texts.map(({ file, place }) => [file, place]));
|
|
54
|
+
const fileOf = (leak: Leak) => leak.File.slice(leak.File.lastIndexOf("/") + 1);
|
|
55
|
+
const sorted = leaks.toSorted((left, right) => (order.get(fileOf(left)) ?? 0) - (order.get(fileOf(right)) ?? 0) || left.StartLine - right.StartLine || left.RuleID.localeCompare(right.RuleID));
|
|
56
|
+
return [
|
|
57
|
+
`${NAME}: pull request ${number} carries ${leaks.length} secret(s) or private term(s); delete each from the text it names, and delete the revision GitHub keeps in that text's edit history:`,
|
|
58
|
+
...sorted.map((leak) => ` ${placeOf.get(fileOf(leak)) ?? fileOf(leak)}:${leak.StartLine} ${leak.RuleID}: ${leak.Description}`),
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const prText = Effect.gen(function* () {
|
|
63
|
+
const fs = yield* FileSystem.FileSystem;
|
|
64
|
+
const path = yield* Path.Path;
|
|
65
|
+
const number = yield* numberOf(process.argv.slice(2));
|
|
66
|
+
const { config, notice } = yield* scanConfig(NAME, yield* fs.makeTempDirectoryScoped({ prefix: "checks-pr-text-pack-" }));
|
|
67
|
+
const texts = yield* textsOf(number);
|
|
68
|
+
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-pr-text-" });
|
|
69
|
+
yield* Effect.forEach(texts, ({ file, body }) => fs.writeFileString(path.join(dir, file), body));
|
|
70
|
+
const ignoreDir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-pr-text-ignore-" });
|
|
71
|
+
const leaks = yield* scanDirectory({ binary: yield* pinnedGitleaks(yield* cacheRoot()), dir, config, ignoreDir });
|
|
72
|
+
yield* Console.log(report(number, texts, leaks));
|
|
73
|
+
yield* Console.log(notice);
|
|
74
|
+
return leaks.length === 0;
|
|
75
|
+
}).pipe(Effect.scoped);
|
|
76
|
+
|
|
77
|
+
if (import.meta.main) runMain(NAME, prText);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import * as Base64 from "effect/encoding/Base64";
|
|
3
|
+
import { gitHubJson, REPOSITORY } from "./github.ts";
|
|
4
|
+
import { nextVersion, type Pending, readPending, releaseTitle, runBuild, withVersion } from "./release.ts";
|
|
5
|
+
import { git } from "../core/git.ts";
|
|
6
|
+
|
|
7
|
+
export class ReleaseRefused extends Schema.TaggedError<ReleaseRefused>()("ReleaseRefused", {
|
|
8
|
+
message: Schema.String,
|
|
9
|
+
}) {}
|
|
10
|
+
|
|
11
|
+
type Staged =
|
|
12
|
+
| { readonly kind: "written"; readonly path: string; readonly mode: string }
|
|
13
|
+
| { readonly kind: "deleted"; readonly path: string; readonly mode: string };
|
|
14
|
+
|
|
15
|
+
type Read =
|
|
16
|
+
| { readonly kind: "written"; readonly path: string; readonly mode: string; readonly content: string }
|
|
17
|
+
| { readonly kind: "deleted"; readonly path: string; readonly mode: string };
|
|
18
|
+
|
|
19
|
+
type Built = {
|
|
20
|
+
readonly tree: string;
|
|
21
|
+
readonly files: readonly Read[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const MANIFEST = "package.json";
|
|
25
|
+
const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
|
|
26
|
+
const RAW_ENTRY = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/;
|
|
27
|
+
|
|
28
|
+
const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
|
|
29
|
+
const decodeSha = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ sha: Schema.String })));
|
|
30
|
+
export const decodeRef = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ ref: Schema.String })));
|
|
31
|
+
|
|
32
|
+
export function releaseBranchOf(base: string): string {
|
|
33
|
+
return `release/${base}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stagedOf(raw: string): readonly Staged[] {
|
|
37
|
+
const fields = raw.split("\0");
|
|
38
|
+
const staged: Staged[] = [];
|
|
39
|
+
for (let index = 0; index + 1 < fields.length; index += 2) {
|
|
40
|
+
const [, from = "", to = "", status] = RAW_ENTRY.exec(fields[index] ?? "") ?? [];
|
|
41
|
+
const path = fields[index + 1] ?? "";
|
|
42
|
+
staged.push(status === "D" ? { kind: "deleted", path, mode: from } : { kind: "written", path, mode: to });
|
|
43
|
+
}
|
|
44
|
+
return staged;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function refused(message: string): ReleaseRefused {
|
|
48
|
+
return new ReleaseRefused({ message });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const versionIn = (manifest: string): Effect.Effect<string | undefined> =>
|
|
52
|
+
decodeVersion(manifest).pipe(
|
|
53
|
+
Effect.map(({ version }) => version),
|
|
54
|
+
Effect.orElseSucceed(() => undefined),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const releaseVersion = Effect.fn("releaseVersion")(function* (manifest: string, { since, unreleased }: Pending, untagged: string) {
|
|
58
|
+
const { version } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => refused(`${MANIFEST}: ${cause.message}`)));
|
|
59
|
+
if (since !== undefined && since !== `v${version}`) {
|
|
60
|
+
return yield* refused(
|
|
61
|
+
`${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; ${untagged}, or return its version to ${since.slice(1)}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const next = nextVersion(version, unreleased);
|
|
65
|
+
if (next === undefined) return yield* refused(`${MANIFEST} holds ${version}, which is no plain major.minor.patch version`);
|
|
66
|
+
const bumped = withVersion(manifest, version, next);
|
|
67
|
+
if (bumped === undefined || (yield* versionIn(bumped)) !== next) {
|
|
68
|
+
return yield* refused(`cannot rewrite the version ${version} in ${MANIFEST} as ${next}`);
|
|
69
|
+
}
|
|
70
|
+
return { next, bumped };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const currentBranch = Effect.fn("currentBranch")(function* (root: string) {
|
|
74
|
+
const branch = yield* git(["symbolic-ref", "--quiet", "--short", "HEAD"], root).pipe(
|
|
75
|
+
Effect.map((name) => name.trim()),
|
|
76
|
+
Effect.catchTag("GitFailure", () => Effect.succeed("")),
|
|
77
|
+
);
|
|
78
|
+
if (branch === "") return yield* refused("HEAD is detached; check out the branch the release goes to");
|
|
79
|
+
return branch;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const hasBranch = Effect.fn("hasBranch")(function* (root: string, branch: string) {
|
|
83
|
+
return (yield* git(["ls-remote", "origin", `refs/heads/${branch}`], root)).trim() !== "";
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
type Plan = {
|
|
87
|
+
readonly base: string;
|
|
88
|
+
readonly branch: string;
|
|
89
|
+
readonly head: string;
|
|
90
|
+
readonly next: string;
|
|
91
|
+
readonly bumped: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Undefined means nothing is unreleased, which it has already said.
|
|
95
|
+
export const planRelease = Effect.fn("planRelease")(function* (label: string, root: string, untagged: string) {
|
|
96
|
+
const pending = yield* readPending(root);
|
|
97
|
+
if (pending.unreleased.length === 0) {
|
|
98
|
+
yield* Console.log(`${label}: no unreleased changes ${pending.since === undefined ? "with no tag yet" : `since ${pending.since}`}`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const base = yield* currentBranch(root);
|
|
102
|
+
const head = (yield* git(["rev-parse", "HEAD"], root)).trim();
|
|
103
|
+
const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
|
|
104
|
+
const { next, bumped } = yield* releaseVersion(manifest, pending, untagged);
|
|
105
|
+
return { base, branch: releaseBranchOf(base), head, next, bumped } satisfies Plan;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const readStaged = Effect.fn("readStaged")(function* (root: string, staged: Staged) {
|
|
109
|
+
if (staged.kind === "deleted") return staged;
|
|
110
|
+
if (!REGULAR_FILE_MODES.has(staged.mode)) return yield* refused(`the build wrote ${staged.path} with mode ${staged.mode}, which is no regular file`);
|
|
111
|
+
const bytes = yield* (yield* FileSystem.FileSystem).readFile((yield* Path.Path).join(root, staged.path));
|
|
112
|
+
return { ...staged, content: Base64.encode(bytes) };
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// The build writes into the working tree, so the tree it starts from has to hold nothing a release would sweep in.
|
|
116
|
+
export const buildRelease = Effect.fn("buildRelease")(function* (name: string, root: string, bumped: string) {
|
|
117
|
+
if ((yield* git(["status", "--porcelain", "--untracked-files=no"], root)).trim() !== "") {
|
|
118
|
+
return yield* refused("the working tree has changes to tracked files; the release commits only what the build writes");
|
|
119
|
+
}
|
|
120
|
+
const restore = git(["reset", "--quiet", "--hard", "HEAD"], root).pipe(
|
|
121
|
+
Effect.catchTag("GitFailure", (failure) => Console.error(`${name}: cannot restore the working tree: ${failure.message}`)),
|
|
122
|
+
);
|
|
123
|
+
return yield* Effect.gen(function* () {
|
|
124
|
+
yield* (yield* FileSystem.FileSystem).writeFileString((yield* Path.Path).join(root, MANIFEST), bumped);
|
|
125
|
+
yield* runBuild(root);
|
|
126
|
+
yield* git(["add", "--update"], root);
|
|
127
|
+
const tree = (yield* git(["write-tree"], root)).trim();
|
|
128
|
+
const staged = stagedOf(yield* git(["diff", "--cached", "--raw", "-z", "--no-renames", "HEAD"], root));
|
|
129
|
+
return { tree, files: yield* Effect.forEach(staged, (one) => readStaged(root, one)) } satisfies Built;
|
|
130
|
+
}).pipe(Effect.ensuring(restore));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const treeEntry = Effect.fn("treeEntry")(function* (file: Read) {
|
|
134
|
+
if (file.kind === "deleted") return { path: file.path, mode: file.mode, type: "blob", sha: null };
|
|
135
|
+
const { sha } = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/blobs`, { content: file.content, encoding: "base64" });
|
|
136
|
+
return { path: file.path, mode: file.mode, type: "blob", sha };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export const commitRelease = Effect.fn("commitRelease")(function* (root: string, head: string, built: Built, version: string) {
|
|
140
|
+
const tree = yield* Effect.forEach(built.files, treeEntry);
|
|
141
|
+
const baseTree = (yield* git(["rev-parse", `${head}^{tree}`], root)).trim();
|
|
142
|
+
const made = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/trees`, { base_tree: baseTree, tree });
|
|
143
|
+
if (made.sha !== built.tree) return yield* refused(`GitHub built the tree ${made.sha}, but the build wrote ${built.tree}`);
|
|
144
|
+
const commit = { message: releaseTitle(version), tree: made.sha, parents: [head] };
|
|
145
|
+
return (yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/commits`, commit)).sha;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export const pointBranch = Effect.fn("pointBranch")(function* (branch: string, sha: string, exists: boolean) {
|
|
149
|
+
yield* exists
|
|
150
|
+
? gitHubJson(decodeRef, "PATCH", `${REPOSITORY}/git/refs/heads/${branch}`, { sha, force: true })
|
|
151
|
+
: gitHubJson(decodeRef, "POST", `${REPOSITORY}/git/refs`, { ref: `refs/heads/${branch}`, sha });
|
|
152
|
+
});
|