@avi2dg/checks 0.31.0 → 0.32.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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { releasedVersionOf } from "./release.ts";
3
4
  import { git, refArgs } from "../core/git.ts";
4
5
  import { runMain } from "../core/main.ts";
5
6
 
@@ -20,6 +21,17 @@ const DEFAULT_AUTHORS: readonly Identity[] = [{ name: "avi2d", email: "avi2dg@gm
20
21
  // GitHub writes the squash commit, so it commits what the owner authored and never authors.
21
22
  const SQUASH_COMMITTER: Identity = { name: "GitHub", email: "noreply@github.com" };
22
23
 
24
+ // checks-release-pr commits through the workflow token, which GitHub attributes to its Actions bot.
25
+ // GitHub may also name that bot as co-author when it squashes the pull request the bot opened.
26
+ const RELEASE_AUTHOR: Identity = { name: "github-actions[bot]", email: "41898282+github-actions[bot]@users.noreply.github.com" };
27
+
28
+ const CO_AUTHOR = /^co-authored-by:\s*(.*?)\s*<([^<>]*)>\s*$/i;
29
+
30
+ function namesReleaseAuthor(trailer: string): boolean {
31
+ const [, name, email = ""] = CO_AUTHOR.exec(trailer) ?? [];
32
+ return name !== undefined && allows([RELEASE_AUTHOR], { name, email });
33
+ }
34
+
23
35
  // git's own trailer parser, so only the trailer block counts and prose never does.
24
36
  const CO_AUTHORED_BY_FORMAT = "%(trailers:key=Co-authored-by)";
25
37
 
@@ -113,13 +125,16 @@ function allows(allowed: readonly Identity[], identity: Identity): boolean {
113
125
 
114
126
  function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefined {
115
127
  const reasons: string[] = [];
116
- if (!allows(allowed, commit.author)) {
128
+ const release = releasedVersionOf(commit.subject) !== undefined;
129
+ const authors = release ? [...allowed, RELEASE_AUTHOR] : allowed;
130
+ if (!allows(authors, commit.author)) {
117
131
  reasons.push(`author ${render(commit.author)}`);
118
132
  }
119
133
  if (!allows([...allowed, SQUASH_COMMITTER], commit.committer)) {
120
134
  reasons.push(`committer ${render(commit.committer)}`);
121
135
  }
122
136
  for (const trailer of commit.coAuthoredBy) {
137
+ if (release && namesReleaseAuthor(trailer)) continue;
123
138
  reasons.push(`trailer ${trailer}`);
124
139
  }
125
140
  return reasons.length === 0 ? undefined : { commit, reasons };
@@ -144,6 +159,7 @@ const check = Effect.gen(function* () {
144
159
  }
145
160
  lines.push(` allowed: ${allowed.map(render).join(", ")}`);
146
161
  lines.push(` allowed as committer only: ${render(SQUASH_COMMITTER)}`);
162
+ lines.push(` allowed as release author only: ${render(RELEASE_AUTHOR)}`);
147
163
  yield* Console.error(lines.join("\n"));
148
164
  return false;
149
165
  }
@@ -0,0 +1,38 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { ChildProcessSpawner } from "effect/unstable/process";
3
+ import { collect } from "../core/git.ts";
4
+
5
+ export class GitHubFailure extends Schema.TaggedError<GitHubFailure>()("GitHubFailure", {
6
+ message: Schema.String,
7
+ }) {}
8
+
9
+ type Method = "GET" | "POST" | "PATCH";
10
+
11
+ type Decode<A> = (text: string) => Effect.Effect<A, Schema.SchemaError>;
12
+
13
+ // gh fills {owner} and {repo} from the checkout's remote, so a workflow names no repository.
14
+ export const REPOSITORY = "repos/{owner}/{repo}";
15
+
16
+ const gitHub = Effect.fn("gitHub")(function* (method: Method, path: string, body?: unknown) {
17
+ const failed = (reason: string): GitHubFailure => new GitHubFailure({ message: `gh api ${method} ${path}: ${reason.trim()}` });
18
+ const input = body === undefined ? [] : ["--input", "-"];
19
+ const feed = body === undefined ? {} : { input: JSON.stringify(body) };
20
+ const { stdout, stderr, exitCode } = yield* collect("gh", ["api", "--method", method, path, ...input], undefined, feed).pipe(
21
+ Effect.mapError((cause) => failed(cause.message)),
22
+ );
23
+ if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
24
+ return stdout;
25
+ });
26
+
27
+ export function gitHubJson<A>(decode: Decode<A>, method: Method, path: string, body?: unknown): Effect.Effect<A, GitHubFailure, ChildProcessSpawner.ChildProcessSpawner> {
28
+ return gitHub(method, path, body).pipe(
29
+ Effect.flatMap((text) =>
30
+ decode(text).pipe(Effect.mapError((cause) => new GitHubFailure({ message: `gh api ${method} ${path} answered ${cause.message}` }))),
31
+ ),
32
+ );
33
+ }
34
+
35
+ // A dispatch is the one run a workflow token starts at once: GitHub starts none for the token's push, and holds its pull request's runs for approval.
36
+ export const dispatch = Effect.fn("dispatch")(function* (workflow: string, ref: string) {
37
+ yield* gitHub("POST", `${REPOSITORY}/actions/workflows/${workflow}/dispatches`, { ref });
38
+ });
@@ -0,0 +1,113 @@
1
+ title = "checks-secrets"
2
+
3
+ [extend]
4
+ useDefault = true
5
+
6
+ [[rules]]
7
+ id = "wireguard-key"
8
+ description = "A WireGuard or AmneziaWG private or pre-shared key"
9
+ regex = '''(?i)(?:private[_-]?key|pre[_-]?shared[_-]?key|psk)["']?[ \t]*[:=][ \t]*["']?([A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=)'''
10
+ secretGroup = 1
11
+ entropy = 3.0
12
+ keywords = ["private", "preshared", "pre_shared", "pre-shared", "psk"]
13
+
14
+ [[rules]]
15
+ id = "wireguard-key-file"
16
+ description = "A WireGuard or AmneziaWG key alone on a line of a file whose name says private, priv, pre-shared or psk"
17
+ path = '''(?i)(?:^|/)[^/]*(?:priv|pre[_-]?shared|psk)[^/]*$'''
18
+ regex = '''(?m)^[ \t]*([A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=)[ \t]*\r?$'''
19
+ secretGroup = 1
20
+ entropy = 3.0
21
+
22
+ [[rules]]
23
+ id = "wireguard-dot-key-file"
24
+ description = "A WireGuard or AmneziaWG key alone on a line of a .key file not named for a public key"
25
+ path = '''(?i)\.key$'''
26
+ regex = '''(?m)^[ \t]*([A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=)[ \t]*\r?$'''
27
+ secretGroup = 1
28
+ entropy = 3.0
29
+
30
+ [[rules.allowlists]]
31
+ description = "A .key file named for a public key"
32
+ paths = ['''(?i)pub(?:lic)?\.key$''']
33
+
34
+ [[rules]]
35
+ id = "proxy-userinfo-link"
36
+ description = "A proxy share link that carries its credential before the @, such as vless://, trojan://, socks5:// or wireguard://"
37
+ regex = '''(?i)\b(?:vless|vmess|ss|trojan|hysteria2?|hy2|tuic|socks5?h?|wireguard|wg)://([^\s"'`<>@?#]+)@[^\s"'`<>]+'''
38
+ secretGroup = 1
39
+ entropy = 2.5
40
+ keywords = ["vless://", "vmess://", "ss://", "trojan://", "hysteria://", "hysteria2://", "hy2://", "tuic://", "socks://", "socksh://", "socks5://", "socks5h://", "wireguard://", "wg://"]
41
+
42
+ [[rules.allowlists]]
43
+ description = "A link to a reserved example or test host"
44
+ regexTarget = "match"
45
+ regexes = ['''^(?i)[a-z0-9]+://(?:[^\s"'`@?#]*@)?(?:[\w-]+\.)*(?:example\.(?:com|net|org)|[\w-]+\.(?:example|invalid|test)|localhost)(?::\d+)?(?:[/?#]|$)''']
46
+
47
+ [[rules]]
48
+ id = "proxy-base64-link"
49
+ description = "A proxy share link whose whole payload is base64, such as vmess://, ss:// or ssr://"
50
+ regex = '''(?i)\b(?:vmess|ssr?)://([A-Za-z0-9+/_-]{16,}={0,2})@?'''
51
+ secretGroup = 1
52
+ entropy = 4.2
53
+ keywords = ["vmess://", "ss://", "ssr://"]
54
+
55
+ [[rules.allowlists]]
56
+ description = "A payload an @ follows, which proxy-userinfo-link judges"
57
+ regexTarget = "match"
58
+ regexes = ['''@$''']
59
+
60
+ [[rules]]
61
+ id = "proxy-query-credential-first"
62
+ description = "The first auth, auth_str, obfsParam, obfs-password, password or pk query parameter of a proxy share link, such as hysteria://host?auth="
63
+ regex = '''(?i)\b(?:vless|vmess|ssr?|trojan|hysteria2?|hy2|tuic|socks5?h?|wireguard|wg)://[^\s"'`?#]*\?(?:[^\s"'`#]*?&)??(?:auth|auth_str|obfsParam|obfs-password|password|pk)=([^\s"'`<>&#]+)'''
64
+ secretGroup = 1
65
+ entropy = 2.5
66
+ keywords = ["vless://", "vmess://", "ss://", "ssr://", "trojan://", "hysteria://", "hysteria2://", "hy2://", "tuic://", "socks://", "socksh://", "socks5://", "socks5h://", "wireguard://", "wg://"]
67
+
68
+ [[rules.allowlists]]
69
+ description = "A link to a reserved example or test host"
70
+ regexTarget = "match"
71
+ regexes = ['''^(?i)[a-z0-9]+://(?:[^\s"'`@?#]*@)?(?:[\w-]+\.)*(?:example\.(?:com|net|org)|[\w-]+\.(?:example|invalid|test)|localhost)(?::\d+)?(?:[/?#]|$)''']
72
+
73
+ [[rules]]
74
+ id = "proxy-query-credential-last"
75
+ description = "The last auth, auth_str, obfsParam, obfs-password, password or pk query parameter of a proxy share link, such as hysteria://host?auth="
76
+ regex = '''(?i)\b(?:vless|vmess|ssr?|trojan|hysteria2?|hy2|tuic|socks5?h?|wireguard|wg)://[^\s"'`?#]*\?(?:[^\s"'`#]*&)?(?:auth|auth_str|obfsParam|obfs-password|password|pk)=([^\s"'`<>&#]+)'''
77
+ secretGroup = 1
78
+ entropy = 2.5
79
+ keywords = ["vless://", "vmess://", "ss://", "ssr://", "trojan://", "hysteria://", "hysteria2://", "hy2://", "tuic://", "socks://", "socksh://", "socks5://", "socks5h://", "wireguard://", "wg://"]
80
+
81
+ [[rules.allowlists]]
82
+ description = "A link to a reserved example or test host"
83
+ regexTarget = "match"
84
+ regexes = ['''^(?i)[a-z0-9]+://(?:[^\s"'`@?#]*@)?(?:[\w-]+\.)*(?:example\.(?:com|net|org)|[\w-]+\.(?:example|invalid|test)|localhost)(?::\d+)?(?:[/?#]|$)''']
85
+
86
+ [[rules]]
87
+ id = "proxy-subscription-url"
88
+ description = "A proxy subscription URL whose path or query carries its token"
89
+ regex = '''(?i)(?:\bhttps?://[^\s"'`<>/]+/(?:[^\s"'`<>?]*/)?(?:sub|subs|subscribe|subscription|link)/|[?&](?:token|key|uuid|id)=)([A-Za-z0-9_-]{16,})'''
90
+ secretGroup = 1
91
+ entropy = 3.0
92
+ keywords = ["sub", "link"]
93
+
94
+ [[rules.required]]
95
+ id = "proxy-subscription-path"
96
+ withinLines = 0
97
+
98
+ [[rules]]
99
+ id = "proxy-subscription-path"
100
+ description = "An http or https URL whose last path segment before its token or query is sub, subs, subscribe, subscription or link"
101
+ regex = '''(?i)\bhttps?://[^\s"'`<>/]+/(?:[^\s"'`<>?]*/)?(?:sub|subs|subscribe|subscription|link)[/?]'''
102
+ keywords = ["sub", "link"]
103
+ skipReport = true
104
+
105
+ [[rules.allowlists]]
106
+ description = "A link to a reserved example or test host"
107
+ regexTarget = "match"
108
+ regexes = ['''^(?i)https?://(?:[\w-]+\.)*(?:example\.(?:com|net|org)|[\w-]+\.(?:example|invalid|test)|localhost)(?:[:/?#]|$)''']
109
+
110
+ [[allowlists]]
111
+ description = "A link credential that is only template references, such as {password}, ${pass}, ${encodeURIComponent(pass)}, ${pass:-}, ${pass:?message}, $PASS, {{ password }} or %(password)s, with a user part that is one too; a last ${...} may be cut short, and one that gives a default or alternative value, such as ${pass:-value}, is not a reference"
112
+ targetRules = ["proxy-userinfo-link", "proxy-query-credential-first", "proxy-query-credential-last"]
113
+ regexes = ['''^(?:(?:\$\{(?:[^A-Za-z_}][^}]*|[A-Za-z_][\w.]*(?:[^\w.:=+}-][^}]*|:(?:[^-=+}][^}]*)?|:?[-=+])?)?\}|\$[A-Za-z_]\w*|\{\{\s*[A-Za-z_][\w.]*\s*\}\}|\{[A-Za-z_][\w.]*\}|%\([A-Za-z_][\w.]*\)s):)?(?:\$\{(?:[^A-Za-z_}][^}]*|[A-Za-z_][\w.]*(?:[^\w.:=+}-][^}]*|:(?:[^-=+}][^}]*)?|:?[-=+])?)?\}?|\$[A-Za-z_]\w*|\{\{\s*[A-Za-z_][\w.]*\s*\}\}|\{[A-Za-z_][\w.]*\}|%\([A-Za-z_][\w.]*\)s)$''']
@@ -0,0 +1,111 @@
1
+ import { Effect, Option, Path, Schema } from "effect";
2
+ import { collect } from "../core/git.ts";
3
+ import { installPinned } from "../dependencies/pinned-binary.ts";
4
+
5
+ const GITLEAKS_VERSION = "8.30.1";
6
+ const RELEASES = `https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}`;
7
+ const MEMBER = "gitleaks";
8
+
9
+ type Build = { readonly archive: string; readonly sha256: string; readonly binarySha256: string };
10
+
11
+ // The archive's SHA-256 as gitleaks_<version>_checksums.txt of the release lists it, and the SHA-256 of the binary inside.
12
+ const BUILDS: Readonly<Record<string, Build>> = {
13
+ "darwin-arm64": {
14
+ archive: `gitleaks_${GITLEAKS_VERSION}_darwin_arm64.tar.gz`,
15
+ sha256: "b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5",
16
+ binarySha256: "ba52fb1bfabbcde42f032afad3d6e0b19dff8ed105229a16e7caa338bbc0e84f",
17
+ },
18
+ "darwin-x64": {
19
+ archive: `gitleaks_${GITLEAKS_VERSION}_darwin_x64.tar.gz`,
20
+ sha256: "dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709",
21
+ binarySha256: "cee01fea7173f1b779dff188e1c26ecbcb4027d394acc573b23aaf0be260e291",
22
+ },
23
+ "linux-arm64": {
24
+ archive: `gitleaks_${GITLEAKS_VERSION}_linux_arm64.tar.gz`,
25
+ sha256: "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080",
26
+ binarySha256: "00e91bbe655bd7c47753e8cfe61cb76ea1a5d7e7702fe161ee40102b46b3823b",
27
+ },
28
+ "linux-x64": {
29
+ archive: `gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz`,
30
+ sha256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb",
31
+ binarySha256: "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509",
32
+ },
33
+ };
34
+
35
+ class GitleaksError extends Schema.TaggedError<GitleaksError>()("GitleaksError", {
36
+ message: Schema.String,
37
+ }) {}
38
+
39
+ export function gitleaksBuildFor(platform: string, arch: string): Option.Option<Build> {
40
+ return Option.fromNullishOr(BUILDS[`${platform}-${arch}`]);
41
+ }
42
+
43
+ export const pinnedGitleaks = Effect.fn("pinnedGitleaks")(function* (cache: string) {
44
+ const build = gitleaksBuildFor(process.platform, process.arch);
45
+ if (Option.isNone(build)) {
46
+ return yield* new GitleaksError({ message: `no pinned gitleaks build runs on ${process.platform}-${process.arch}` });
47
+ }
48
+ const path = yield* Path.Path;
49
+ const { archive, sha256, binarySha256 } = build.value;
50
+ return yield* installPinned(
51
+ { kind: "tar.gz", url: `${RELEASES}/${archive}`, sha256, member: MEMBER, memberSha256: binarySha256 },
52
+ path.join(cache, "gitleaks", GITLEAKS_VERSION, MEMBER),
53
+ );
54
+ });
55
+
56
+ const Leak = Schema.Struct({
57
+ RuleID: Schema.String,
58
+ Description: Schema.String,
59
+ File: Schema.String,
60
+ StartLine: Schema.Int,
61
+ Commit: Schema.String,
62
+ });
63
+
64
+ export type Leak = typeof Leak.Type;
65
+
66
+ const decodeGitleaksReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Leak)));
67
+
68
+ export type Scan = {
69
+ readonly binary: string;
70
+ readonly gitDir: string;
71
+ readonly logOptions: string;
72
+ readonly config: string;
73
+ readonly ignoreDir: string;
74
+ };
75
+
76
+ // 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
+ // 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 the source is the git directory, where no tracked file lands.
79
+ export const scanCommits = Effect.fn("scanCommits")(function* ({ binary, gitDir, logOptions, config, ignoreDir }: Scan) {
80
+ const args = [
81
+ "git",
82
+ gitDir,
83
+ "--log-opts",
84
+ logOptions,
85
+ "--config",
86
+ config,
87
+ "--gitleaks-ignore-path",
88
+ ignoreDir,
89
+ "--ignore-gitleaks-allow",
90
+ "--redact",
91
+ "--no-banner",
92
+ "--no-color",
93
+ "--log-level",
94
+ "error",
95
+ "--exit-code",
96
+ "0",
97
+ "--report-format",
98
+ "json",
99
+ "--report-path",
100
+ "-",
101
+ ];
102
+ const { stdout, stderr, exitCode } = yield* collect(binary, args, ignoreDir).pipe(
103
+ Effect.mapError((cause) => new GitleaksError({ message: `cannot run ${binary}: ${cause.message}` })),
104
+ );
105
+ if (exitCode !== 0 || stderr.trim() !== "") {
106
+ return yield* new GitleaksError({ message: `${binary} failed: ${stderr.trim() || `exit code ${exitCode}`}` });
107
+ }
108
+ return yield* decodeGitleaksReport(stdout).pipe(
109
+ Effect.mapError((cause) => new GitleaksError({ message: `cannot read the report gitleaks wrote: ${cause.message}` })),
110
+ );
111
+ });
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, Encoding, FileSystem, Path, Schema } from "effect";
3
+ import { dispatch, 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
+ import { runMain, Usage } from "../core/main.ts";
7
+
8
+ class ReleaseRefused extends Schema.TaggedError<ReleaseRefused>()("ReleaseRefused", {
9
+ message: Schema.String,
10
+ }) {}
11
+
12
+ type Staged =
13
+ | { readonly kind: "written"; readonly path: string; readonly mode: string }
14
+ | { readonly kind: "deleted"; readonly path: string; readonly mode: string };
15
+
16
+ type Read =
17
+ | { readonly kind: "written"; readonly path: string; readonly mode: string; readonly content: string }
18
+ | { readonly kind: "deleted"; readonly path: string; readonly mode: string };
19
+
20
+ type Built = {
21
+ readonly tree: string;
22
+ readonly files: readonly Read[];
23
+ };
24
+
25
+ type Remote = {
26
+ readonly parents: readonly string[];
27
+ readonly version: string | undefined;
28
+ };
29
+
30
+ const NAME = "checks-release-pr";
31
+ const USAGE = "usage: release-pr.ts <workflow>...";
32
+ const MANIFEST = "package.json";
33
+ const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
34
+ const RAW_ENTRY = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/;
35
+
36
+ const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
37
+ const decodeSha = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ sha: Schema.String })));
38
+ const decodeRef = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ ref: Schema.String })));
39
+ const PullRequest = Schema.Struct({ number: Schema.Int, title: Schema.String, html_url: Schema.String });
40
+ type PullRequest = typeof PullRequest.Type;
41
+ const decodePullRequest = Schema.decodeUnknownEffect(Schema.fromJsonString(PullRequest));
42
+ const decodePullRequests = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(PullRequest)));
43
+
44
+ export function releaseBranchOf(base: string): string {
45
+ return `release/${base}`;
46
+ }
47
+
48
+ export function stagedOf(raw: string): readonly Staged[] {
49
+ const fields = raw.split("\0");
50
+ const staged: Staged[] = [];
51
+ for (let index = 0; index + 1 < fields.length; index += 2) {
52
+ const [, from = "", to = "", status] = RAW_ENTRY.exec(fields[index] ?? "") ?? [];
53
+ const path = fields[index + 1] ?? "";
54
+ staged.push(status === "D" ? { kind: "deleted", path, mode: from } : { kind: "written", path, mode: to });
55
+ }
56
+ return staged;
57
+ }
58
+
59
+ const refused = (message: string) => new ReleaseRefused({ message });
60
+
61
+ const versionIn = (manifest: string) =>
62
+ decodeVersion(manifest).pipe(
63
+ Effect.map(({ version }) => version),
64
+ Effect.orElseSucceed(() => undefined),
65
+ );
66
+
67
+ const releaseVersion = Effect.fn("releaseVersion")(function* (manifest: string, { since, unreleased }: Pending) {
68
+ const { version } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => refused(`${MANIFEST}: ${cause.message}`)));
69
+ if (since !== undefined && since !== `v${version}`) {
70
+ return yield* refused(
71
+ `${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; rerun the tag job of daily-release on the release commit, whose refusal says what to fix, or return its version to ${since.slice(1)}`,
72
+ );
73
+ }
74
+ const next = nextVersion(version, unreleased);
75
+ if (next === undefined) return yield* refused(`${MANIFEST} holds ${version}, which is no plain major.minor.patch version`);
76
+ const bumped = withVersion(manifest, version, next);
77
+ if (bumped === undefined || (yield* versionIn(bumped)) !== next) {
78
+ return yield* refused(`cannot rewrite the version ${version} in ${MANIFEST} as ${next}`);
79
+ }
80
+ return { next, bumped };
81
+ });
82
+
83
+ const currentBranch = Effect.fn("currentBranch")(function* (root: string) {
84
+ const branch = yield* git(["symbolic-ref", "--quiet", "--short", "HEAD"], root).pipe(
85
+ Effect.map((name) => name.trim()),
86
+ Effect.catchTag("GitFailure", () => Effect.succeed("")),
87
+ );
88
+ if (branch === "") return yield* refused("HEAD is detached; check out the branch the release goes to");
89
+ return branch;
90
+ });
91
+
92
+ const readRemote = Effect.fn("readRemote")(function* (root: string, branch: string) {
93
+ if ((yield* git(["ls-remote", "origin", `refs/heads/${branch}`], root)).trim() === "") return undefined;
94
+ const tracking = `refs/remotes/origin/${branch}`;
95
+ yield* git(["fetch", "--quiet", "--no-tags", "origin", `+refs/heads/${branch}:${tracking}`], root);
96
+ const [, ...parents] = (yield* git(["rev-list", "--parents", "-n", "1", tracking], root)).trim().split(" ");
97
+ const version = yield* git(["show", `${tracking}:${MANIFEST}`], root).pipe(
98
+ Effect.flatMap(versionIn),
99
+ Effect.orElseSucceed(() => undefined),
100
+ );
101
+ return { parents, version } satisfies Remote;
102
+ });
103
+
104
+ const readStaged = Effect.fn("readStaged")(function* (root: string, staged: Staged) {
105
+ if (staged.kind === "deleted") return staged;
106
+ if (!REGULAR_FILE_MODES.has(staged.mode)) return yield* refused(`the build wrote ${staged.path} with mode ${staged.mode}, which is no regular file`);
107
+ const bytes = yield* (yield* FileSystem.FileSystem).readFile((yield* Path.Path).join(root, staged.path));
108
+ return { ...staged, content: Encoding.encodeBase64(bytes) };
109
+ });
110
+
111
+ // The build writes into the working tree, so the tree it starts from has to hold nothing a release would sweep in.
112
+ const buildRelease = Effect.fn("buildRelease")(function* (root: string, bumped: string) {
113
+ if ((yield* git(["status", "--porcelain", "--untracked-files=no"], root)).trim() !== "") {
114
+ return yield* refused("the working tree has changes to tracked files; the release commits only what the build writes");
115
+ }
116
+ const restore = git(["reset", "--quiet", "--hard", "HEAD"], root).pipe(
117
+ Effect.catchTag("GitFailure", (failure) => Console.error(`${NAME}: cannot restore the working tree: ${failure.message}`)),
118
+ );
119
+ return yield* Effect.gen(function* () {
120
+ yield* (yield* FileSystem.FileSystem).writeFileString((yield* Path.Path).join(root, MANIFEST), bumped);
121
+ yield* runBuild(root);
122
+ yield* git(["add", "--update"], root);
123
+ const tree = (yield* git(["write-tree"], root)).trim();
124
+ const staged = stagedOf(yield* git(["diff", "--cached", "--raw", "-z", "--no-renames", "HEAD"], root));
125
+ return { tree, files: yield* Effect.forEach(staged, (one) => readStaged(root, one)) } satisfies Built;
126
+ }).pipe(Effect.ensuring(restore));
127
+ });
128
+
129
+ const treeEntry = Effect.fn("treeEntry")(function* (file: Read) {
130
+ if (file.kind === "deleted") return { path: file.path, mode: file.mode, type: "blob", sha: null };
131
+ const { sha } = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/blobs`, { content: file.content, encoding: "base64" });
132
+ return { path: file.path, mode: file.mode, type: "blob", sha };
133
+ });
134
+
135
+ const commitRelease = Effect.fn("commitRelease")(function* (root: string, head: string, built: Built, version: string) {
136
+ const tree = yield* Effect.forEach(built.files, treeEntry);
137
+ const baseTree = (yield* git(["rev-parse", `${head}^{tree}`], root)).trim();
138
+ const made = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/trees`, { base_tree: baseTree, tree });
139
+ if (made.sha !== built.tree) return yield* refused(`GitHub built the tree ${made.sha}, but the build wrote ${built.tree}`);
140
+ const commit = { message: releaseTitle(version), tree: made.sha, parents: [head] };
141
+ return (yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/commits`, commit)).sha;
142
+ });
143
+
144
+ const pointBranch = Effect.fn("pointBranch")(function* (branch: string, sha: string, exists: boolean) {
145
+ yield* exists
146
+ ? gitHubJson(decodeRef, "PATCH", `${REPOSITORY}/git/refs/heads/${branch}`, { sha, force: true })
147
+ : gitHubJson(decodeRef, "POST", `${REPOSITORY}/git/refs`, { ref: `refs/heads/${branch}`, sha });
148
+ });
149
+
150
+ const findOpen = Effect.fn("findOpen")(function* (base: string, branch: string) {
151
+ const query = `head={owner}:${encodeURIComponent(branch)}&base=${encodeURIComponent(base)}&state=open`;
152
+ const [open] = yield* gitHubJson(decodePullRequests, "GET", `${REPOSITORY}/pulls?${query}`);
153
+ return open;
154
+ });
155
+
156
+ const titlePullRequest = Effect.fn("titlePullRequest")(function* (open: PullRequest | undefined, base: string, branch: string, version: string) {
157
+ const title = releaseTitle(version);
158
+ const body = `Release ${version}.`;
159
+ if (open === undefined) {
160
+ const created = yield* gitHubJson(decodePullRequest, "POST", `${REPOSITORY}/pulls`, { title, head: branch, base, body });
161
+ return { verb: "opened", url: created.html_url };
162
+ }
163
+ if (open.title !== title) yield* gitHubJson(decodePullRequest, "PATCH", `${REPOSITORY}/pulls/${open.number}`, { title, body });
164
+ return { verb: "refreshed", url: open.html_url };
165
+ });
166
+
167
+ const releasePullRequest = Effect.gen(function* () {
168
+ const workflows = process.argv.slice(2);
169
+ if (workflows.length === 0) return yield* new Usage({ message: USAGE });
170
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
171
+ const pending = yield* readPending(root);
172
+ if (pending.unreleased.length === 0) {
173
+ yield* Console.log(`release-pr: no unreleased changes ${pending.since === undefined ? "with no tag yet" : `since ${pending.since}`}`);
174
+ return true;
175
+ }
176
+ const base = yield* currentBranch(root);
177
+ const branch = releaseBranchOf(base);
178
+ const head = (yield* git(["rev-parse", "HEAD"], root)).trim();
179
+ const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
180
+ const { next, bumped } = yield* releaseVersion(manifest, pending);
181
+ const remote = yield* readRemote(root, branch);
182
+ const current = remote?.version === next && remote.parents.length === 1 && remote.parents[0] === head;
183
+ const open = yield* findOpen(base, branch);
184
+ if (current && open?.title === releaseTitle(next)) {
185
+ yield* Console.log(`release-pr: ${open.html_url} releases ${next} from ${head.slice(0, 12)} and is current`);
186
+ return true;
187
+ }
188
+ if (!current) yield* pointBranch(branch, yield* commitRelease(root, head, yield* buildRelease(root, bumped), next), remote !== undefined);
189
+ const { verb, url } = yield* titlePullRequest(open, base, branch, next);
190
+ yield* Effect.forEach(workflows, (workflow) => dispatch(workflow, branch));
191
+ yield* Console.log(`release-pr: ${verb} ${url} to release ${next}, and dispatched ${workflows.join(", ")} on ${branch}`);
192
+ return true;
193
+ });
194
+
195
+ if (import.meta.main) runMain(NAME, releasePullRequest);
@@ -1,19 +1,10 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, Schema } from "effect";
3
- import { groupOf } from "./changelog.ts";
2
+ import { Console, Effect } from "effect";
3
+ import { readPending } from "./release.ts";
4
4
  import { git } from "../core/git.ts";
5
5
  import { runMain, Usage } from "../core/main.ts";
6
6
 
7
- export class ReleaseReportUnreadable extends Schema.TaggedError<ReleaseReportUnreadable>()("ReleaseReportUnreadable", {
8
- message: Schema.String,
9
- }) {}
10
-
11
7
  const USAGE = "usage: release-report.ts";
12
- const RELEASE_TAG = "v[0-9]*.[0-9]*.[0-9]*";
13
-
14
- export function unreleasedOf(subjects: readonly string[]): readonly string[] {
15
- return subjects.filter((subject) => groupOf(subject) !== undefined);
16
- }
17
8
 
18
9
  export function formatReport(unreleased: readonly string[], since: string | undefined): string {
19
10
  const where = since === undefined ? "with no tag yet" : `since ${since}`;
@@ -21,28 +12,10 @@ export function formatReport(unreleased: readonly string[], since: string | unde
21
12
  return [`release-report: ${unreleased.length} unreleased change(s) ${where}:`, ...unreleased.map((subject) => ` ${subject}`)].join("\n");
22
13
  }
23
14
 
24
- const lastTag = Effect.fn("lastTag")(function* (root: string) {
25
- const described = yield* git(["describe", "--tags", "--abbrev=0", `--match=${RELEASE_TAG}`, "HEAD"], root).pipe(
26
- Effect.map((tag) => tag.trim()),
27
- Effect.catchTag("GitFailure", () => Effect.succeed("")),
28
- );
29
- return described === "" ? undefined : described;
30
- });
31
-
32
- const subjectsSince = Effect.fn("subjectsSince")(function* (root: string, since: string | undefined) {
33
- const range = since === undefined ? "HEAD" : `${since}..HEAD`;
34
- const log = yield* git(["log", "--topo-order", "--no-merges", "--format=%s", range], root);
35
- return log.split("\n").filter((subject) => subject !== "");
36
- });
37
-
38
15
  const report = Effect.gen(function* () {
39
16
  if (process.argv.slice(2).length > 0) return yield* new Usage({ message: USAGE });
40
17
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
41
- if ((yield* git(["rev-parse", "--is-shallow-repository"], root)).trim() === "true") {
42
- return yield* new ReleaseReportUnreadable({ message: "the checkout is shallow, so the tag it sees may not be the last one; fetch all of it" });
43
- }
44
- const since = yield* lastTag(root);
45
- const unreleased = unreleasedOf(yield* subjectsSince(root, since));
18
+ const { since, unreleased } = yield* readPending(root);
46
19
  yield* Console.log(formatReport(unreleased, since));
47
20
  return unreleased.length === 0;
48
21
  });
@@ -0,0 +1,64 @@
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";
5
+ import { git } from "../core/git.ts";
6
+ import { runMain, Usage } from "../core/main.ts";
7
+
8
+ class TagRefused extends Schema.TaggedError<TagRefused>()("TagRefused", {
9
+ message: Schema.String,
10
+ }) {}
11
+
12
+ const NAME = "checks-release-tag";
13
+ 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
+
25
+ const releaseTag = Effect.gen(function* () {
26
+ const [workflow, ...extra] = process.argv.slice(2);
27
+ 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;
62
+ });
63
+
64
+ if (import.meta.main) runMain(NAME, releaseTag);