@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.
@@ -1,28 +1,11 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import * as Base64 from "effect/encoding/Base64";
2
+ import { Console, Effect, Schema } from "effect";
4
3
  import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
5
- import { nextVersion, type Pending, readPending, releaseTitle, runBuild, withVersion } from "./release.ts";
4
+ import { releaseTitle } from "./release.ts";
5
+ import { buildRelease, commitRelease, hasBranch, MANIFEST, planRelease, pointBranch, versionIn } from "./release-commit.ts";
6
6
  import { git } from "../core/git.ts";
7
7
  import { runMain, Usage } from "../core/main.ts";
8
8
 
9
- class ReleaseRefused extends Schema.TaggedError<ReleaseRefused>()("ReleaseRefused", {
10
- message: Schema.String,
11
- }) {}
12
-
13
- type Staged =
14
- | { readonly kind: "written"; readonly path: string; readonly mode: string }
15
- | { readonly kind: "deleted"; readonly path: string; readonly mode: string };
16
-
17
- type Read =
18
- | { readonly kind: "written"; readonly path: string; readonly mode: string; readonly content: string }
19
- | { readonly kind: "deleted"; readonly path: string; readonly mode: string };
20
-
21
- type Built = {
22
- readonly tree: string;
23
- readonly files: readonly Read[];
24
- };
25
-
26
9
  type Remote = {
27
10
  readonly parents: readonly string[];
28
11
  readonly version: string | undefined;
@@ -30,68 +13,15 @@ type Remote = {
30
13
 
31
14
  const NAME = "checks-release-pr";
32
15
  const USAGE = "usage: release-pr.ts <workflow>...";
33
- const MANIFEST = "package.json";
34
- const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
35
- const RAW_ENTRY = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/;
16
+ const UNTAGGED = "rerun the tag job of daily-release on the release commit, whose refusal says what to fix";
36
17
 
37
- const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
38
- const decodeSha = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ sha: Schema.String })));
39
- const decodeRef = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ ref: Schema.String })));
40
18
  const PullRequest = Schema.Struct({ number: Schema.Int, title: Schema.String, html_url: Schema.String });
41
19
  type PullRequest = typeof PullRequest.Type;
42
20
  const decodePullRequest = Schema.decodeUnknownEffect(Schema.fromJsonString(PullRequest));
43
21
  const decodePullRequests = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(PullRequest)));
44
22
 
