@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 +138 -0
- package/ActionCache.js +317 -0
- package/ActionEnvironment.js +255 -0
- package/ActionInput.js +346 -0
- package/ActionLogger.js +205 -0
- package/ActionOutputs.js +135 -0
- package/ActionState.js +117 -0
- package/Artifact.js +423 -0
- package/BlobEnvelope.js +135 -0
- package/BlobStore.githubCache.js +183 -0
- package/BlobStore.js +203 -0
- package/BlobTransfer.js +21 -0
- package/CacheKey.js +234 -0
- package/DetachedProcess.js +231 -0
- package/DryRun.js +73 -0
- package/GitHubToken.js +226 -0
- package/LICENSE +21 -0
- package/OidcTokenIssuer.js +246 -0
- package/Secret.js +106 -0
- package/ToolInstaller.js +293 -0
- package/WorkflowCommand.js +100 -0
- package/index.d.ts +2467 -0
- package/index.js +22 -0
- package/internal/actionsResults.js +98 -0
- package/internal/sigv4.js +117 -0
- package/internal/twirp.js +97 -0
- package/package.json +52 -0
- package/tsdoc-metadata.json +11 -0
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 };
|