@effected/github-actions 0.1.0 → 0.2.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/ActionLogger.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ActionEnvironment } from "./ActionEnvironment.js";
2
2
  import { WorkflowCommand } from "./WorkflowCommand.js";
3
- import { Console, Context, Effect, Inspectable, Layer, LogLevel, Logger, References } from "effect";
3
+ import { Console, Context, Effect, Exit, Inspectable, Layer, LogLevel, Logger, References } from "effect";
4
4
 
5
5
  //#region src/ActionLogger.ts
6
6
  /**
@@ -91,6 +91,10 @@ const renderEntry = (level, message, annotations) => {
91
91
  * @internal
92
92
  */
93
93
  const ActiveBuffer = Context.Reference("@effected/github-actions/ActiveBuffer", { defaultValue: () => null });
94
+ /** Drop a transcript without writing it, so a green step stays quiet. */
95
+ const discard = (state) => Effect.sync(() => {
96
+ state.entries.length = 0;
97
+ });
94
98
  /** Write a transcript out and clear it, so a second flush is a no-op. */
95
99
  const flush = (state) => Effect.suspend(() => {
96
100
  if (state.entries.length === 0) return Effect.void;
@@ -107,7 +111,7 @@ const make = Effect.gen(function* () {
107
111
  const flushActive = Effect.flatMap(ActiveBuffer, (state) => state === null ? Effect.void : flush(state));
108
112
  return {
109
113
  group: (name, effect) => Effect.acquireUseRelease(Console.log(WorkflowCommand.group(name)), () => Effect.tapCause(effect, () => flushActive), () => Console.log(WorkflowCommand.endGroup())),
110
- withBuffer: (label, effect) => Effect.gen(function* () {
114
+ withBuffer: (label, effect, options) => Effect.gen(function* () {
111
115
  const minimum = yield* References.MinimumLogLevel;
112
116
  if ((yield* env.isDebug) || LogLevel.isLessThanOrEqualTo(minimum, "Debug")) return yield* effect;
113
117
  const state = {
@@ -122,7 +126,7 @@ const make = Effect.gen(function* () {
122
126
  }
123
127
  state.entries.push(formatMessage(options.message));
124
128
  });
125
- return yield* effect.pipe(Effect.provideService(References.MinimumLogLevel, "All"), Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([buffering])), Effect.provideService(ActiveBuffer, state), Effect.onExit(() => flush(state)));
129
+ return yield* effect.pipe(Effect.provideService(References.MinimumLogLevel, "All"), Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([buffering])), Effect.provideService(ActiveBuffer, state), Effect.onExit((exit) => Exit.isSuccess(exit) && options?.onSuccess === "discard" ? discard(state) : flush(state)));
126
130
  }),
127
131
  notice: (message, properties = {}) => Console.log(WorkflowCommand.notice(message, properties)),
128
132
  annotated: (properties, effect) => Effect.annotateLogs(effect, annotationRecord(properties))
@@ -0,0 +1,61 @@
1
+ import { OidcTokenIssuer } from "./OidcTokenIssuer.js";
2
+ import { Effect, Layer } from "effect";
3
+ import { IdentityToken, IdentityTokenError } from "@effected/sbom";
4
+
5
+ //#region src/ActionsIdentityToken.ts
6
+ /**
7
+ * The Actions side of `@effected/sbom`'s inverted OIDC contract.
8
+ *
9
+ * @remarks
10
+ * `@effected/sbom` declares `IdentityToken` — one method, one audience,
11
+ * one redacted token — precisely so that signing does not drag the Actions
12
+ * runtime into every consumer that only wants to emit an SBOM. This is the
13
+ * layer that closes the inversion from the other side: it serves that contract
14
+ * from the runner's own OIDC token service ({@link OidcTokenIssuer}), the same
15
+ * arrangement as `@effected/workspaces` implementing `@effected/commands`'
16
+ * `LocalExec`.
17
+ *
18
+ * The audience is forwarded verbatim. `SigstoreSigner` asks its identity
19
+ * contract for the `sigstore` audience itself (`SIGSTORE_OIDC_AUDIENCE` is the
20
+ * signing protocol's requirement, owned there), so nothing here — and nothing
21
+ * in a consumer — needs to spell it.
22
+ *
23
+ * An `OidcTokenError` is rewrapped as `IdentityTokenError` with the
24
+ * original preserved structurally in `cause`, so a refused exchange still says
25
+ * which audience it was minting for **and** why the runner declined —
26
+ * `reason: "unavailable"` almost always means the workflow is missing
27
+ * `permissions: id-token: write`.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { ActionsIdentityToken, OidcTokenIssuer } from "@effected/github-actions";
32
+ * import { SigstoreSigner } from "@effected/sbom";
33
+ * import { Layer } from "effect";
34
+ *
35
+ * const signing = SigstoreSigner.layer.pipe(
36
+ * Layer.provide(ActionsIdentityToken.layer),
37
+ * Layer.provide(OidcTokenIssuer.layer),
38
+ * );
39
+ * ```
40
+ *
41
+ * @public
42
+ */
43
+ var ActionsIdentityToken = class {
44
+ constructor() {}
45
+ /**
46
+ * `IdentityToken` served by the runner's OIDC token service.
47
+ *
48
+ * @remarks
49
+ * Requires {@link OidcTokenIssuer} rather than composing
50
+ * `OidcTokenIssuer.layer` in, so an action that already wired the issuer —
51
+ * every action using `ActionRuntime.layer` has its requirements at hand —
52
+ * does not construct a second one.
53
+ */
54
+ static layer = Layer.effect(IdentityToken, Effect.map(OidcTokenIssuer, (issuer) => ({ token: (audience) => issuer.token(audience).pipe(Effect.mapError((cause) => new IdentityTokenError({
55
+ audience,
56
+ cause
57
+ }))) })));
58
+ };
59
+
60
+ //#endregion
61
+ export { ActionsIdentityToken };
@@ -0,0 +1,103 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { OidcTokenIssuer } from "./OidcTokenIssuer.js";
3
+ import { Effect, Option } from "effect";
4
+ import { SlsaProvenance } from "@effected/sbom";
5
+
6
+ //#region src/ActionsProvenance.ts
7
+ /**
8
+ * The server URL a workflow on github.com runs against.
9
+ *
10
+ * @remarks
11
+ * GHES runners export `GITHUB_SERVER_URL`; github.com runners do too, but a
12
+ * program running outside a runner — or under a partial test environment —
13
+ * legitimately sees it absent, and absence has a **correct answer** rather
14
+ * than being an error. See {@link ActionsProvenance.capture}.
15
+ */
16
+ const DEFAULT_SERVER_URL = "https://github.com";
17
+ /**
18
+ * The Actions side of `@effected/sbom`'s SLSA provenance constructor.
19
+ *
20
+ * @remarks
21
+ * `SlsaProvenance.forGitHubWorkflow` is total and does the real work, but it
22
+ * takes a camelCase input record — and the only input anyone inside a workflow
23
+ * actually holds is the runner's own OIDC claims, spelled in GitHub's
24
+ * snake_case claim names. The rename between the two is eleven all-string
25
+ * fields, so transposing `repository_id` and `repository_owner_id` compiles,
26
+ * passes every type check, and produces a validly signed attestation asserting
27
+ * the **wrong** provenance. This module owns that rename once, on the same
28
+ * `github-actions` ↔ `sbom` seam as {@link ActionsIdentityToken}: `sbom` must
29
+ * not depend on the Actions runtime, so the adapter lives here, beside the
30
+ * services it reads.
31
+ *
32
+ * The construct ends at the predicate. Assembling the in-toto statement,
33
+ * signing it and uploading the bundle stay with the consumer — that pipeline
34
+ * is deliberately consumer glue (see `@effected/sbom`'s `SigstoreSigner` and
35
+ * `@effected/github`'s `Attestation.upload`).
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { ActionsProvenance } from "@effected/github-actions";
40
+ * import { Effect } from "effect";
41
+ *
42
+ * const program = Effect.gen(function* () {
43
+ * const provenance = yield* ActionsProvenance.capture();
44
+ * return provenance.runDetails.builder.id;
45
+ * });
46
+ * ```
47
+ *
48
+ * @public
49
+ */
50
+ var ActionsProvenance = class {
51
+ constructor() {}
52
+ /**
53
+ * The current run's SLSA provenance, built from the runner's OIDC claims.
54
+ *
55
+ * @remarks
56
+ * Every field `GitHubWorkflowProvenance` declares is populated from
57
+ * {@link OidcClaims}, except `serverUrl`, which is read from
58
+ * `GITHUB_SERVER_URL` through {@link ActionEnvironment}'s `getOptional`
59
+ * with `https://github.com` as the default — **not** through the
60
+ * `GitHubContext` projection. The projection fails typed when a `GITHUB_*`
61
+ * variable is missing, and a missing server URL is not a failure: it has a
62
+ * correct default. GHES consumers set the variable, github.com consumers
63
+ * never should have to, and neither should think about it. (Upstream
64
+ * `@actions/attest` reads the same variable with no default and writes the
65
+ * literal string `undefined` into every URL it builds — the hazard
66
+ * `GitHubWorkflowProvenance.serverUrl` being a required field exists to
67
+ * prevent.)
68
+ *
69
+ * The audience is forwarded to {@link OidcTokenIssuerShape.claims}
70
+ * verbatim; the claims a provenance reads do not depend on it, so omitting
71
+ * it is fine and spends no extra token mint.
72
+ *
73
+ * An {@link OidcTokenError} passes through **untouched** — not caught, not
74
+ * defaulted, not wrapped. Whether attestation is mandatory or best-effort
75
+ * is the consumer's policy: an action that must not publish unattested
76
+ * lets the error propagate, and one that publishes anyway catches it at
77
+ * its own boundary. `reason: "unavailable"` almost always means the
78
+ * workflow is missing `permissions: id-token: write`.
79
+ */
80
+ static capture = Effect.fn("ActionsProvenance.capture")(function* (audience) {
81
+ const issuer = yield* OidcTokenIssuer;
82
+ const env = yield* ActionEnvironment;
83
+ const claims = yield* issuer.claims(audience);
84
+ const serverUrl = Option.getOrElse(yield* env.getOptional("GITHUB_SERVER_URL"), () => DEFAULT_SERVER_URL);
85
+ return SlsaProvenance.forGitHubWorkflow({
86
+ serverUrl,
87
+ repository: claims.repository,
88
+ ref: claims.ref,
89
+ sha: claims.sha,
90
+ eventName: claims.event_name,
91
+ workflowRef: claims.workflow_ref,
92
+ jobWorkflowRef: claims.job_workflow_ref,
93
+ repositoryId: claims.repository_id,
94
+ repositoryOwnerId: claims.repository_owner_id,
95
+ runnerEnvironment: claims.runner_environment,
96
+ runId: claims.run_id,
97
+ runAttempt: claims.run_attempt
98
+ });
99
+ });
100
+ };
101
+
102
+ //#endregion
103
+ export { ActionsProvenance };
@@ -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 };