45
- export function releaseBranchOf(base: string): string {
46
- return `release/${base}`;
47
- }
48
-
49
- export function stagedOf(raw: string): readonly Staged[] {
50
- const fields = raw.split("\0");
51
- const staged: Staged[] = [];
52
- for (let index = 0; index + 1 < fields.length; index += 2) {
53
- const [, from = "", to = "", status] = RAW_ENTRY.exec(fields[index] ?? "") ?? [];
54
- const path = fields[index + 1] ?? "";
55
- staged.push(status === "D" ? { kind: "deleted", path, mode: from } : { kind: "written", path, mode: to });
56
- }
57
- return staged;
58
- }
59
-
60
- const refused = (message: string) => new ReleaseRefused({ message });
61
-
62
- const versionIn = (manifest: string) =>
63
- decodeVersion(manifest).pipe(
64
- Effect.map(({ version }) => version),
65
- Effect.orElseSucceed(() => undefined),
66
- );
67
-
68
- const releaseVersion = Effect.fn("releaseVersion")(function* (manifest: string, { since, unreleased }: Pending) {
69
- const { version } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => refused(`${MANIFEST}: ${cause.message}`)));
70
- if (since !== undefined && since !== `v${version}`) {
71
- return yield* refused(
72
- `${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)}`,
73
- );
74
- }
75
- const next = nextVersion(version, unreleased);
76
- if (next === undefined) return yield* refused(`${MANIFEST} holds ${version}, which is no plain major.minor.patch version`);
77
- const bumped = withVersion(manifest, version, next);
78
- if (bumped === undefined || (yield* versionIn(bumped)) !== next) {
79
- return yield* refused(`cannot rewrite the version ${version} in ${MANIFEST} as ${next}`);
80
- }
81
- return { next, bumped };
82
- });
83
-
84
- const currentBranch = Effect.fn("currentBranch")(function* (root: string) {
85
- const branch = yield* git(["symbolic-ref", "--quiet", "--short", "HEAD"], root).pipe(
86
- Effect.map((name) => name.trim()),
87
- Effect.catchTag("GitFailure", () => Effect.succeed("")),
88
- );
89
- if (branch === "") return yield* refused("HEAD is detached; check out the branch the release goes to");
90
- return branch;
91
- });
92
-
93
23
  const readRemote = Effect.fn("readRemote")(function* (root: string, branch: string) {
94
- if ((yield* git(["ls-remote", "origin", `refs/heads/${branch}`], root)).trim() === "") return undefined;
24
+ if (!(yield* hasBranch(root, branch))) return undefined;
95
25
  const tracking = `refs/remotes/origin/${branch}`;
96
26
  yield* git(["fetch", "--quiet", "--no-tags", "origin", `+refs/heads/${branch}:${tracking}`], root);
97
27
  const [, ...parents] = (yield* git(["rev-list", "--parents", "-n", "1", tracking], root)).trim().split(" ");
@@ -102,52 +32,6 @@ const readRemote = Effect.fn("readRemote")(function* (root: string, branch: stri
102
32
  return { parents, version } satisfies Remote;
103
33
  });
104
34
 
105
- const readStaged = Effect.fn("readStaged")(function* (root: string, staged: Staged) {
106
- if (staged.kind === "deleted") return staged;
107
- if (!REGULAR_FILE_MODES.has(staged.mode)) return yield* refused(`the build wrote ${staged.path} with mode ${staged.mode}, which is no regular file`);
108
- const bytes = yield* (yield* FileSystem.FileSystem).readFile((yield* Path.Path).join(root, staged.path));
109
- return { ...staged, content: Base64.encode(bytes) };
110
- });
111
-
112
- // The build writes into the working tree, so the tree it starts from has to hold nothing a release would sweep in.
113
- const buildRelease = Effect.fn("buildRelease")(function* (root: string, bumped: string) {
114
- if ((yield* git(["status", "--porcelain", "--untracked-files=no"], root)).trim() !== "") {
115
- return yield* refused("the working tree has changes to tracked files; the release commits only what the build writes");
116
- }
117
- const restore = git(["reset", "--quiet", "--hard", "HEAD"], root).pipe(
118
- Effect.catchTag("GitFailure", (failure) => Console.error(`${NAME}: cannot restore the working tree: ${failure.message}`)),
119
- );
120
- return yield* Effect.gen(function* () {
121
- yield* (yield* FileSystem.FileSystem).writeFileString((yield* Path.Path).join(root, MANIFEST), bumped);
122
- yield* runBuild(root);
123
- yield* git(["add", "--update"], root);
124
- const tree = (yield* git(["write-tree"], root)).trim();
125
- const staged = stagedOf(yield* git(["diff", "--cached", "--raw", "-z", "--no-renames", "HEAD"], root));
126
- return { tree, files: yield* Effect.forEach(staged, (one) => readStaged(root, one)) } satisfies Built;
127
- }).pipe(Effect.ensuring(restore));
128
- });
129
-
130
- const treeEntry = Effect.fn("treeEntry")(function* (file: Read) {
131
- if (file.kind === "deleted") return { path: file.path, mode: file.mode, type: "blob", sha: null };
132
- const { sha } = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/blobs`, { content: file.content, encoding: "base64" });
133
- return { path: file.path, mode: file.mode, type: "blob", sha };
134
- });
135
-
136
- const commitRelease = Effect.fn("commitRelease")(function* (root: string, head: string, built: Built, version: string) {
137
- const tree = yield* Effect.forEach(built.files, treeEntry);
138
- const baseTree = (yield* git(["rev-parse", `${head}^{tree}`], root)).trim();
139
- const made = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/trees`, { base_tree: baseTree, tree });
140
- if (made.sha !== built.tree) return yield* refused(`GitHub built the tree ${made.sha}, but the build wrote ${built.tree}`);
141
- const commit = { message: releaseTitle(version), tree: made.sha, parents: [head] };
142
- return (yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/commits`, commit)).sha;
143
- });
144
-
145
- const pointBranch = Effect.fn("pointBranch")(function* (branch: string, sha: string, exists: boolean) {
146
- yield* exists
147
- ? gitHubJson(decodeRef, "PATCH", `${REPOSITORY}/git/refs/heads/${branch}`, { sha, force: true })
148
- : gitHubJson(decodeRef, "POST", `${REPOSITORY}/git/refs`, { ref: `refs/heads/${branch}`, sha });
149
- });
150
-
151
35
  const findOpen = Effect.fn("findOpen")(function* (base: string, branch: string) {
152
36
  const query = `head={owner}:${encodeURIComponent(branch)}&base=${encodeURIComponent(base)}&state=open`;
153
37
  const [open] = yield* gitHubJson(decodePullRequests, "GET", `${REPOSITORY}/pulls?${query}`);
@@ -169,16 +53,9 @@ const releasePullRequest = Effect.gen(function* () {
169
53
  const workflows = process.argv.slice(2);
170
54
  if (workflows.length === 0) return yield* new Usage({ message: USAGE });
171
55
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
172
- const pending = yield* readPending(root);
173
- if (pending.unreleased.length === 0) {
174
- yield* Console.log(`release-pr: no unreleased changes ${pending.since === undefined ? "with no tag yet" : `since ${pending.since}`}`);
175
- return true;
176
- }
177
- const base = yield* currentBranch(root);
178
- const branch = releaseBranchOf(base);
179
- const head = (yield* git(["rev-parse", "HEAD"], root)).trim();
180
- const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
181
- const { next, bumped } = yield* releaseVersion(manifest, pending);
56
+ const plan = yield* planRelease("release-pr", root, UNTAGGED);
57
+ if (plan === undefined) return true;
58
+ const { base, branch, head, next, bumped } = plan;
182
59
  const remote = yield* readRemote(root, branch);
183
60
  const current = remote?.version === next && remote.parents.length === 1 && remote.parents[0] === head;
184
61
  const open = yield* findOpen(base, branch);
@@ -186,7 +63,7 @@ const releasePullRequest = Effect.gen(function* () {
186
63
  yield* Console.log(`release-pr: ${open.html_url} releases ${next} from ${head.slice(0, 12)} and is current`);
187
64
  return true;
188
65
  }
189
- if (!current) yield* pointBranch(branch, yield* commitRelease(root, head, yield* buildRelease(root, bumped), next), remote !== undefined);
66
+ if (!current) yield* pointBranch(branch, yield* commitRelease(root, head, yield* buildRelease(NAME, root, bumped), next), remote !== undefined);
190
67
  const { verb, url } = yield* titlePullRequest(open, base, branch, next);
191
68
  yield* Effect.forEach(workflows, (workflow) => dispatch(workflow, branch));
192
69
  yield* Console.log(`release-pr: ${verb} ${url} to release ${next}, and dispatched ${workflows.join(", ")} on ${branch}`);
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Option, Schema } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
+ import { collect } from "../core/git.ts";
5
+ import { runMain, Usage } from "../core/main.ts";
6
+
7
+ const NAME = "checks-release-publish";
8
+ const LABEL = "release-publish";
9
+ const USAGE = "usage: release-publish.ts";
10
+ const MANIFEST = "package.json";
11
+
12
+ class PublishFailed extends Schema.TaggedError<PublishFailed>()("PublishFailed", {
13
+ message: Schema.String,
14
+ }) {}
15
+
16
+ const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ name: Schema.String, version: Schema.String })));
17
+ const decodeTags = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ latest: Schema.String })));
18
+ const decodeMissing = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.Literal("E404") }) })));
19
+
20
+ type Standing = "unpublished" | "published" | "superseded";
21
+
22
+ // npm refuses to move `latest` back to a lower version, so a version below it can only stay off npm.
23
+ function standingOf(version: string, latest: string | undefined): Standing {
24
+ if (latest === undefined) return "unpublished";
25
+ const order = Bun.semver.order(version, latest);
26
+ if (order === 0) return "published";
27
+ return order < 0 ? "superseded" : "unpublished";
28
+ }
29
+
30
+ const latestOnNpm = Effect.fn("latestOnNpm")(function* (name: string) {
31
+ const failed = (reason: string) => new PublishFailed({ message: `npm view ${name} dist-tags: ${reason.trim()}` });
32
+ const { stdout, stderr, exitCode } = yield* collect("npm", ["view", name, "dist-tags", "--json"]).pipe(Effect.mapError((cause) => failed(cause.message)));
33
+ if (exitCode !== ChildProcessSpawner.ExitCode(0)) return Option.isSome(decodeMissing(stdout)) ? undefined : yield* failed(stderr);
34
+ return (yield* decodeTags(stdout).pipe(Effect.mapError((cause) => failed(cause.message)))).latest;
35
+ });
36
+
37
+ const npmPublish = Effect.fn("npmPublish")(
38
+ function* () {
39
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
40
+ const handle = yield* spawner.spawn(ChildProcess.make("npm", ["publish"], { stdin: "ignore", stdout: "inherit", stderr: "inherit" }));
41
+ return (yield* handle.exitCode) === ChildProcessSpawner.ExitCode(0);
42
+ },
43
+ Effect.scoped,
44
+ );
45
+
46
+ const settled = (name: string, version: string, standing: Standing, latest: string | undefined): string =>
47
+ standing === "published"
48
+ ? `${LABEL}: npm already holds ${name}@${version}`
49
+ : `${LABEL}: npm already holds ${name}@${latest ?? ""}, which carries every change in ${version}, so ${version} stays off npm`;
50
+
51
+ const releasePublish = Effect.gen(function* () {
52
+ if (process.argv.length > 2) return yield* new Usage({ message: USAGE });
53
+ const manifest = yield* (yield* FileSystem.FileSystem).readFileString(MANIFEST);
54
+ const { name, version } = yield* decodeManifest(manifest).pipe(Effect.mapError((cause) => new PublishFailed({ message: `${MANIFEST}: ${cause.message}` })));
55
+ const before = yield* latestOnNpm(name);
56
+ const standing = standingOf(version, before);
57
+ if (standing !== "unpublished") {
58
+ yield* Console.log(settled(name, version, standing, before));
59
+ return true;
60
+ }
61
+ if (yield* npmPublish()) {
62
+ yield* Console.log(`${LABEL}: published ${name}@${version}`);
63
+ return true;
64
+ }
65
+ // A release that published while this one ran makes npm refuse it, which leaves nothing to publish.
66
+ const after = yield* latestOnNpm(name);
67
+ const now = standingOf(version, after);
68
+ if (now === "unpublished") return yield* new PublishFailed({ message: `npm publish refused ${name}@${version}` });
69
+ yield* Console.log(settled(name, version, now, after));
70
+ return true;
71
+ });
72
+
73
+ if (import.meta.main) runMain(NAME, releasePublish);
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Duration, Effect, Schema } from "effect";
3
+ import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
4
+ import { releasedVersionOf } from "./release.ts";
5
+ import { buildRelease, commitRelease, decodeRef, hasBranch, planRelease, pointBranch, refused } from "./release-commit.ts";
6
+ import { pushTag, tagHead, taggedOn } from "./release-tagging.ts";
7
+ import { git } from "../core/git.ts";
8
+ import { runMain, Usage } from "../core/main.ts";
9
+
10
+ const NAME = "checks-release-push";
11
+ const LABEL = "release-push";
12
+ const USAGE = "usage: release-push.ts <release-workflow> <workflow>...";
13
+ const UNTAGGED = "tag the release commit with checks-release-tag, whose refusal says what to fix";
14
+ const POLL = Duration.seconds(15);
15
+ const POLLS = 180;
16
+
17
+ const Run = Schema.Struct({ path: Schema.String, status: Schema.String, conclusion: Schema.NullOr(Schema.String), html_url: Schema.String });
18
+ type Run = typeof Run.Type;
19
+ const decodeRuns = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(Run) })));
20
+
21
+ export type Verdict =
22
+ | { readonly kind: "pending"; readonly waiting: readonly string[] }
23
+ | { readonly kind: "passed" }
24
+ | { readonly kind: "failed"; readonly failed: readonly string[] };
25
+
26
+ // GitHub lists the newest run first, so a rerun stands in for the run it repeats.
27
+ export function verdictOf(workflows: readonly string[], runs: readonly Run[]): Verdict {
28
+ const latest = workflows.map((workflow) => {
29
+ const path = `.github/workflows/${workflow}`;
30
+ return { workflow, run: runs.find((run) => run.path === path || run.path.startsWith(`${path}@`)) };
31
+ });
32
+ const failed = latest.flatMap(({ workflow, run }) =>
33
+ run?.status === "completed" && run.conclusion !== "success" ? [`${workflow} concluded ${run.conclusion ?? "with no conclusion"} at ${run.html_url}`] : [],
34
+ );
35
+ if (failed.length > 0) return { kind: "failed", failed };
36
+ const waiting = latest.flatMap(({ workflow, run }) => (run?.status === "completed" ? [] : [workflow]));
37
+ return waiting.length > 0 ? { kind: "pending", waiting } : { kind: "passed" };
38
+ }
39
+
40
+ const POLL_FAILURES = 4;
41
+
42
+ const awaitWorkflows = Effect.fn("awaitWorkflows")(function* (
43
+ workflows: readonly string[],
44
+ sha: string,
45
+ failed: (failed: readonly string[]) => string,
46
+ unfinished: (waiting: readonly string[]) => string,
47
+ ) {
48
+ const runs = `${REPOSITORY}/actions/runs?head_sha=${sha}&event=workflow_dispatch&per_page=100`;
49
+ let waiting = workflows;
50
+ let failures = 0;
51
+ for (let poll = 1; poll <= POLLS; poll += 1) {
52
+ const read = yield* gitHubJson(decodeRuns, "GET", runs).pipe(
53
+ Effect.map((decoded) => ({ done: true as const, runs: decoded.workflow_runs })),
54
+ Effect.catchTag("GitHubFailure", (failure) =>
55
+ failures + 1 >= POLL_FAILURES ? Effect.fail(failure) : Effect.succeed({ done: false as const }),
56
+ ),
57
+ );
58
+ if (!read.done) {
59
+ failures += 1;
60
+ yield* Effect.sleep(POLL);
61
+ continue;
62
+ }
63
+ failures = 0;
64
+ const verdict = verdictOf(workflows, read.runs);
65
+ if (verdict.kind === "passed") return;
66
+ if (verdict.kind === "failed") return yield* refused(failed(verdict.failed));
67
+ waiting = verdict.waiting;
68
+ yield* Effect.sleep(POLL);
69
+ }
70
+ return yield* refused(unfinished(waiting));
71
+ });
72
+
73
+ const awaitChecks = Effect.fn("awaitChecks")(function* (workflows: readonly string[], sha: string) {
74
+ return yield* awaitWorkflows(
75
+ workflows,
76
+ sha,
77
+ (failed) => `the checks failed on ${sha.slice(0, 12)}, so the release stays off the branch: ${failed.join(", ")}`,
78
+ (waiting) => `${waiting.join(", ")} did not finish on ${sha.slice(0, 12)} within ${Duration.format(Duration.times(POLL, POLLS))}, so the release stays off the branch`,
79
+ );
80
+ });
81
+
82
+ const awaitReleaseWorkflow = Effect.fn("awaitReleaseWorkflow")(function* (workflow: string, sha: string) {
83
+ return yield* awaitWorkflows(
84
+ [workflow],
85
+ sha,
86
+ (failed) => `the release workflow failed on ${sha.slice(0, 12)}: ${failed.join(", ")}`,
87
+ () => `${workflow} did not finish on ${sha.slice(0, 12)} within ${Duration.format(Duration.times(POLL, POLLS))}, though the branch already holds the release and the tag exists`,
88
+ );
89
+ });
90
+
91
+ type Landing = "landed" | "moved";
92
+
93
+ // GitHub refuses a branch update that is no fast-forward, which is how a branch that moved during the checks shows.
94
+ // A failed call may still have applied the update, so the branch's head decides what happened.
95
+ const land = Effect.fn("land")(function* (root: string, base: string, head: string, release: string) {
96
+ return yield* gitHubJson(decodeRef, "PATCH", `${REPOSITORY}/git/refs/heads/${base}`, { sha: release, force: false }).pipe(
97
+ Effect.as<Landing>("landed"),
98
+ Effect.catchTag("GitHubFailure", (failure) =>
99
+ git(["ls-remote", "origin", `refs/heads/${base}`], root).pipe(
100
+ Effect.flatMap((listed) =>
101
+ Effect.gen(function* () {
102
+ const now = listed.split("\t")[0];
103
+ if (now === release) return "landed";
104
+ if (now === head) return yield* Effect.fail(failure);
105
+ yield* git(["fetch", "--quiet", "--no-tags", "origin", `refs/heads/${base}`], root);
106
+ const ancestor = yield* git(["merge-base", "--is-ancestor", release, "FETCH_HEAD"], root).pipe(
107
+ Effect.as(true),
108
+ Effect.catchTag("GitFailure", () => Effect.succeed(false)),
109
+ );
110
+ return ancestor ? "landed" : "moved";
111
+ }),
112
+ ),
113
+ ),
114
+ ),
115
+ );
116
+ });
117
+
118
+ const releasePush = Effect.gen(function* () {
119
+ const [publish, ...checks] = process.argv.slice(2);
120
+ if (publish === undefined || checks.length === 0) return yield* new Usage({ message: USAGE });
121
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
122
+ if (releasedVersionOf((yield* git(["log", "-1", "--format=%s", "HEAD"], root)).trim()) !== undefined)
123
+ return yield* tagHead(LABEL, root, publish, LABEL, awaitReleaseWorkflow);
124
+ const plan = yield* planRelease(LABEL, root, UNTAGGED);
125
+ if (plan === undefined) return true;
126
+ const { base, branch, head, next, bumped } = plan;
127
+ const tag = `v${next}`;
128
+ const taken = yield* taggedOn(root, tag);
129
+ if (taken !== undefined) return yield* refused(`${tag} already tags ${taken.slice(0, 12)}, so ${next} cannot be released again`);
130
+ const release = yield* commitRelease(root, head, yield* buildRelease(NAME, root, bumped), next);
131
+ yield* pointBranch(branch, release, yield* hasBranch(root, branch));
132
+ yield* Effect.forEach(checks, (workflow) => dispatch(workflow, branch));
133
+ yield* Console.log(`${LABEL}: dispatched ${checks.join(", ")} on ${branch} to check ${next} at ${release.slice(0, 12)}`);
134
+ yield* awaitChecks(checks, release);
135
+ if ((yield* land(root, base, head, release)) === "moved") {
136
+ yield* Console.log(`${LABEL}: ${base} moved past ${head.slice(0, 12)} while the checks ran, so the run on its new head releases it`);
137
+ return true;
138
+ }
139
+ // The release commit exists only on GitHub, and pushing the tag needs it in the checkout.
140
+ yield* git(["fetch", "--quiet", "--no-tags", "origin", `refs/heads/${base}`], root);
141
+ yield* pushTag(root, release, tag, publish);
142
+ yield* Console.log(`${LABEL}: released ${next} as ${release.slice(0, 12)} on ${base}, tagged it ${tag}, and dispatched ${publish} on it`);
143
+ yield* awaitReleaseWorkflow(publish, release);
144
+ yield* Console.log(`${LABEL}: ${publish} finished on ${tag}`);
145
+ return true;
146
+ });
147
+
148
+ if (import.meta.main) runMain(NAME, releasePush);
@@ -1,64 +1,15 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import { dispatch, GitHubFailure } from "./github.ts";
4
- import { lastTag, releasedVersionOf, runBuild } from "./release.ts";
2
+ import { Effect } from "effect";
3
+ import { tagHead } from "./release-tagging.ts";
5
4
  import { git } from "../core/git.ts";
6
5
  import { runMain, Usage } from "../core/main.ts";
7
6
 
8
- class TagRefused extends Schema.TaggedError<TagRefused>()("TagRefused", {
9
- message: Schema.String,
10
- }) {}
11
-
12
- const NAME = "checks-release-tag";
13
7
  const USAGE = "usage: release-tag.ts <workflow>";
14
- const MANIFEST = "package.json";
15
-
16
- const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
17
-
18
- // An annotated tag lists its own object first and the commit it tags on a `^{}` line after it.
19
- export function taggedCommitOf(listed: string): string | undefined {
20
- const lines = listed.split("\n").filter((line) => line !== "");
21
- const peeled = lines.find((line) => line.endsWith("^{}")) ?? lines[0];
22
- return peeled?.split("\t")[0];
23
- }
24
8
 
25
9
  const releaseTag = Effect.gen(function* () {
26
10
  const [workflow, ...extra] = process.argv.slice(2);
27
11
  if (workflow === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
28
- const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
29
- const [head = "", subject = ""] = (yield* git(["log", "-1", "--format=%H%n%s", "HEAD"], root)).split("\n");
30
- const version = releasedVersionOf(subject);
31
- if (version === undefined) {
32
- yield* Console.log(`release-tag: ${head.slice(0, 12)} is no release commit`);
33
- return true;
34
- }
35
- const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
36
- const { version: held } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => new TagRefused({ message: `${MANIFEST}: ${cause.message}` })));
37
- if (held !== version) return yield* new TagRefused({ message: `${head.slice(0, 12)} releases ${version}, but ${MANIFEST} holds ${held}` });
38
- const tag = `v${version}`;
39
- const tagged = taggedCommitOf(yield* git(["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`], root));
40
- if (tagged === head) {
41
- yield* Console.log(`release-tag: ${tag} already tags ${head.slice(0, 12)}`);
42
- return true;
43
- }
44
- if (tagged !== undefined) return yield* new TagRefused({ message: `${tag} already tags ${tagged.slice(0, 12)}, not ${head.slice(0, 12)}` });
45
- yield* runBuild(root);
46
- const rebuilt = (yield* git(["diff", "--name-only"], root)).trim().split("\n").filter((file) => file !== "");
47
- if (rebuilt.length > 0) {
48
- const since = yield* lastTag(root);
49
- const returned = since === undefined ? "the version it held before this release" : since.slice(1);
50
- return yield* new TagRefused({
51
- message: `the build rewrites ${rebuilt.join(", ")} at ${head.slice(0, 12)}, which the release workflow's build check refuses; open a \`chore: cancel the unpublished ${version}\` pull request that returns ${MANIFEST} to ${returned} and commits what \`bun run build\` then writes to CHANGELOG.md, and the next daily-release run cuts the release again with every change since ${since ?? "the first commit"}`,
52
- });
53
- }
54
- yield* git(["push", "--quiet", "origin", `${head}:refs/tags/${tag}`], root);
55
- yield* dispatch(workflow, tag).pipe(
56
- Effect.mapError(
57
- (failure) => new GitHubFailure({ message: `${failure.message}; ${tag} is pushed, so dispatch the release by hand with \`gh workflow run ${workflow} --ref ${tag}\`` }),
58
- ),
59
- );
60
- yield* Console.log(`release-tag: tagged ${head.slice(0, 12)} as ${tag}, and dispatched ${workflow} on it`);
61
- return true;
12
+ return yield* tagHead("release-tag", (yield* git(["rev-parse", "--show-toplevel"])).trim(), workflow, "daily-release");
62
13
  });
