@avi2dg/checks 0.52.0 → 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.
@@ -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@4.0.0 from https://github.com/Effect-TS/effect.git and linked repos/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@4.0.0, verified against its recorded commit
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@4.0.0, and froze the tree again, so repos/effect still holds effect@4.0.0, verified against its recorded commit
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.52.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("no one open pull request heads the branch") end`,
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 === "" || title === "null") {
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
- const lintTitle = Effect.fn("lintTitle")(function* (title: string) {
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, `${title} (#0000)`);
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();
@@ -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
- export type Scan = {
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 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) {
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
- "git",
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
+ });