@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/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: 64 * 1024 * 1024,
87
+ blockSize: 67108864,
88
88
  concurrency: 8,
89
- maxSingleShotSize: 128 * 1024 * 1024
89
+ maxSingleShotSize: 134217728
90
90
  }),
91
91
  catch: (cause) => new BlobTransferError({
92
92
  reason: "uploadFailed",
@@ -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 backend = resultsBackend(yield* ActionEnvironment).pipe(Effect.mapError((name) => new BlobStoreError({
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
- * Each rung drops the last segment and keeps the trailing separator, so a
84
- * three-segment key falls back to two prefixes and a one-segment key falls
85
- * back to nothing — there is no rung that would match every cache in the
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(`${this.segments.slice(0, length).join(SEPARATOR)}${SEPARATOR}`);
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 });
@@ -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 };