63
14
 
64
- if (import.meta.main) runMain(NAME, releaseTag);
15
+ if (import.meta.main) runMain("checks-release-tag", releaseTag);
@@ -0,0 +1,74 @@
1
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
+ import { dispatch, GitHubFailure } from "./github.ts";
3
+ import { ChildProcessSpawner } from "effect/process";
4
+ import { lastTag, releasedVersionOf, runBuild } from "./release.ts";
5
+ import { git } from "../core/git.ts";
6
+
7
+ class TagRefused extends Schema.TaggedError<TagRefused>()("TagRefused", {
8
+ message: Schema.String,
9
+ }) {}
10
+
11
+ const MANIFEST = "package.json";
12
+
13
+ const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
14
+
15
+ // An annotated tag lists its own object first and the commit it tags on a `^{}` line after it.
16
+ export function taggedCommitOf(listed: string): string | undefined {
17
+ const lines = listed.split("\n").filter((line) => line !== "");
18
+ const peeled = lines.find((line) => line.endsWith("^{}")) ?? lines[0];
19
+ return peeled?.split("\t")[0];
20
+ }
21
+
22
+ export const taggedOn = Effect.fn("taggedOn")(function* (root: string, tag: string) {
23
+ return taggedCommitOf(yield* git(["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`], root));
24
+ });
25
+
26
+ export const pushTag = Effect.fn("pushTag")(function* (root: string, sha: string, tag: string, workflow: string) {
27
+ yield* git(["push", "--quiet", "origin", `${sha}:refs/tags/${tag}`], root);
28
+ yield* dispatch(workflow, tag).pipe(
29
+ Effect.mapError(
30
+ (failure) => new GitHubFailure({ message: `${failure.message}; ${tag} is pushed, so dispatch the release by hand with \`gh workflow run ${workflow} --ref ${tag}\`` }),
31
+ ),
32
+ );
33
+ });
34
+
35
+ export const tagHead = Effect.fn("tagHead")(function* (
36
+ label: string,
37
+ root: string,
38
+ workflow: string,
39
+ cuttingWorkflow: string,
40
+ awaitRelease?: (workflow: string, sha: string) => Effect.Effect<void, { readonly message: string }, ChildProcessSpawner.ChildProcessSpawner>,
41
+ ) {
42
+ const [head = "", subject = ""] = (yield* git(["log", "-1", "--format=%H%n%s", "HEAD"], root)).split("\n");
43
+ const version = releasedVersionOf(subject);
44
+ if (version === undefined) {
45
+ yield* Console.log(`${label}: ${head.slice(0, 12)} is no release commit`);
46
+ return true;
47
+ }
48
+ const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
49
+ const { version: held } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => new TagRefused({ message: `${MANIFEST}: ${cause.message}` })));
50
+ if (held !== version) return yield* new TagRefused({ message: `${head.slice(0, 12)} releases ${version}, but ${MANIFEST} holds ${held}` });
51
+ const tag = `v${version}`;
52
+ const tagged = yield* taggedOn(root, tag);
53
+ if (tagged === head) {
54
+ yield* Console.log(`${label}: ${tag} already tags ${head.slice(0, 12)}`);
55
+ return true;
56
+ }
57
+ if (tagged !== undefined) return yield* new TagRefused({ message: `${tag} already tags ${tagged.slice(0, 12)}, not ${head.slice(0, 12)}` });
58
+ yield* runBuild(root);
59
+ const rebuilt = (yield* git(["diff", "--name-only"], root)).trim().split("\n").filter((file) => file !== "");
60
+ if (rebuilt.length > 0) {
61
+ const since = yield* lastTag(root);
62
+ const returned = since === undefined ? "the version it held before this release" : since.slice(1);
63
+ return yield* new TagRefused({
64
+ message: `the build rewrites ${rebuilt.join(", ")} at ${head.slice(0, 12)}, which the release workflow's build check refuses; open a \`chore: cancel the unpublished ${version}\` pull request that returns ${MANIFEST} to ${returned} and commits what \`bun run build\` then writes to CHANGELOG.md, and the next ${cuttingWorkflow} run cuts the release again with every change since ${since ?? "the first commit"}`,
65
+ });
66
+ }
67
+ yield* pushTag(root, head, tag, workflow);
68
+ yield* Console.log(`${label}: tagged ${head.slice(0, 12)} as ${tag}, and dispatched ${workflow} on it`);
69
+ if (awaitRelease !== undefined) {
70
+ yield* awaitRelease(workflow, head);
71
+ yield* Console.log(`${label}: ${workflow} finished on ${tag}`);
72
+ }
73
+ return true;
74
+ });