@effected/github-actions 0.1.0 → 0.3.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/ActionCache.js +128 -17
- package/ActionInput.js +61 -8
- package/ActionLogger.js +7 -3
- package/ActionOutputs.js +71 -2
- package/ActionState.js +33 -6
- package/ActionsIdentityToken.js +61 -0
- package/ActionsProvenance.js +103 -0
- package/Artifact.js +2 -2
- package/BlobStore.githubCache.js +2 -1
- package/CacheKey.js +106 -8
- package/CheckDocument.js +193 -0
- package/CheckState.js +75 -0
- package/GitHubMarkdown.js +197 -0
- package/ManagedDocument.js +281 -0
- package/PackageManagerInstaller.js +494 -0
- package/README.md +241 -0
- package/Secret.js +34 -0
- package/ToolInstaller.js +98 -52
- package/index.d.ts +1612 -230
- package/index.js +9 -2
- package/package.json +7 -3
package/Artifact.js
CHANGED
|
@@ -84,9 +84,9 @@ const ARTIFACT_VERSION = 7;
|
|
|
84
84
|
const azure = {
|
|
85
85
|
uploadFile: (url, file) => Effect.tryPromise({
|
|
86
86
|
try: () => new BlockBlobClient(url).uploadFile(file, {
|
|
87
|
-
blockSize:
|
|
87
|
+
blockSize: 67108864,
|
|
88
88
|
concurrency: 8,
|
|
89
|
-
maxSingleShotSize:
|
|
89
|
+
maxSingleShotSize: 134217728
|
|
90
90
|
}),
|
|
91
91
|
catch: (cause) => new BlobTransferError({
|
|
92
92
|
reason: "uploadFailed",
|
package/BlobStore.githubCache.js
CHANGED
|
@@ -64,7 +64,8 @@ const fromTwirp = (failure, key) => failure.kind === "status" ? new BlobStoreErr
|
|
|
64
64
|
});
|
|
65
65
|
const make = (transfer) => Effect.gen(function* () {
|
|
66
66
|
const http = yield* HttpClient.HttpClient;
|
|
67
|
-
const
|
|
67
|
+
const env = yield* ActionEnvironment;
|
|
68
|
+
const backend = resultsBackend(env).pipe(Effect.mapError((name) => new BlobStoreError({
|
|
68
69
|
reason: "misconfigured",
|
|
69
70
|
detail: `${name} is not set — the Actions results backend is only reachable from a \`uses:\` step, never from \`run:\``
|
|
70
71
|
})));
|
package/CacheKey.js
CHANGED
|
@@ -48,6 +48,20 @@ const MAX_KEY_LENGTH = 512;
|
|
|
48
48
|
const Segment = Schema.String.check(Schema.isPattern(/^[^,\n\r]+$/));
|
|
49
49
|
const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values) => values.join(SEPARATOR).length <= MAX_KEY_LENGTH, { title: `a cache key of at most ${MAX_KEY_LENGTH} characters` }));
|
|
50
50
|
/**
|
|
51
|
+
* One explicit rung: how many leading segments it keeps. The upper bound is
|
|
52
|
+
* cross-field (`segments.length - 1`) and lives on the class schema.
|
|
53
|
+
*/
|
|
54
|
+
const RestoreDepth = Schema.Number.check(Schema.isInt(), Schema.isBetween({
|
|
55
|
+
minimum: 1,
|
|
56
|
+
maximum: Number.MAX_SAFE_INTEGER
|
|
57
|
+
}));
|
|
58
|
+
/**
|
|
59
|
+
* The explicit ladder: possibly empty, because **zero rungs is a policy** —
|
|
60
|
+
* exact-match-only restores, spelled by {@link CacheKey.withoutRestoreKeys}.
|
|
61
|
+
* Only *absence* means the default every-prefix ladder.
|
|
62
|
+
*/
|
|
63
|
+
const RestoreDepths = Schema.Array(RestoreDepth);
|
|
64
|
+
/**
|
|
51
65
|
* A GitHub Actions cache key and the restore-key ladder that goes with it.
|
|
52
66
|
*
|
|
53
67
|
* @remarks
|
|
@@ -58,6 +72,12 @@ const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values)
|
|
|
58
72
|
* the primary key and its fallbacks impossible to drift apart, which is the
|
|
59
73
|
* failure that produces a cache that never hits and never says why.
|
|
60
74
|
*
|
|
75
|
+
* By default every prefix is a rung. When some segments must never be dropped
|
|
76
|
+
* — a rung without a version digest resurrects stale cross-version caches —
|
|
77
|
+
* give the key an explicit ladder policy with
|
|
78
|
+
* {@link CacheKey.withRestoreDepths}, or none at all with
|
|
79
|
+
* {@link CacheKey.withoutRestoreKeys}, instead of bypassing the typed key.
|
|
80
|
+
*
|
|
61
81
|
* @example
|
|
62
82
|
* ```ts
|
|
63
83
|
* import { CacheKey } from "@effected/github-actions";
|
|
@@ -69,9 +89,18 @@ const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values)
|
|
|
69
89
|
*
|
|
70
90
|
* @public
|
|
71
91
|
*/
|
|
72
|
-
var CacheKey = class CacheKey extends Schema.Class("CacheKey")({
|
|
73
|
-
/** The components, most general first. */
|
|
74
|
-
segments: Segments
|
|
92
|
+
var CacheKey = class CacheKey extends Schema.Class("CacheKey")(Schema.Struct({
|
|
93
|
+
/** The components, most general first. */
|
|
94
|
+
segments: Segments,
|
|
95
|
+
/**
|
|
96
|
+
* The explicit ladder policy, when one was given: how many leading
|
|
97
|
+
* segments each rung keeps, in the order the rungs are tried. Absent for
|
|
98
|
+
* the default every-prefix ladder; **empty for no ladder at all** —
|
|
99
|
+
* exact-match-only. See {@link CacheKey.withRestoreDepths} and
|
|
100
|
+
* {@link CacheKey.withoutRestoreKeys}.
|
|
101
|
+
*/
|
|
102
|
+
restoreDepths: Schema.optionalKey(RestoreDepths)
|
|
103
|
+
}).check(Schema.makeFilter((key) => key.restoreDepths === void 0 || key.restoreDepths.every((depth) => depth <= key.segments.length - 1) ? void 0 : "every restore depth must be between 1 and segments.length - 1 — a rung keeping every segment would just repeat the primary key"))) {
|
|
75
104
|
/** The primary key: every segment, joined. */
|
|
76
105
|
get key() {
|
|
77
106
|
return this.segments.join(SEPARATOR);
|
|
@@ -80,16 +109,85 @@ segments: Segments }) {
|
|
|
80
109
|
* The restore keys, most specific first.
|
|
81
110
|
*
|
|
82
111
|
* @remarks
|
|
83
|
-
*
|
|
84
|
-
* three-segment key falls back to two prefixes and a
|
|
85
|
-
* back to nothing — there is no rung that would match
|
|
86
|
-
* repository.
|
|
112
|
+
* By default each rung drops the last segment and keeps the trailing
|
|
113
|
+
* separator, so a three-segment key falls back to two prefixes and a
|
|
114
|
+
* one-segment key falls back to nothing — there is no rung that would match
|
|
115
|
+
* every cache in the repository.
|
|
116
|
+
*
|
|
117
|
+
* **The default ladder is every prefix**, which is wrong for a key whose
|
|
118
|
+
* later segments must never be dropped alone: a
|
|
119
|
+
* `platform-arch-versionHash-branchHash-lockfileHash` key derives rungs
|
|
120
|
+
* without the version digest, and those resurrect stale cross-version
|
|
121
|
+
* caches. A key built with {@link CacheKey.withRestoreDepths} carries its
|
|
122
|
+
* own ladder instead, and this getter answers exactly that ladder — so
|
|
123
|
+
* `ActionCache.restore` picks the policy up through the same typed-key
|
|
124
|
+
* path, no hand-built restore-key list required. An **empty** policy
|
|
125
|
+
* ({@link CacheKey.withoutRestoreKeys}) answers no rungs at all: only
|
|
126
|
+
* *absence* selects the default ladder, so "none" is never mistaken for
|
|
127
|
+
* "every prefix".
|
|
87
128
|
*/
|
|
88
129
|
get restoreKeys() {
|
|
130
|
+
const rung = (length) => `${this.segments.slice(0, length).join(SEPARATOR)}${SEPARATOR}`;
|
|
131
|
+
if (this.restoreDepths !== void 0) return this.restoreDepths.map(rung);
|
|
89
132
|
const rungs = [];
|
|
90
|
-
for (let length = this.segments.length - 1; length >= 1; length -= 1) rungs.push(
|
|
133
|
+
for (let length = this.segments.length - 1; length >= 1; length -= 1) rungs.push(rung(length));
|
|
91
134
|
return rungs;
|
|
92
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* A copy of this key carrying an explicit restore-key ladder policy.
|
|
138
|
+
*
|
|
139
|
+
* @remarks
|
|
140
|
+
* Each depth is the number of **leading segments** the rung keeps, and the
|
|
141
|
+
* rungs are emitted in exactly the order given — GitHub tries restore keys
|
|
142
|
+
* in order, so the order IS the policy. Descending depths (most specific
|
|
143
|
+
* first) are what a fallback ladder almost always wants; that convention is
|
|
144
|
+
* recommended rather than enforced, because rung ordering is the consumer's
|
|
145
|
+
* policy where commas, newlines and the length limit are the runner's
|
|
146
|
+
* protocol. A depth outside `1..segments.length - 1` is refused at
|
|
147
|
+
* construction: `0` would match every cache in the repository, and
|
|
148
|
+
* `segments.length` would just repeat the primary key.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
153
|
+
*
|
|
154
|
+
* const key = CacheKey.of("Linux", "X64", "v1hash", "main", "lockhash").withRestoreDepths([4, 3]);
|
|
155
|
+
* key.restoreKeys; // ["Linux-X64-v1hash-main-", "Linux-X64-v1hash-"]
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
withRestoreDepths(depths) {
|
|
159
|
+
return CacheKey.make({
|
|
160
|
+
segments: this.segments,
|
|
161
|
+
restoreDepths: depths
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A copy of this key that restores from its **exact key only** — no ladder.
|
|
166
|
+
*
|
|
167
|
+
* @remarks
|
|
168
|
+
* The third point in the policy space, and the one the typed key had no
|
|
169
|
+
* spelling for: absence of a policy means the default every-prefix ladder,
|
|
170
|
+
* so exact-match-only restores (a cache-bust mode, where a stale partial
|
|
171
|
+
* hit is worse than a cold start) previously forced the caller back to the
|
|
172
|
+
* string form. This is the same `restoreDepths` field carrying **zero
|
|
173
|
+
* rungs** — an honest value, not a sentinel: `ActionCache.restore` sends no
|
|
174
|
+
* `restore_keys` at all, so the backend answers this key or a miss.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
179
|
+
*
|
|
180
|
+
* const key = CacheKey.of("Linux", "pnpm-store", "abc123").withoutRestoreKeys();
|
|
181
|
+
* key.key; // "Linux-pnpm-store-abc123"
|
|
182
|
+
* key.restoreKeys; // []
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
withoutRestoreKeys() {
|
|
186
|
+
return CacheKey.make({
|
|
187
|
+
segments: this.segments,
|
|
188
|
+
restoreDepths: []
|
|
189
|
+
});
|
|
190
|
+
}
|
|
93
191
|
/** Build a key from its segments. */
|
|
94
192
|
static of(...segments) {
|
|
95
193
|
return CacheKey.make({ segments });
|
package/CheckDocument.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { CheckState } from "./CheckState.js";
|
|
2
|
+
import { ManagedDocument } from "./ManagedDocument.js";
|
|
3
|
+
import { Clock, Context, Duration, Effect, Latch, Layer, Ref, Schema, Semaphore } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/CheckDocument.ts
|
|
6
|
+
/**
|
|
7
|
+
* What a run knows about one of its checks: the authoritative state, and the
|
|
8
|
+
* presentation facts a renderer projects into the document.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Only `state` is required. The rest exist because a status table needs them:
|
|
12
|
+
* `title` when the display name differs from the registry key, `outcome` for
|
|
13
|
+
* the one-line verdict cell, `url` for linking the check's name, and `detail`
|
|
14
|
+
* for the per-check block a body region carries. A later report for the same
|
|
15
|
+
* key **replaces** the whole entry — last write wins, per field and per check.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
var CheckReport = class extends Schema.Class("CheckReport")({
|
|
20
|
+
/** Where the check is now. The last reported state is the authoritative one. */
|
|
21
|
+
state: CheckState,
|
|
22
|
+
/** Display name, when it differs from the registry key. */
|
|
23
|
+
title: Schema.optionalKey(Schema.String),
|
|
24
|
+
/** A one-line verdict, e.g. `in progress...` or `3 packages ready`. */
|
|
25
|
+
outcome: Schema.optionalKey(Schema.String),
|
|
26
|
+
/** A pre-rendered markdown block of per-check detail. */
|
|
27
|
+
detail: Schema.optionalKey(Schema.String),
|
|
28
|
+
/** Where the check's name should link. */
|
|
29
|
+
url: Schema.optionalKey(Schema.String)
|
|
30
|
+
}) {};
|
|
31
|
+
/**
|
|
32
|
+
* Raised when the check document cannot be regenerated or written.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* `render` — projecting the registry onto the document failed, almost always
|
|
36
|
+
* because a reported `detail` contains a line the region scanner would read
|
|
37
|
+
* as a marker; the underlying `ManagedDocumentError` rides in `cause`.
|
|
38
|
+
* `sink` — the consumer's write effect failed; its error rides in `cause`.
|
|
39
|
+
* Callers of `flush` branch on `kind`; the background reconciler never fails
|
|
40
|
+
* a fiber with this — it logs and retries on the next report.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
var CheckDocumentError = class extends Schema.TaggedErrorClass()("CheckDocumentError", {
|
|
45
|
+
/** Which half failed: regenerating the document, or writing it. */
|
|
46
|
+
kind: Schema.Literals(["render", "sink"]),
|
|
47
|
+
/** The underlying failure. */
|
|
48
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
49
|
+
}) {
|
|
50
|
+
get message() {
|
|
51
|
+
return this.kind === "render" ? "The check document could not be regenerated from the reported state" : "Writing the check document failed";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A living document of check state: an in-process registry the run reports
|
|
56
|
+
* into, debounced onto a marker-delimited document, written through a narrow
|
|
57
|
+
* sink.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* **Push, not pull.** The run owns its checks and knows when they resolve;
|
|
61
|
+
* nothing here polls GitHub. Consumers call `report` as states change, and a
|
|
62
|
+
* background fiber projects the registry onto a {@link ManagedDocument} —
|
|
63
|
+
* regions replaced from current state, so a check that resolves many times
|
|
64
|
+
* updates its region in place instead of appending.
|
|
65
|
+
*
|
|
66
|
+
* The debounce is **trailing with a max-wait, never leading**: a burst of
|
|
67
|
+
* reports coalesces into one write carrying the *final* state, and a steady
|
|
68
|
+
* stream still surfaces progress every `maxWait`. A pass that renders
|
|
69
|
+
* byte-identical text issues no write at all.
|
|
70
|
+
*
|
|
71
|
+
* `layer` mints fresh state per call — one layer per document. Bind it to a
|
|
72
|
+
* `const` if two parts of a program must share one registry.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* import { CheckDocument, CheckReport, GitHubMarkdown } from "@effected/github-actions";
|
|
77
|
+
* import { Effect } from "effect";
|
|
78
|
+
*
|
|
79
|
+
* const layer = CheckDocument.layer({
|
|
80
|
+
* namespace: "my-action",
|
|
81
|
+
* key: "release-validation",
|
|
82
|
+
* render: (checks) => [
|
|
83
|
+
* [
|
|
84
|
+
* "header",
|
|
85
|
+
* GitHubMarkdown.table(
|
|
86
|
+
* ["Check", "Outcome"],
|
|
87
|
+
* [...checks].map(([key, check]) => [key, check.outcome ?? check.state]),
|
|
88
|
+
* ),
|
|
89
|
+
* ],
|
|
90
|
+
* ],
|
|
91
|
+
* sink: (rendered) => Effect.log(rendered),
|
|
92
|
+
* });
|
|
93
|
+
*
|
|
94
|
+
* const program = Effect.gen(function* () {
|
|
95
|
+
* const doc = yield* CheckDocument;
|
|
96
|
+
* yield* doc.report("build", CheckReport.make({ state: "running" }));
|
|
97
|
+
* yield* doc.report("build", CheckReport.make({ state: "pass", outcome: "clean" }));
|
|
98
|
+
* yield* doc.flush;
|
|
99
|
+
* });
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @public
|
|
103
|
+
*/
|
|
104
|
+
var CheckDocument = class CheckDocument extends Context.Service()("@effected/github-actions/CheckDocument") {
|
|
105
|
+
/**
|
|
106
|
+
* The reconciler over one document.
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* Forks the debounce fiber into the layer's scope and registers a
|
|
110
|
+
* finalizer that flushes once more on the way out, so the scope closing
|
|
111
|
+
* mid-window cannot strand the final state. A background pass that fails
|
|
112
|
+
* logs a structured warning and leaves the registry intact — the next
|
|
113
|
+
* report retries it; only `flush` surfaces the typed error.
|
|
114
|
+
*/
|
|
115
|
+
static layer(options) {
|
|
116
|
+
return Layer.effect(CheckDocument, Effect.gen(function* () {
|
|
117
|
+
const quietMillis = Duration.toMillis(options.debounce?.quiet ?? "500 millis");
|
|
118
|
+
const maxWaitMillis = Math.max(Duration.toMillis(options.debounce?.maxWait ?? "3 seconds"), quietMillis);
|
|
119
|
+
const checksRef = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
120
|
+
const writtenRef = yield* Ref.make(options.initial ?? "");
|
|
121
|
+
const versionRef = yield* Ref.make(0);
|
|
122
|
+
const signal = yield* Latch.make(false);
|
|
123
|
+
const gate = yield* Semaphore.make(1);
|
|
124
|
+
const reconcile = Effect.fn("CheckDocument.reconcile")(function* () {
|
|
125
|
+
const checks = yield* Ref.get(checksRef);
|
|
126
|
+
const current = yield* Ref.get(writtenRef);
|
|
127
|
+
const next = yield* (yield* ManagedDocument.parse({
|
|
128
|
+
namespace: options.namespace,
|
|
129
|
+
key: options.key,
|
|
130
|
+
text: current
|
|
131
|
+
}).pipe(Effect.mapError((cause) => new CheckDocumentError({
|
|
132
|
+
kind: "render",
|
|
133
|
+
cause
|
|
134
|
+
})))).withRegions(options.render(checks)).pipe(Effect.mapError((cause) => new CheckDocumentError({
|
|
135
|
+
kind: "render",
|
|
136
|
+
cause
|
|
137
|
+
})));
|
|
138
|
+
if (next.text === current) return;
|
|
139
|
+
yield* options.sink(next.text).pipe(Effect.timeout(options.sinkTimeout ?? "30 seconds"), Effect.mapError((cause) => new CheckDocumentError({
|
|
140
|
+
kind: "sink",
|
|
141
|
+
cause
|
|
142
|
+
})));
|
|
143
|
+
yield* Ref.set(writtenRef, next.text);
|
|
144
|
+
});
|
|
145
|
+
const flush = gate.withPermits(1)(reconcile());
|
|
146
|
+
const daemon = Effect.gen(function* () {
|
|
147
|
+
while (true) {
|
|
148
|
+
yield* signal.await;
|
|
149
|
+
yield* signal.close;
|
|
150
|
+
const start = yield* Clock.currentTimeMillis;
|
|
151
|
+
let settled = false;
|
|
152
|
+
while (!settled) {
|
|
153
|
+
const before = yield* Ref.get(versionRef);
|
|
154
|
+
yield* Effect.sleep(quietMillis);
|
|
155
|
+
const after = yield* Ref.get(versionRef);
|
|
156
|
+
const now = yield* Clock.currentTimeMillis;
|
|
157
|
+
settled = after === before || now - start >= maxWaitMillis;
|
|
158
|
+
}
|
|
159
|
+
yield* flush.pipe(Effect.catch((failure) => Effect.logWarning("CheckDocument reconcile failed; retrying on the next report", { kind: failure.kind })));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
yield* Effect.addFinalizer(() => flush.pipe(Effect.catch((failure) => Effect.logWarning("CheckDocument final flush failed", { kind: failure.kind }))));
|
|
163
|
+
yield* Effect.forkScoped(daemon);
|
|
164
|
+
return {
|
|
165
|
+
report: Effect.fn("CheckDocument.report")(function* (check, entry) {
|
|
166
|
+
yield* Ref.update(checksRef, (current) => {
|
|
167
|
+
const next = new Map(current);
|
|
168
|
+
next.set(check, entry);
|
|
169
|
+
return next;
|
|
170
|
+
});
|
|
171
|
+
yield* Ref.update(versionRef, (count) => count + 1);
|
|
172
|
+
yield* signal.open;
|
|
173
|
+
}),
|
|
174
|
+
checks: Ref.get(checksRef),
|
|
175
|
+
flush
|
|
176
|
+
};
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
/** An inert double; unstubbed members die naming themselves. */
|
|
180
|
+
static makeTest = (overrides = {}) => ({
|
|
181
|
+
report: overrides.report ?? (() => Effect.sync(() => unstubbed("report"))),
|
|
182
|
+
checks: overrides.checks ?? Effect.sync(() => unstubbed("checks")),
|
|
183
|
+
flush: overrides.flush ?? Effect.sync(() => unstubbed("flush"))
|
|
184
|
+
});
|
|
185
|
+
/** {@link CheckDocument.makeTest} behind a `Layer`. */
|
|
186
|
+
static layerTest = (overrides = {}) => Layer.succeed(CheckDocument, CheckDocument.makeTest(overrides));
|
|
187
|
+
};
|
|
188
|
+
const unstubbed = (member) => {
|
|
189
|
+
throw new Error(`CheckDocument.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
//#endregion
|
|
193
|
+
export { CheckDocument, CheckDocumentError, CheckReport };
|
package/CheckState.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/CheckState.ts
|
|
4
|
+
/**
|
|
5
|
+
* The kit's check-state vocabulary.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Deliberately **wider than GitHub's own check-run conclusions**: `running` is
|
|
9
|
+
* a first-class state rather than the absence of a conclusion, and
|
|
10
|
+
* `user_interaction_required` names a release pipeline waiting on a human. A
|
|
11
|
+
* check's lifecycle is a *sequence* of these states, and only the last one is
|
|
12
|
+
* authoritative — resolution is not a terminal transition, so a check may run
|
|
13
|
+
* `running`, `pass`, `running`, `fail` in order, and that means `fail`.
|
|
14
|
+
*
|
|
15
|
+
* Every state projects onto GitHub's check-run wire vocabulary through
|
|
16
|
+
* {@link projectCheckState}. GitHub's `cancelled` conclusion has no
|
|
17
|
+
* counterpart here on purpose: cancellation is something the runner does *to*
|
|
18
|
+
* a run, not a state a pipeline reports about its own checks.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
const CheckState = Schema.Literals([
|
|
23
|
+
"running",
|
|
24
|
+
"pass",
|
|
25
|
+
"fail",
|
|
26
|
+
"warn",
|
|
27
|
+
"user_interaction_required",
|
|
28
|
+
"skipped",
|
|
29
|
+
"timeout"
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Project a kit check state onto GitHub's check-run wire vocabulary.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* The canonical mapping: `running` is `in_progress`; everything else is
|
|
36
|
+
* `completed` with `pass → success`, `fail → failure`, `warn → neutral`
|
|
37
|
+
* (GitHub has no warning conclusion, and `neutral` is its non-failing,
|
|
38
|
+
* non-success verdict), `user_interaction_required → action_required`,
|
|
39
|
+
* `skipped → skipped` and `timeout → timed_out`.
|
|
40
|
+
*
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
const projectCheckState = (state) => {
|
|
44
|
+
switch (state) {
|
|
45
|
+
case "running": return { status: "in_progress" };
|
|
46
|
+
case "pass": return {
|
|
47
|
+
status: "completed",
|
|
48
|
+
conclusion: "success"
|
|
49
|
+
};
|
|
50
|
+
case "fail": return {
|
|
51
|
+
status: "completed",
|
|
52
|
+
conclusion: "failure"
|
|
53
|
+
};
|
|
54
|
+
case "warn": return {
|
|
55
|
+
status: "completed",
|
|
56
|
+
conclusion: "neutral"
|
|
57
|
+
};
|
|
58
|
+
case "user_interaction_required": return {
|
|
59
|
+
status: "completed",
|
|
60
|
+
conclusion: "action_required"
|
|
61
|
+
};
|
|
62
|
+
case "skipped": return {
|
|
63
|
+
status: "completed",
|
|
64
|
+
conclusion: "skipped"
|
|
65
|
+
};
|
|
66
|
+
case "timeout": return {
|
|
67
|
+
status: "completed",
|
|
68
|
+
conclusion: "timed_out"
|
|
69
|
+
};
|
|
70
|
+
default: throw new Error(`Unhandled CheckState: ${String(state)}`);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
export { CheckState, projectCheckState };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Result, Schema, SchemaAST } from "effect";
|
|
2
|
+
import { Code, Html, InlineCode, Link, List, ListItem, Markdown, Paragraph, Root, Table, TableCell, TableRow } from "@effected/markdown";
|
|
3
|
+
|
|
4
|
+
//#region src/GitHubMarkdown.ts
|
|
5
|
+
/**
|
|
6
|
+
* A block node whose value passes through the serializer verbatim, which is
|
|
7
|
+
* exactly how a pre-rendered markdown fragment must travel: GFM structure
|
|
8
|
+
* around it is escaped, the fragment itself is not.
|
|
9
|
+
*/
|
|
10
|
+
const passthrough = (value) => Html.make({ value });
|
|
11
|
+
/**
|
|
12
|
+
* Serialize a tree the writer built itself.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* `Markdown.stringifyResult` is total over every tree this module can
|
|
16
|
+
* construct — its only failure is the hardening depth cap, which a
|
|
17
|
+
* fixed-shape two-level tree cannot reach — so the impossible arm is a
|
|
18
|
+
* defect, **not** a silent fallback to unescaped string joining. The
|
|
19
|
+
* predecessor's fallback arm is the live table-corruption defect this module
|
|
20
|
+
* exists to delete.
|
|
21
|
+
*/
|
|
22
|
+
const render = (root) => Result.getOrThrow(Markdown.stringifyResult(root)).trimEnd();
|
|
23
|
+
const block = (node) => render(Root.make({ children: [node] }));
|
|
24
|
+
/**
|
|
25
|
+
* The fluent markdown writer for GitHub surfaces — PR comments, check-run
|
|
26
|
+
* summaries, job summaries.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Every member takes **pre-rendered markdown** (links, emphasis, emoji — the
|
|
30
|
+
* caller's fragments pass through verbatim) and returns a `string`, so
|
|
31
|
+
* compositions read as plain string assembly. What the writer owns is the
|
|
32
|
+
* *structure*: wherever a fragment could break the construct around it, the
|
|
33
|
+
* output goes through `@effected/markdown`'s node classes and serializer
|
|
34
|
+
* rather than string joining. A table cell carrying `>=1 || <2` renders with
|
|
35
|
+
* its pipes escaped instead of shifting every column after it; a code block
|
|
36
|
+
* whose content contains a fence gets a longer fence; inline code containing
|
|
37
|
+
* a backtick gets a wider delimiter; a URL containing spaces or parentheses
|
|
38
|
+
* is angle-bracketed.
|
|
39
|
+
*
|
|
40
|
+
* This module is the package's **only** importer of `@effected/markdown` — a
|
|
41
|
+
* structural test pins that, on the same terms as the Azure confinement — so
|
|
42
|
+
* an action that never renders markdown never links the engine.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* import { GitHubMarkdown } from "@effected/github-actions";
|
|
47
|
+
*
|
|
48
|
+
* const body = [
|
|
49
|
+
* GitHubMarkdown.heading("Release Validation"),
|
|
50
|
+
* GitHubMarkdown.table(["Check", "Outcome"], [["build", "✅ passed"]]),
|
|
51
|
+
* GitHubMarkdown.details("Logs", GitHubMarkdown.codeBlock(log, "text")),
|
|
52
|
+
* ].join("\n\n");
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
var GitHubMarkdown = class GitHubMarkdown {
|
|
58
|
+
constructor() {}
|
|
59
|
+
/**
|
|
60
|
+
* A GFM table.
|
|
61
|
+
*
|
|
62
|
+
* @remarks
|
|
63
|
+
* Cells are pre-rendered markdown; the serializer escapes the pipes GFM
|
|
64
|
+
* would otherwise read as column breaks. Rows shorter than the header are
|
|
65
|
+
* padded with empty cells rather than shifting.
|
|
66
|
+
*/
|
|
67
|
+
static table(headers, rows) {
|
|
68
|
+
const inline = (value) => value.replace(/\r?\n/g, "<br>");
|
|
69
|
+
const cells = (values) => TableRow.make({ children: Array.from({ length: headers.length }, (_, index) => TableCell.make({ children: [passthrough(inline(values[index] ?? ""))] })) });
|
|
70
|
+
return block(Table.make({ children: [cells(headers), ...rows.map(cells)] }));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A GFM table whose columns are defined ONCE by a row schema.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* The schema is the single authority for the table's shape: column order
|
|
77
|
+
* is field declaration order, each header is the field's `title`
|
|
78
|
+
* annotation (fall back: the property name, overridable per column), and
|
|
79
|
+
* each cell is the field value's **encoded** string form — a branded or
|
|
80
|
+
* typed field projects through its own codec, so the vocabulary lives
|
|
81
|
+
* with the schema instead of being respelled at every call site, and a
|
|
82
|
+
* row can no longer transpose columns because it is a typed object, not
|
|
83
|
+
* a positional array.
|
|
84
|
+
*
|
|
85
|
+
* A field whose encoded side is not a string has no string projection to
|
|
86
|
+
* borrow; the type of `options` makes its column's `format` — and
|
|
87
|
+
* therefore `columns` and `options` themselves — required. An absent or
|
|
88
|
+
* `undefined` optional field renders as an empty cell without consulting
|
|
89
|
+
* codec or formatter.
|
|
90
|
+
*
|
|
91
|
+
* The renderer is a pure value: `render` maps current rows through
|
|
92
|
+
* {@link GitHubMarkdown.table}, so escaping is inherited and identical
|
|
93
|
+
* rows produce identical output. Rows are already-decoded values of the
|
|
94
|
+
* schema's type, so encoding them is total in practice; a value smuggled
|
|
95
|
+
* past the types throws the codec's `SchemaError` as a defect — same
|
|
96
|
+
* posture as the serializer arm above, **not** a silent fallback.
|
|
97
|
+
*
|
|
98
|
+
* Only string-keyed fields become columns; symbol keys are not
|
|
99
|
+
* enumerable table columns.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* import { GitHubMarkdown } from "@effected/github-actions";
|
|
104
|
+
* import { Schema } from "effect";
|
|
105
|
+
*
|
|
106
|
+
* const CheckRow = Schema.Struct({
|
|
107
|
+
* name: Schema.String.annotate({ title: "Check" }),
|
|
108
|
+
* outcome: Schema.Literals(["passed", "failed"]),
|
|
109
|
+
* });
|
|
110
|
+
* const checks = GitHubMarkdown.tableFor(CheckRow);
|
|
111
|
+
* const body = checks.render([{ name: "build", outcome: "passed" }]);
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
static tableFor(schema, ...options) {
|
|
115
|
+
const overrides = options[0]?.columns ?? {};
|
|
116
|
+
const columns = Object.keys(schema.fields).map((key) => {
|
|
117
|
+
const field = schema.fields[key];
|
|
118
|
+
const column = overrides[key];
|
|
119
|
+
const format = column?.format;
|
|
120
|
+
const encode = format === void 0 ? Schema.encodeSync(field) : void 0;
|
|
121
|
+
return {
|
|
122
|
+
key,
|
|
123
|
+
header: column?.header ?? SchemaAST.resolveTitle(field.ast) ?? key,
|
|
124
|
+
cell: (value) => value === void 0 ? "" : format !== void 0 ? format(value) : encode?.(value) ?? ""
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
const headers = columns.map((column) => column.header);
|
|
128
|
+
return { render: (rows) => GitHubMarkdown.table(headers, rows.map((row) => columns.map((column) => column.cell(row[column.key])))) };
|
|
129
|
+
}
|
|
130
|
+
/** An ATX heading; the level defaults to `2`. */
|
|
131
|
+
static heading(text, level = 2) {
|
|
132
|
+
return `${"#".repeat(level)} ${text}`;
|
|
133
|
+
}
|
|
134
|
+
/** A link. URLs the destination cannot carry raw are angle-bracketed. */
|
|
135
|
+
static link(text, url) {
|
|
136
|
+
return block(Paragraph.make({ children: [Link.make({
|
|
137
|
+
url,
|
|
138
|
+
children: [passthrough(text)]
|
|
139
|
+
})] }));
|
|
140
|
+
}
|
|
141
|
+
/** Inline code. Content containing backticks widens the delimiter. */
|
|
142
|
+
static code(text) {
|
|
143
|
+
return block(Paragraph.make({ children: [InlineCode.make({ value: text })] }));
|
|
144
|
+
}
|
|
145
|
+
/** A fenced code block. Content containing a fence lengthens the fence. */
|
|
146
|
+
static codeBlock(content, language) {
|
|
147
|
+
return block(Code.make({
|
|
148
|
+
value: content,
|
|
149
|
+
fenceChar: "`",
|
|
150
|
+
...language === void 0 || language === "" ? {} : { lang: language }
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A list of pre-rendered markdown items.
|
|
155
|
+
*
|
|
156
|
+
* @remarks
|
|
157
|
+
* A multi-line item is indented onto continuation lines, so it stays part
|
|
158
|
+
* of its bullet instead of terminating the list.
|
|
159
|
+
*/
|
|
160
|
+
static list(items, options) {
|
|
161
|
+
const ordered = options?.ordered ?? false;
|
|
162
|
+
return block(List.make({
|
|
163
|
+
ordered,
|
|
164
|
+
...ordered ? { start: 1 } : {},
|
|
165
|
+
children: items.map((item) => ListItem.make({ children: [passthrough(item)] }))
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* A collapsible `<details>` block.
|
|
170
|
+
*
|
|
171
|
+
* @remarks
|
|
172
|
+
* mdast has no details node, so this is the one construct the writer emits
|
|
173
|
+
* as raw HTML. The blank lines around `body` are what make GitHub render
|
|
174
|
+
* the markdown inside it; `summary` is HTML context — markdown is **not**
|
|
175
|
+
* rendered there, so bold a summary with `<strong>`, not `**`.
|
|
176
|
+
*/
|
|
177
|
+
static details(summary, body) {
|
|
178
|
+
return `<details>\n<summary>${summary}</summary>\n\n${body}\n\n</details>`;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Pre-rendered markdown, passed through by name.
|
|
182
|
+
*
|
|
183
|
+
* @remarks
|
|
184
|
+
* The identity function, deliberately: every writer member already treats
|
|
185
|
+
* its fragments as markdown, so `raw` adds no behavior — it adds the
|
|
186
|
+
* *statement* that a value is already rendered. Use it where a fragment
|
|
187
|
+
* from elsewhere enters a composition and a reader would otherwise wonder
|
|
188
|
+
* whether it still needs escaping. It replaces the `Html.make` idiom,
|
|
189
|
+
* which passes markdown through correctly but announces the wrong intent.
|
|
190
|
+
*/
|
|
191
|
+
static raw(markdown) {
|
|
192
|
+
return markdown;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
//#endregion
|
|
197
|
+
export { GitHubMarkdown };
|