@effected/github-actions 0.1.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/Action.js ADDED
@@ -0,0 +1,138 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { ActionInput } from "./ActionInput.js";
3
+ import { WorkflowCommand } from "./WorkflowCommand.js";
4
+ import { ActionLogger } from "./ActionLogger.js";
5
+ import { ActionOutputs } from "./ActionOutputs.js";
6
+ import { ActionState } from "./ActionState.js";
7
+ import { NodeServices } from "@effect/platform-node";
8
+ import { Cause, Effect, Exit, Layer, Option, Result } from "effect";
9
+ import { FetchHttpClient } from "effect/unstable/http";
10
+
11
+ //#region src/Action.ts
12
+ /**
13
+ * The default runtime an action executes inside.
14
+ *
15
+ * @remarks
16
+ * The one place in the kit where `@effect/platform-node` is composed rather
17
+ * than left to the consumer, and the reason is that there is no second answer:
18
+ * a GitHub Action is a Node process on a GitHub-provided runner.
19
+ *
20
+ * **The cache, artifact and blob services are deliberately NOT in here.** They
21
+ * are the only modules that import `@azure/storage-blob`, and folding them into
22
+ * the default runtime would put a blob-storage client in the bundle of every
23
+ * action that merely sets an output. Their requirements are all satisfied by
24
+ * this layer, so taking one costs a caller exactly one line:
25
+ * `Action.run(program, { layer: ActionCache.layer })`.
26
+ *
27
+ * @public
28
+ */
29
+ var ActionRuntime = class {
30
+ constructor() {}
31
+ /**
32
+ * The composed default: the runner services, the platform, an HTTP client
33
+ * and the workflow-command `Logger`.
34
+ *
35
+ * @remarks
36
+ * A bound constant rather than a factory. A layer-returning function mints a
37
+ * fresh layer per call, and layers memoize by reference — a factory here
38
+ * would rebuild the environment snapshot for every composition site.
39
+ */
40
+ static layer = Layer.mergeAll(ActionLogger.layer, ActionLogger.layerLogger, ActionState.layer, ActionInput.layerDefault).pipe(Layer.provideMerge(ActionOutputs.layer), Layer.provideMerge(ActionEnvironment.layer), Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)));
41
+ };
42
+ /**
43
+ * A readable one-line summary of why an action failed.
44
+ *
45
+ * @remarks
46
+ * `[Tag] message` because a workflow log is read by a human scanning for the
47
+ * first red line, and every error in this kit carries both. The `_tag` is what
48
+ * makes two failures with the same wording distinguishable, and the `message`
49
+ * getter is what makes the line worth reading.
50
+ *
51
+ * An interruption has neither, and `Cause.pretty` is the honest answer for it.
52
+ *
53
+ * @public
54
+ */
55
+ const describeCause = (cause) => {
56
+ const failure = Cause.findErrorOption(cause);
57
+ if (Option.isSome(failure)) return describeError(failure.value);
58
+ const defect = Cause.findDefect(cause);
59
+ if (Result.isSuccess(defect)) return `[defect] ${describeError(defect.success)}`;
60
+ const pretty = Cause.pretty(cause);
61
+ return pretty.trim() === "" ? "the action failed with no diagnostic information" : pretty;
62
+ };
63
+ /** The `[Tag]: message` half, over anything a failure or a defect can be. */
64
+ const describeError = (error) => {
65
+ if (typeof error === "object" && error !== null && "_tag" in error) {
66
+ const tagged = error;
67
+ const detail = typeof tagged.message === "string" && tagged.message !== "" ? `: ${tagged.message}` : "";
68
+ return `[${String(tagged._tag)}]${detail}`;
69
+ }
70
+ return error instanceof Error ? `[${error.name}]: ${error.message}` : `[unknown]: ${String(error)}`;
71
+ };
72
+ /**
73
+ * The entry point an action's `main`, `pre` and `post` scripts call.
74
+ *
75
+ * @remarks
76
+ * Everything an action entry point does that is not the action: compose the
77
+ * runtime, run the program, render a failure as a `::error::` annotation, and
78
+ * **set the exit code** — which is the piece
79
+ * `ActionOutputs.setFailed` deliberately leaves alone, so an action that
80
+ * reports a failure and then recovers is not doomed by a side effect it cannot
81
+ * undo.
82
+ *
83
+ * Two things it deliberately does **not** do:
84
+ *
85
+ * - **It does not wrap the program in a log buffer.** The package this replaces
86
+ * did, and an unhandled defect inside the buffer swallowed the entire
87
+ * transcript — the run failed and printed nothing. `ActionLogger.withBuffer`
88
+ * is opt-in and flushes on every exit path including a defect, so the
89
+ * buffering is where the caller can see it.
90
+ * - **It does not throw.** The returned promise always resolves; the failure is
91
+ * in `process.exitCode`, which is what the runner reads. An action entry
92
+ * point that rejected would produce an unhandled rejection *and* a failed
93
+ * step, and only the first would be legible.
94
+ *
95
+ * The rendering depth is deliberate too: one `::error::` line carrying
96
+ * `[Tag]: message`, and the fiddly diagnostics — the full `Cause.pretty` render
97
+ * with its span trace and stack — behind `::debug::`, which the runner shows
98
+ * only when someone turns step debugging on. The package this replaces spliced
99
+ * a JS stack into the visible error, which in a bundled action points at one
100
+ * line of `dist/main.js`.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * import { Action, ActionCache } from "@effected/github-actions";
105
+ *
106
+ * await Action.run(program, { layer: ActionCache.layer });
107
+ * ```
108
+ *
109
+ * @public
110
+ */
111
+ var Action = class {
112
+ constructor() {}
113
+ /** {@link describeCause}, so an action can render its own failures the same way. */
114
+ static describeCause = describeCause;
115
+ /**
116
+ * Run an action program to completion.
117
+ *
118
+ * @remarks
119
+ * Resolves whether the program succeeded or not; read `process.exitCode` for
120
+ * the verdict.
121
+ */
122
+ static run = (program, options = {}) => {
123
+ const extra = options.layer;
124
+ const composed = extra === void 0 ? ActionRuntime.layer : Layer.mergeAll(ActionRuntime.layer, Layer.provide(extra, ActionRuntime.layer));
125
+ const runnable = program.pipe(Effect.provide(composed), Effect.exit, Effect.flatMap((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.sync(() => {
126
+ const detail = Cause.pretty(exit.cause);
127
+ if (detail.trim() !== "") console.log(WorkflowCommand.render("debug", {}, detail));
128
+ console.log(WorkflowCommand.render("error", {}, `Action failed: ${describeCause(exit.cause)}`));
129
+ process.exitCode = 1;
130
+ })));
131
+ return Effect.runPromise(runnable).catch(() => {
132
+ process.exitCode = 1;
133
+ });
134
+ };
135
+ };
136
+
137
+ //#endregion
138
+ export { Action, ActionRuntime, describeCause };
package/ActionCache.js ADDED
@@ -0,0 +1,317 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { BlobTransferError } from "./BlobTransfer.js";
3
+ import { resultsBackend } from "./internal/actionsResults.js";
4
+ import { CONFLICT, isOk, stringField, twirpCall } from "./internal/twirp.js";
5
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
6
+ import { HttpClient } from "effect/unstable/http";
7
+ import { createHash } from "node:crypto";
8
+ import { BlobClient, BlockBlobClient } from "@azure/storage-blob";
9
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10
+
11
+ //#region src/ActionCache.ts
12
+ /**
13
+ * Raised when the runner's cache cannot be read or written.
14
+ *
15
+ * @public
16
+ */
17
+ var ActionCacheError = class extends Schema.TaggedErrorClass()("ActionCacheError", {
18
+ /**
19
+ * `misconfigured` — the results backend is not reachable from here (see
20
+ * {@link ActionCache}). `unreachable` — it could not be contacted, or
21
+ * answered with something that is not a Twirp body. `refused` — it answered,
22
+ * unhappily. `archiveFailed` — `tar` would not pack or unpack the paths.
23
+ * `transferFailed` — the archive itself did not move.
24
+ */
25
+ reason: Schema.Literals([
26
+ "misconfigured",
27
+ "unreachable",
28
+ "refused",
29
+ "archiveFailed",
30
+ "transferFailed"
31
+ ]),
32
+ /** The cache key involved. A stable identifier, never a value. */
33
+ key: Schema.optionalKey(Schema.String),
34
+ /** The HTTP status, when the backend answered. */
35
+ status: Schema.optionalKey(Schema.Number),
36
+ /** What went wrong, when the reason alone does not say. */
37
+ detail: Schema.optionalKey(Schema.String),
38
+ /** `tar`'s own complaint, which is the only useful part of an archive failure. */
39
+ stderr: Schema.optionalKey(Schema.String),
40
+ /** The underlying failure, preserved structurally. */
41
+ cause: Schema.optionalKey(Schema.Defect())
42
+ }) {
43
+ get message() {
44
+ const about = this.key === void 0 ? "" : ` for "${this.key}"`;
45
+ const detail = this.detail === void 0 ? "" : `: ${this.detail}`;
46
+ switch (this.reason) {
47
+ case "misconfigured": return `The Actions cache is not reachable from here${detail}`;
48
+ case "unreachable": return `The Actions cache could not be reached${about}${detail}`;
49
+ case "refused": return `The Actions cache refused the request${about}${this.status === void 0 ? "" : ` with status ${this.status}`}${detail}`;
50
+ case "archiveFailed": return `The cache archive could not be built or extracted${about}${this.stderr === void 0 ? "" : `: ${this.stderr}`}`;
51
+ default: return `The cache archive did not transfer${about}`;
52
+ }
53
+ }
54
+ };
55
+ /** The Twirp service the Actions cache protocol lives under. */
56
+ const SERVICE = "github.actions.results.api.v1.CacheService";
57
+ /**
58
+ * The entry version, byte-compatible with `actions/cache`.
59
+ *
60
+ * @remarks
61
+ * A cache entry is keyed on `(key, version)`, and the version is a digest of
62
+ * the archived paths plus the compression method. **The paths are not sorted**,
63
+ * because upstream does not sort them: sorting here would compute a version no
64
+ * other cache step in the workflow agrees with, and the entry a sibling step
65
+ * wrote would simply never be found.
66
+ */
67
+ const versionOf = (paths) => createHash("sha256").update([
68
+ ...paths,
69
+ "gzip",
70
+ "1.0"
71
+ ].join("|")).digest("hex");
72
+ /**
73
+ * The Azure half, duplicated on purpose.
74
+ *
75
+ * @remarks
76
+ * `@azure/storage-blob` may be imported here, by `Artifact` and by
77
+ * `BlobStore.githubCache`, and nowhere else — hoisting these two calls into a
78
+ * shared `internal/` helper is exactly how a heavy import leaks into the graph
79
+ * of a module that only sets an output. `__test__/reachability.test.ts`
80
+ * measures that these are the only three.
81
+ */
82
+ const azure = {
83
+ uploadFile: (url, file) => Effect.tryPromise({
84
+ try: () => new BlockBlobClient(url).uploadFile(file, {
85
+ blockSize: 64 * 1024 * 1024,
86
+ concurrency: 8,
87
+ maxSingleShotSize: 128 * 1024 * 1024
88
+ }),
89
+ catch: (cause) => new BlobTransferError({
90
+ reason: "uploadFailed",
91
+ cause
92
+ })
93
+ }).pipe(Effect.asVoid),
94
+ downloadToFile: (url, file) => Effect.tryPromise({
95
+ try: () => new BlobClient(url).downloadToFile(file),
96
+ catch: (cause) => new BlobTransferError({
97
+ reason: "downloadFailed",
98
+ cause
99
+ })
100
+ }).pipe(Effect.asVoid)
101
+ };
102
+ const fromTwirp = (failure, key) => failure.kind === "status" ? new ActionCacheError({
103
+ reason: "refused",
104
+ key,
105
+ detail: failure.method,
106
+ ...failure.status === void 0 ? {} : { status: failure.status }
107
+ }) : new ActionCacheError({
108
+ reason: "unreachable",
109
+ key,
110
+ detail: `${failure.method} did not answer with a Twirp body`,
111
+ ...failure.cause === void 0 ? {} : { cause: failure.cause }
112
+ });
113
+ /** The primary key and the ladder to fall back through. */
114
+ const ladder = (key, restoreKeys) => typeof key === "string" ? {
115
+ primary: key,
116
+ fallbacks: restoreKeys ?? []
117
+ } : {
118
+ primary: key.key,
119
+ fallbacks: restoreKeys ?? key.restoreKeys
120
+ };
121
+ const make = (transfer) => Effect.gen(function* () {
122
+ const http = yield* HttpClient.HttpClient;
123
+ const fs = yield* FileSystem.FileSystem;
124
+ const path = yield* Path.Path;
125
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
126
+ const env = yield* ActionEnvironment;
127
+ const windows = yield* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.match(found, {
128
+ onNone: () => false,
129
+ onSome: (os) => os.toLowerCase() === "windows"
130
+ }));
131
+ const backend = resultsBackend(env).pipe(Effect.mapError((name) => new ActionCacheError({
132
+ reason: "misconfigured",
133
+ detail: `${name} is not set — the Actions cache is only reachable from a \`uses:\` step, never from \`run:\``
134
+ })));
135
+ const call = (method, body, key) => Effect.gen(function* () {
136
+ const { baseUrl, token } = yield* backend;
137
+ return yield* twirpCall({
138
+ http,
139
+ baseUrl,
140
+ service: SERVICE,
141
+ token,
142
+ method,
143
+ body
144
+ }).pipe(Effect.mapError((failure) => fromTwirp(failure, key)));
145
+ });
146
+ /**
147
+ * Run `tar`, keeping its stderr.
148
+ *
149
+ * @remarks
150
+ * On Windows, extraction runs with `-k` so a file another process holds
151
+ * open is skipped rather than failing the whole restore — and `tar` then
152
+ * exits 1 to say so, which is a warning rather than an error. Exit 2 and
153
+ * above stays a failure on every platform.
154
+ */
155
+ const tar = (args, key, tolerateWarnings) => Effect.gen(function* () {
156
+ const command = ChildProcess.make("tar", [...args]);
157
+ const output = yield* spawner.string(command, { includeStderr: true }).pipe(Effect.mapError((cause) => new ActionCacheError({
158
+ reason: "archiveFailed",
159
+ key,
160
+ cause
161
+ })));
162
+ const code = yield* spawner.exitCode(command).pipe(Effect.mapError((cause) => new ActionCacheError({
163
+ reason: "archiveFailed",
164
+ key,
165
+ cause
166
+ })));
167
+ if (code !== 0 && !(tolerateWarnings && code === 1)) return yield* Effect.fail(new ActionCacheError({
168
+ reason: "archiveFailed",
169
+ key,
170
+ stderr: output.trim()
171
+ }));
172
+ });
173
+ /** A scratch archive path, removed on every exit path. */
174
+ const withArchive = (key, use) => Effect.acquireUseRelease(fs.makeTempDirectory({ prefix: "effected-cache-" }).pipe(Effect.mapError((cause) => new ActionCacheError({
175
+ reason: "archiveFailed",
176
+ key,
177
+ cause
178
+ }))), (directory) => use(path.join(directory, "cache.tar.gz")), (directory) => Effect.ignore(fs.remove(directory, {
179
+ recursive: true,
180
+ force: true
181
+ })));
182
+ const moved = (key) => Effect.mapError((cause) => new ActionCacheError({
183
+ reason: "transferFailed",
184
+ key,
185
+ cause
186
+ }));
187
+ return {
188
+ save: Effect.fn("ActionCache.save")(function* (paths, key) {
189
+ const { primary } = ladder(key, void 0);
190
+ yield* Effect.annotateCurrentSpan({ key: primary });
191
+ const version = versionOf(paths);
192
+ if (paths.length === 0) return yield* Effect.fail(new ActionCacheError({
193
+ reason: "archiveFailed",
194
+ key: primary,
195
+ detail: "no paths were given to cache"
196
+ }));
197
+ yield* withArchive(primary, (archive) => Effect.gen(function* () {
198
+ yield* tar([
199
+ "czPf",
200
+ archive,
201
+ ...paths
202
+ ], primary, false);
203
+ const size = yield* fs.stat(archive).pipe(Effect.mapError((cause) => new ActionCacheError({
204
+ reason: "archiveFailed",
205
+ key: primary,
206
+ cause
207
+ })));
208
+ const created = yield* call("CreateCacheEntry", {
209
+ key: primary,
210
+ version
211
+ }, primary);
212
+ if (created === CONFLICT) return;
213
+ const url = stringField(created, "signedUploadUrl");
214
+ if (!isOk(created) || url === void 0) return yield* Effect.fail(new ActionCacheError({
215
+ reason: "refused",
216
+ key: primary,
217
+ detail: "CreateCacheEntry returned no upload url"
218
+ }));
219
+ yield* transfer.uploadFile(url, archive).pipe(moved(primary));
220
+ const finalized = yield* call("FinalizeCacheEntryUpload", {
221
+ key: primary,
222
+ version,
223
+ size_bytes: String(size.size)
224
+ }, primary);
225
+ if (finalized === CONFLICT || !isOk(finalized)) return yield* Effect.fail(new ActionCacheError({
226
+ reason: "refused",
227
+ key: primary,
228
+ detail: "FinalizeCacheEntryUpload did not confirm the upload"
229
+ }));
230
+ }));
231
+ }),
232
+ restore: Effect.fn("ActionCache.restore")(function* (paths, key, restoreKeys) {
233
+ const { primary, fallbacks } = ladder(key, restoreKeys);
234
+ yield* Effect.annotateCurrentSpan({ key: primary });
235
+ const version = versionOf(paths);
236
+ const found = yield* call("GetCacheEntryDownloadURL", {
237
+ key: primary,
238
+ restore_keys: [...fallbacks],
239
+ version
240
+ }, primary);
241
+ if (found === CONFLICT || !isOk(found)) return Option.none();
242
+ const url = stringField(found, "signedDownloadUrl");
243
+ if (url === void 0) return Option.none();
244
+ yield* withArchive(primary, (archive) => Effect.gen(function* () {
245
+ yield* transfer.downloadToFile(url, archive).pipe(moved(primary));
246
+ yield* tar([windows ? "xzPkf" : "xzPf", archive], primary, windows);
247
+ }));
248
+ return Option.some(stringField(found, "matchedKey") ?? primary);
249
+ })
250
+ };
251
+ });
252
+ const unimplemented = (member) => {
253
+ throw new Error(`ActionCache.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
254
+ };
255
+ /**
256
+ * The runner's own cache: archive a set of paths under a key, and get them back
257
+ * in a later job.
258
+ *
259
+ * @remarks
260
+ * Speaks the Actions cache **Twirp v2** protocol at `ACTIONS_RESULTS_URL`,
261
+ * which answers with a pre-signed Azure blob url for the archive itself. No
262
+ * `@actions/cache` dependency — that package alone brings a dependency tree
263
+ * larger than this one.
264
+ *
265
+ * **Only reachable from a `uses:` step.** The runner injects
266
+ * `ACTIONS_RESULTS_URL` and `ACTIONS_RUNTIME_TOKEN` into action execution
267
+ * contexts and not into `run:` shell steps, so the identical code fails as
268
+ * `misconfigured` when a workflow invokes it with `node ./main.js`. The failure
269
+ * names the absent variable, because nothing else distinguishes the two cases.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * import { ActionCache, CacheKey } from "@effected/github-actions";
274
+ * import { Effect, Option } from "effect";
275
+ *
276
+ * const program = Effect.gen(function* () {
277
+ * const cache = yield* ActionCache;
278
+ * const key = CacheKey.of("Linux", "pnpm-store", "abc123");
279
+ * const hit = yield* cache.restore(["~/.pnpm-store"], key);
280
+ * if (Option.isNone(hit)) {
281
+ * yield* cache.save(["~/.pnpm-store"], key);
282
+ * }
283
+ * });
284
+ * ```
285
+ *
286
+ * @public
287
+ */
288
+ var ActionCache = class ActionCache extends Context.Service()("@effected/github-actions/ActionCache") {
289
+ /** The cache, over the real Azure client and the real `tar`. */
290
+ static layer = Layer.effect(this, make(azure));
291
+ /**
292
+ * The cache, over a supplied transport.
293
+ *
294
+ * @remarks
295
+ * The protocol, the version derivation, the conflict handling and the archive
296
+ * are what this package owns; the pre-signed `PUT` is not. Supplying the
297
+ * transport is what lets a test exercise all of the first group without the
298
+ * second, and what an integration test uses to point the same protocol at a
299
+ * local blob endpoint.
300
+ *
301
+ * A parameterized layer factory mints a fresh layer per call and layers
302
+ * memoize by reference — bind it to a `const` rather than calling it at each
303
+ * composition site.
304
+ */
305
+ static layerWith = (transfer) => Layer.effect(ActionCache, make(transfer));
306
+ /** A test double. Unstubbed members die rather than reporting a miss. */
307
+ static makeTest = (overrides = {}) => ({
308
+ save: () => Effect.sync(() => unimplemented("save")),
309
+ restore: () => Effect.sync(() => unimplemented("restore")),
310
+ ...overrides
311
+ });
312
+ /** {@link ActionCache.makeTest} behind `Layer.succeed`. */
313
+ static layerTest = (overrides = {}) => Layer.succeed(ActionCache, ActionCache.makeTest(overrides));
314
+ };
315
+
316
+ //#endregion
317
+ export { ActionCache, ActionCacheError };