@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 +7 -3
- package/ActionsIdentityToken.js +61 -0
- package/ActionsProvenance.js +103 -0
- package/CheckDocument.js +193 -0
- package/CheckState.js +75 -0
- package/GitHubMarkdown.js +197 -0
- package/ManagedDocument.js +281 -0
- package/README.md +241 -0
- package/Secret.js +13 -0
- package/index.d.ts +1009 -167
- package/index.js +8 -2
- package/package.json +6 -3
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 };
|
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 };
|