@avi2dg/checks 0.52.1 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -574
- package/docs/bins.md +4 -0
- package/docs/configs/commit-messages.md +5 -4
- package/docs/design.md +28 -6
- package/docs/gates/checks-ci-wiring.md +1 -1
- package/docs/gates/checks-commit-identity.md +1 -1
- package/docs/gates/checks-commit-title.md +5 -4
- package/docs/gates/checks-copy.md +2 -2
- package/docs/gates/checks-night-run.md +4 -4
- package/docs/gates/checks-pr-text.md +90 -0
- package/docs/gates/checks-release-notes.md +6 -4
- package/docs/gates/checks-release-pr.md +3 -4
- package/docs/gates/checks-release-publish.md +63 -0
- package/docs/gates/checks-release-push.md +153 -0
- package/docs/gates/checks-release-report.md +2 -1
- package/docs/gates/checks-release-tag.md +2 -0
- package/docs/gates/checks-secrets.md +90 -1
- package/docs/gates/checks-setup.md +76 -0
- package/docs/gates/checks-vendor.md +3 -3
- package/package.json +12 -1
- package/src/delivery/commit-title.ts +14 -7
- package/src/delivery/gitleaks.ts +24 -9
- package/src/delivery/pr-text.ts +77 -0
- package/src/delivery/release-commit.ts +152 -0
- package/src/delivery/release-pr.ts +9 -132
- package/src/delivery/release-publish.ts +73 -0
- package/src/delivery/release-push.ts +148 -0
- package/src/delivery/release-tag.ts +4 -53
- package/src/delivery/release-tagging.ts +74 -0
- package/src/delivery/secrets-pack.ts +133 -0
- package/src/delivery/secrets.ts +5 -4
- package/src/delivery/setup.ts +399 -0
- package/src/delivery/workflow-references.ts +2 -2
|
@@ -1,28 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { Console, 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 {
|
|
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
|
|
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*
|
|
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
|
|
173
|
-
if (
|
|
174
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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
|
+
});
|