@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/Artifact.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
|
+
import { BlobTransferError } from "./BlobTransfer.js";
|
|
3
|
+
import { resultsBackend } from "./internal/actionsResults.js";
|
|
4
|
+
import { CONFLICT, field, isOk, stringField, twirpCall } from "./internal/twirp.js";
|
|
5
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Result, Schema, Stream } 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/Artifact.ts
|
|
12
|
+
/**
|
|
13
|
+
* Raised when an artifact cannot be uploaded, listed, downloaded or deleted.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var ArtifactError = class extends Schema.TaggedErrorClass()("ArtifactError", {
|
|
18
|
+
/**
|
|
19
|
+
* `misconfigured` — the results backend is not reachable from here (see
|
|
20
|
+
* {@link Artifact}). `unreachable` — it could not be contacted, or answered
|
|
21
|
+
* with something that is not a Twirp body. `refused` — it answered,
|
|
22
|
+
* unhappily; re-uploading a name that already exists in the run is the
|
|
23
|
+
* common case. `notFound` — no artifact by that name or id exists in this
|
|
24
|
+
* run. `archiveFailed` — `zip` would not pack or unpack the files.
|
|
25
|
+
* `transferFailed` — the archive itself did not move. `invalidOptions` — the
|
|
26
|
+
* call cannot be made as asked.
|
|
27
|
+
*/
|
|
28
|
+
reason: Schema.Literals([
|
|
29
|
+
"misconfigured",
|
|
30
|
+
"unreachable",
|
|
31
|
+
"refused",
|
|
32
|
+
"notFound",
|
|
33
|
+
"archiveFailed",
|
|
34
|
+
"transferFailed",
|
|
35
|
+
"invalidOptions"
|
|
36
|
+
]),
|
|
37
|
+
/** The artifact's name or id. A stable identifier, never a value. */
|
|
38
|
+
artifact: Schema.optionalKey(Schema.String),
|
|
39
|
+
/** The HTTP status, when the backend answered. */
|
|
40
|
+
status: Schema.optionalKey(Schema.Number),
|
|
41
|
+
/** What went wrong, when the reason alone does not say. */
|
|
42
|
+
detail: Schema.optionalKey(Schema.String),
|
|
43
|
+
/** `zip`'s own complaint, which is the only useful part of an archive failure. */
|
|
44
|
+
stderr: Schema.optionalKey(Schema.String),
|
|
45
|
+
/** The underlying failure, preserved structurally. */
|
|
46
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
47
|
+
}) {
|
|
48
|
+
get message() {
|
|
49
|
+
const about = this.artifact === void 0 ? "" : ` "${this.artifact}"`;
|
|
50
|
+
const detail = this.detail === void 0 ? "" : `: ${this.detail}`;
|
|
51
|
+
switch (this.reason) {
|
|
52
|
+
case "misconfigured": return `The artifact service is not reachable from here${detail}`;
|
|
53
|
+
case "unreachable": return `The artifact service could not be reached${detail}`;
|
|
54
|
+
case "refused": return `The artifact service refused${about}${this.status === void 0 ? "" : ` with status ${this.status}`}${detail}`;
|
|
55
|
+
case "notFound": return `No artifact${about} exists in this run`;
|
|
56
|
+
case "archiveFailed": return `The artifact archive could not be built or extracted${about}${this.stderr === void 0 ? "" : `: ${this.stderr}`}`;
|
|
57
|
+
case "transferFailed": return `The artifact archive did not transfer${about}`;
|
|
58
|
+
default: return `The artifact call cannot be made as asked${detail}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** The Twirp service the artifact protocol lives under. */
|
|
63
|
+
const SERVICE = "github.actions.results.api.v1.ArtifactService";
|
|
64
|
+
/**
|
|
65
|
+
* `CreateArtifact`'s `version` field.
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* `7`, read off `actions/toolkit`'s own upload path. It is a *protocol*
|
|
69
|
+
* version, unrelated to the `v4` in `actions/upload-artifact@v4`, and guessing
|
|
70
|
+
* it from the action's major version — which is the obvious guess — produces a
|
|
71
|
+
* create the backend rejects.
|
|
72
|
+
*/
|
|
73
|
+
const ARTIFACT_VERSION = 7;
|
|
74
|
+
/**
|
|
75
|
+
* The Azure half, duplicated on purpose.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* `@azure/storage-blob` may be imported here, by `ActionCache` and by
|
|
79
|
+
* `BlobStore.githubCache`, and nowhere else. Hoisting these two calls into a
|
|
80
|
+
* shared `internal/` helper is exactly how a heavy import leaks into the graph
|
|
81
|
+
* of a module that only sets an output; `__test__/reachability.test.ts`
|
|
82
|
+
* measures that these are the only three.
|
|
83
|
+
*/
|
|
84
|
+
const azure = {
|
|
85
|
+
uploadFile: (url, file) => Effect.tryPromise({
|
|
86
|
+
try: () => new BlockBlobClient(url).uploadFile(file, {
|
|
87
|
+
blockSize: 64 * 1024 * 1024,
|
|
88
|
+
concurrency: 8,
|
|
89
|
+
maxSingleShotSize: 128 * 1024 * 1024
|
|
90
|
+
}),
|
|
91
|
+
catch: (cause) => new BlobTransferError({
|
|
92
|
+
reason: "uploadFailed",
|
|
93
|
+
cause
|
|
94
|
+
})
|
|
95
|
+
}).pipe(Effect.asVoid),
|
|
96
|
+
downloadToFile: (url, file) => Effect.tryPromise({
|
|
97
|
+
try: () => new BlobClient(url).downloadToFile(file),
|
|
98
|
+
catch: (cause) => new BlobTransferError({
|
|
99
|
+
reason: "downloadFailed",
|
|
100
|
+
cause
|
|
101
|
+
})
|
|
102
|
+
}).pipe(Effect.asVoid)
|
|
103
|
+
};
|
|
104
|
+
const fromTwirp = (failure, artifact) => failure.kind === "status" ? new ArtifactError({
|
|
105
|
+
reason: "refused",
|
|
106
|
+
artifact,
|
|
107
|
+
detail: failure.method,
|
|
108
|
+
...failure.status === void 0 ? {} : { status: failure.status }
|
|
109
|
+
}) : new ArtifactError({
|
|
110
|
+
reason: "unreachable",
|
|
111
|
+
artifact,
|
|
112
|
+
detail: `${failure.method} did not answer with a Twirp body`,
|
|
113
|
+
...failure.cause === void 0 ? {} : { cause: failure.cause }
|
|
114
|
+
});
|
|
115
|
+
/** One row of a `ListArtifacts` answer, read under either field spelling. */
|
|
116
|
+
const toItem = (row) => {
|
|
117
|
+
const createdAt = stringField(row, "createdAt");
|
|
118
|
+
return {
|
|
119
|
+
id: Number(stringField(row, "databaseId") ?? 0),
|
|
120
|
+
name: stringField(row, "name") ?? "",
|
|
121
|
+
size: Number(stringField(row, "size") ?? 0),
|
|
122
|
+
...createdAt === void 0 ? {} : { createdAt }
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const make = (transfer) => Effect.gen(function* () {
|
|
126
|
+
const http = yield* HttpClient.HttpClient;
|
|
127
|
+
const fs = yield* FileSystem.FileSystem;
|
|
128
|
+
const path = yield* Path.Path;
|
|
129
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
130
|
+
const env = yield* ActionEnvironment;
|
|
131
|
+
const windows = yield* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.match(found, {
|
|
132
|
+
onNone: () => false,
|
|
133
|
+
onSome: (os) => os.toLowerCase() === "windows"
|
|
134
|
+
}));
|
|
135
|
+
const backend = (artifact) => resultsBackend(env).pipe(Effect.mapError((name) => new ArtifactError({
|
|
136
|
+
reason: "misconfigured",
|
|
137
|
+
artifact,
|
|
138
|
+
detail: `${name} is not set — the artifact service is only reachable from a \`uses:\` step, never from \`run:\``
|
|
139
|
+
})), Effect.flatMap((resolved) => Result.isFailure(resolved.backendIds) ? Effect.fail(new ArtifactError({
|
|
140
|
+
reason: "misconfigured",
|
|
141
|
+
artifact,
|
|
142
|
+
detail: resolved.backendIds.failure
|
|
143
|
+
})) : Effect.succeed({
|
|
144
|
+
baseUrl: resolved.baseUrl,
|
|
145
|
+
token: resolved.token,
|
|
146
|
+
ids: resolved.backendIds.success
|
|
147
|
+
})));
|
|
148
|
+
const call = (method, body, artifact) => Effect.gen(function* () {
|
|
149
|
+
const { baseUrl, ids, token } = yield* backend(artifact);
|
|
150
|
+
return yield* twirpCall({
|
|
151
|
+
http,
|
|
152
|
+
baseUrl,
|
|
153
|
+
service: SERVICE,
|
|
154
|
+
token,
|
|
155
|
+
method,
|
|
156
|
+
body: {
|
|
157
|
+
...ids,
|
|
158
|
+
...body(ids)
|
|
159
|
+
}
|
|
160
|
+
}).pipe(Effect.mapError((failure) => fromTwirp(failure, artifact)));
|
|
161
|
+
});
|
|
162
|
+
const listAll = (artifact) => Effect.map(call("ListArtifacts", () => ({}), artifact), (answer) => {
|
|
163
|
+
if (answer === CONFLICT) return [];
|
|
164
|
+
const rows = field(answer, "artifacts");
|
|
165
|
+
return Array.isArray(rows) ? rows.map(toItem) : [];
|
|
166
|
+
});
|
|
167
|
+
/** Run an archiving command, keeping its stderr. */
|
|
168
|
+
const archive = (command, artifact) => Effect.gen(function* () {
|
|
169
|
+
const output = yield* spawner.string(command, { includeStderr: true }).pipe(Effect.mapError((cause) => new ArtifactError({
|
|
170
|
+
reason: "archiveFailed",
|
|
171
|
+
artifact,
|
|
172
|
+
cause
|
|
173
|
+
})));
|
|
174
|
+
if ((yield* spawner.exitCode(command).pipe(Effect.mapError((cause) => new ArtifactError({
|
|
175
|
+
reason: "archiveFailed",
|
|
176
|
+
artifact,
|
|
177
|
+
cause
|
|
178
|
+
})))) !== 0) return yield* Effect.fail(new ArtifactError({
|
|
179
|
+
reason: "archiveFailed",
|
|
180
|
+
artifact,
|
|
181
|
+
stderr: output.trim()
|
|
182
|
+
}));
|
|
183
|
+
});
|
|
184
|
+
const scratch = (artifact, use) => Effect.acquireUseRelease(fs.makeTempDirectory({ prefix: "effected-artifact-" }).pipe(Effect.mapError((cause) => new ArtifactError({
|
|
185
|
+
reason: "archiveFailed",
|
|
186
|
+
artifact,
|
|
187
|
+
cause
|
|
188
|
+
}))), use, (directory) => Effect.ignore(fs.remove(directory, {
|
|
189
|
+
recursive: true,
|
|
190
|
+
force: true
|
|
191
|
+
})));
|
|
192
|
+
/**
|
|
193
|
+
* The SHA-256 the backend finalizes against.
|
|
194
|
+
*
|
|
195
|
+
* @remarks
|
|
196
|
+
* Over the *stored zip*, streamed rather than read: an artifact is the one
|
|
197
|
+
* payload in this package with no upper bound on size, and a runner has
|
|
198
|
+
* roughly seven gigabytes of memory for everything.
|
|
199
|
+
*/
|
|
200
|
+
const digestOf = (file, artifact) => Effect.gen(function* () {
|
|
201
|
+
const accumulator = createHash("sha256");
|
|
202
|
+
yield* Stream.runForEach(fs.stream(file), (chunk) => Effect.sync(() => {
|
|
203
|
+
accumulator.update(chunk);
|
|
204
|
+
})).pipe(Effect.mapError((cause) => new ArtifactError({
|
|
205
|
+
reason: "archiveFailed",
|
|
206
|
+
artifact,
|
|
207
|
+
cause
|
|
208
|
+
})));
|
|
209
|
+
return accumulator.digest("hex");
|
|
210
|
+
});
|
|
211
|
+
const moved = (artifact) => Effect.mapError((cause) => new ArtifactError({
|
|
212
|
+
reason: "transferFailed",
|
|
213
|
+
artifact,
|
|
214
|
+
cause
|
|
215
|
+
}));
|
|
216
|
+
const zip = (files, root, destination, level, artifact) => {
|
|
217
|
+
const relative = files.map((file) => path.relative(root, file));
|
|
218
|
+
return windows ? archive(ChildProcess.make("pwsh", [
|
|
219
|
+
"-NoProfile",
|
|
220
|
+
"-NonInteractive",
|
|
221
|
+
"-Command",
|
|
222
|
+
`Compress-Archive -Path ${relative.map((file) => `'${file.replaceAll("'", "''")}'`).join(",")} -DestinationPath '${destination.replaceAll("'", "''")}' -Force`
|
|
223
|
+
], { cwd: root }), artifact) : archive(ChildProcess.make("zip", [
|
|
224
|
+
`-${Math.min(9, Math.max(0, Math.trunc(level)))}`,
|
|
225
|
+
"-qr",
|
|
226
|
+
destination,
|
|
227
|
+
...relative
|
|
228
|
+
], { cwd: root }), artifact);
|
|
229
|
+
};
|
|
230
|
+
const unzip = (source, destination, artifact) => windows ? archive(ChildProcess.make("pwsh", [
|
|
231
|
+
"-NoProfile",
|
|
232
|
+
"-NonInteractive",
|
|
233
|
+
"-Command",
|
|
234
|
+
`Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${source.replaceAll("'", "''")}', '${destination.replaceAll("'", "''")}')`
|
|
235
|
+
]), artifact) : archive(ChildProcess.make("unzip", [
|
|
236
|
+
"-oq",
|
|
237
|
+
source,
|
|
238
|
+
"-d",
|
|
239
|
+
destination
|
|
240
|
+
]), artifact);
|
|
241
|
+
return {
|
|
242
|
+
upload: Effect.fn("Artifact.upload")(function* (name, files, rootDirectory, options) {
|
|
243
|
+
yield* Effect.annotateCurrentSpan({ name });
|
|
244
|
+
if (files.length === 0) return yield* Effect.fail(new ArtifactError({
|
|
245
|
+
reason: "invalidOptions",
|
|
246
|
+
artifact: name,
|
|
247
|
+
detail: "no files were given to upload"
|
|
248
|
+
}));
|
|
249
|
+
if (options?.retentionDays !== void 0 && options.retentionDays <= 0) return yield* Effect.fail(new ArtifactError({
|
|
250
|
+
reason: "invalidOptions",
|
|
251
|
+
artifact: name,
|
|
252
|
+
detail: "retentionDays must be positive — omit it to use the repository default"
|
|
253
|
+
}));
|
|
254
|
+
return yield* scratch(name, (directory) => Effect.gen(function* () {
|
|
255
|
+
const packed = path.join(directory, "artifact.zip");
|
|
256
|
+
yield* zip(files, rootDirectory, packed, options?.compressionLevel ?? 6, name);
|
|
257
|
+
const created = yield* call("CreateArtifact", () => ({
|
|
258
|
+
name,
|
|
259
|
+
version: ARTIFACT_VERSION,
|
|
260
|
+
mimeType: "application/zip"
|
|
261
|
+
}), name);
|
|
262
|
+
if (created === CONFLICT || !isOk(created)) return yield* Effect.fail(new ArtifactError({
|
|
263
|
+
reason: "refused",
|
|
264
|
+
artifact: name,
|
|
265
|
+
detail: "an artifact with this name already exists in this run"
|
|
266
|
+
}));
|
|
267
|
+
const url = stringField(created, "signedUploadUrl");
|
|
268
|
+
if (url === void 0) return yield* Effect.fail(new ArtifactError({
|
|
269
|
+
reason: "refused",
|
|
270
|
+
artifact: name,
|
|
271
|
+
detail: "CreateArtifact returned no upload url"
|
|
272
|
+
}));
|
|
273
|
+
yield* transfer.uploadFile(url, packed).pipe(moved(name));
|
|
274
|
+
const size = yield* fs.stat(packed).pipe(Effect.mapError((cause) => new ArtifactError({
|
|
275
|
+
reason: "archiveFailed",
|
|
276
|
+
artifact: name,
|
|
277
|
+
cause
|
|
278
|
+
})));
|
|
279
|
+
const digest = yield* digestOf(packed, name);
|
|
280
|
+
const finalized = yield* call("FinalizeArtifact", () => ({
|
|
281
|
+
name,
|
|
282
|
+
size: String(size.size),
|
|
283
|
+
hash: `sha256:${digest}`,
|
|
284
|
+
...options?.retentionDays === void 0 ? {} : { expiresAt: new Date(Date.now() + options.retentionDays * 864e5).toISOString() }
|
|
285
|
+
}), name);
|
|
286
|
+
if (finalized === CONFLICT || !isOk(finalized)) return yield* Effect.fail(new ArtifactError({
|
|
287
|
+
reason: "refused",
|
|
288
|
+
artifact: name,
|
|
289
|
+
detail: "FinalizeArtifact did not confirm the upload"
|
|
290
|
+
}));
|
|
291
|
+
return {
|
|
292
|
+
id: Number(stringField(finalized, "artifactId") ?? 0),
|
|
293
|
+
size: Number(size.size)
|
|
294
|
+
};
|
|
295
|
+
}));
|
|
296
|
+
}),
|
|
297
|
+
list: Effect.fn("Artifact.list")(function* () {
|
|
298
|
+
return yield* listAll("*");
|
|
299
|
+
}),
|
|
300
|
+
get: Effect.fn("Artifact.get")(function* (name) {
|
|
301
|
+
yield* Effect.annotateCurrentSpan({ name });
|
|
302
|
+
const all = yield* listAll(name);
|
|
303
|
+
return Option.fromNullishOr(all.find((item) => item.name === name));
|
|
304
|
+
}),
|
|
305
|
+
download: Effect.fn("Artifact.download")(function* (artifactId, options) {
|
|
306
|
+
const label = String(artifactId);
|
|
307
|
+
yield* Effect.annotateCurrentSpan({ artifactId });
|
|
308
|
+
const found = (yield* listAll(label)).find((item) => item.id === artifactId);
|
|
309
|
+
if (found === void 0) return yield* Effect.fail(new ArtifactError({
|
|
310
|
+
reason: "notFound",
|
|
311
|
+
artifact: label
|
|
312
|
+
}));
|
|
313
|
+
const signed = yield* call("GetSignedArtifactURL", () => ({ name: found.name }), label);
|
|
314
|
+
const url = signed === CONFLICT ? void 0 : stringField(signed, "signedUrl");
|
|
315
|
+
if (url === void 0) return yield* Effect.fail(new ArtifactError({
|
|
316
|
+
reason: "refused",
|
|
317
|
+
artifact: label,
|
|
318
|
+
detail: "GetSignedArtifactURL returned no url"
|
|
319
|
+
}));
|
|
320
|
+
const destination = options?.path;
|
|
321
|
+
const downloadPath = destination === void 0 ? yield* fs.makeTempDirectory({ prefix: "effected-artifact-download-" }).pipe(Effect.mapError((cause) => new ArtifactError({
|
|
322
|
+
reason: "archiveFailed",
|
|
323
|
+
artifact: label,
|
|
324
|
+
cause
|
|
325
|
+
}))) : yield* fs.makeDirectory(destination, { recursive: true }).pipe(Effect.as(destination), Effect.mapError((cause) => new ArtifactError({
|
|
326
|
+
reason: "archiveFailed",
|
|
327
|
+
artifact: label,
|
|
328
|
+
cause
|
|
329
|
+
})));
|
|
330
|
+
yield* scratch(label, (directory) => Effect.gen(function* () {
|
|
331
|
+
const packed = path.join(directory, "artifact.zip");
|
|
332
|
+
yield* transfer.downloadToFile(url, packed).pipe(moved(label));
|
|
333
|
+
yield* unzip(packed, downloadPath, label);
|
|
334
|
+
}));
|
|
335
|
+
return { downloadPath };
|
|
336
|
+
}),
|
|
337
|
+
delete: Effect.fn("Artifact.delete")(function* (name) {
|
|
338
|
+
yield* Effect.annotateCurrentSpan({ name });
|
|
339
|
+
const found = (yield* listAll(name)).find((item) => item.name === name);
|
|
340
|
+
if (found === void 0) return yield* Effect.fail(new ArtifactError({
|
|
341
|
+
reason: "notFound",
|
|
342
|
+
artifact: name
|
|
343
|
+
}));
|
|
344
|
+
const deleted = yield* call("DeleteArtifact", () => ({ name }), name);
|
|
345
|
+
if (deleted === CONFLICT || !isOk(deleted)) return yield* Effect.fail(new ArtifactError({
|
|
346
|
+
reason: "refused",
|
|
347
|
+
artifact: name,
|
|
348
|
+
detail: "DeleteArtifact did not confirm"
|
|
349
|
+
}));
|
|
350
|
+
return { id: Number(stringField(deleted, "artifactId") ?? found.id) };
|
|
351
|
+
})
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
const unimplemented = (member) => {
|
|
355
|
+
throw new Error(`Artifact.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
356
|
+
};
|
|
357
|
+
/**
|
|
358
|
+
* Upload, list, download and delete GitHub Actions artifacts.
|
|
359
|
+
*
|
|
360
|
+
* @remarks
|
|
361
|
+
* Speaks the artifact **Twirp v2** protocol at `ACTIONS_RESULTS_URL`, which
|
|
362
|
+
* answers with a pre-signed Azure blob url for the zip. No `@actions/artifact`
|
|
363
|
+
* dependency.
|
|
364
|
+
*
|
|
365
|
+
* **Only reachable from a `uses:` step**, like {@link ActionCache} — the two
|
|
366
|
+
* variables it needs are injected into action execution contexts and not into
|
|
367
|
+
* `run:` shell steps. The `Actions.Results` scope inside the runtime token is a
|
|
368
|
+
* third thing that can be absent, and it is reported as `misconfigured` for the
|
|
369
|
+
* same reason.
|
|
370
|
+
*
|
|
371
|
+
* **This surface is provisional.** Unlike every other service here it was
|
|
372
|
+
* ported without a call site to shape it against, so it keeps the source's
|
|
373
|
+
* shape rather than a narrower one invented from nothing. The first consumer to
|
|
374
|
+
* adopt it is the one whose feedback reshapes it — the members lost their
|
|
375
|
+
* `Artifact` prefix (`artifact.uploadArtifact(...)` stutters) and the
|
|
376
|
+
* cross-run `findBy` lookup is **not** ported, because every path through it in
|
|
377
|
+
* the source is a typed "not implemented" failure. Adding it later is additive;
|
|
378
|
+
* shipping a parameter that has never had behavior is not.
|
|
379
|
+
*
|
|
380
|
+
* @example
|
|
381
|
+
* ```ts
|
|
382
|
+
* import { Artifact } from "@effected/github-actions";
|
|
383
|
+
* import { Effect } from "effect";
|
|
384
|
+
*
|
|
385
|
+
* const program = Effect.gen(function* () {
|
|
386
|
+
* const artifacts = yield* Artifact;
|
|
387
|
+
* return yield* artifacts.upload("logs", ["/tmp/build/log.txt"], "/tmp/build");
|
|
388
|
+
* });
|
|
389
|
+
* ```
|
|
390
|
+
*
|
|
391
|
+
* @public
|
|
392
|
+
*/
|
|
393
|
+
var Artifact = class Artifact extends Context.Service()("@effected/github-actions/Artifact") {
|
|
394
|
+
/** The service, over the real Azure client and the real `zip`. */
|
|
395
|
+
static layer = Layer.effect(this, make(azure));
|
|
396
|
+
/**
|
|
397
|
+
* The service, over a supplied transport.
|
|
398
|
+
*
|
|
399
|
+
* @remarks
|
|
400
|
+
* The protocol, the zip, the digest and the conflict handling are what this
|
|
401
|
+
* package owns; the pre-signed `PUT` is not. Supplying the transport is what
|
|
402
|
+
* lets a test exercise all of the first group without the second.
|
|
403
|
+
*
|
|
404
|
+
* A parameterized layer factory mints a fresh layer per call and layers
|
|
405
|
+
* memoize by reference — bind it to a `const` rather than calling it at each
|
|
406
|
+
* composition site.
|
|
407
|
+
*/
|
|
408
|
+
static layerWith = (transfer) => Layer.effect(Artifact, make(transfer));
|
|
409
|
+
/** A test double. Unstubbed members die rather than reporting an empty run. */
|
|
410
|
+
static makeTest = (overrides = {}) => ({
|
|
411
|
+
upload: () => Effect.sync(() => unimplemented("upload")),
|
|
412
|
+
list: () => Effect.sync(() => unimplemented("list")),
|
|
413
|
+
get: () => Effect.sync(() => unimplemented("get")),
|
|
414
|
+
download: () => Effect.sync(() => unimplemented("download")),
|
|
415
|
+
delete: () => Effect.sync(() => unimplemented("delete")),
|
|
416
|
+
...overrides
|
|
417
|
+
});
|
|
418
|
+
/** {@link Artifact.makeTest} behind `Layer.succeed`. */
|
|
419
|
+
static layerTest = (overrides = {}) => Layer.succeed(Artifact, Artifact.makeTest(overrides));
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
//#endregion
|
|
423
|
+
export { Artifact, ArtifactError };
|
package/BlobEnvelope.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Result, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/BlobEnvelope.ts
|
|
4
|
+
/**
|
|
5
|
+
* Raised when bytes cannot be read as an envelope.
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
var BlobEnvelopeError = class extends Schema.TaggedErrorClass()("BlobEnvelopeError", {
|
|
10
|
+
/**
|
|
11
|
+
* `notAnEnvelope` — the magic prefix is absent, so these are raw bytes
|
|
12
|
+
* written before this format existed (or by something else entirely).
|
|
13
|
+
* `unsupportedVersion` — an envelope from a newer revision.
|
|
14
|
+
* `truncated` — the frame ends mid-header or mid-metadata.
|
|
15
|
+
* `metadataDecodeFailed` — the metadata is well-framed but does not satisfy
|
|
16
|
+
* the caller's schema. `metadataEncodeFailed` — the value being stored does
|
|
17
|
+
* not satisfy it.
|
|
18
|
+
*/
|
|
19
|
+
reason: Schema.Literals([
|
|
20
|
+
"notAnEnvelope",
|
|
21
|
+
"unsupportedVersion",
|
|
22
|
+
"truncated",
|
|
23
|
+
"metadataDecodeFailed",
|
|
24
|
+
"metadataEncodeFailed"
|
|
25
|
+
]),
|
|
26
|
+
/** The envelope version found, when one was readable. */
|
|
27
|
+
version: Schema.optionalKey(Schema.Number),
|
|
28
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
29
|
+
}) {
|
|
30
|
+
get message() {
|
|
31
|
+
switch (this.reason) {
|
|
32
|
+
case "notAnEnvelope": return "Bytes are not an @effected/github-actions blob envelope";
|
|
33
|
+
case "unsupportedVersion": return `Blob envelope version ${this.version} is not supported`;
|
|
34
|
+
case "truncated": return "Blob envelope is truncated";
|
|
35
|
+
case "metadataDecodeFailed": return "Blob envelope metadata did not satisfy the schema";
|
|
36
|
+
default: return "Blob metadata could not be encoded";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** Identifies the frame family. Four bytes: `E F B S`. */
|
|
41
|
+
const MAGIC = Uint8Array.from([
|
|
42
|
+
69,
|
|
43
|
+
70,
|
|
44
|
+
66,
|
|
45
|
+
83
|
|
46
|
+
]);
|
|
47
|
+
/** The only version this build writes. */
|
|
48
|
+
const VERSION = 1;
|
|
49
|
+
/** magic + version + metadata length. */
|
|
50
|
+
const HEADER_BYTES = MAGIC.length + 1 + 4;
|
|
51
|
+
/**
|
|
52
|
+
* The schema-versioned frame that gives a stored blob a **metadata channel**.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* Layout:
|
|
56
|
+
*
|
|
57
|
+
* ```text
|
|
58
|
+
* [4B magic "EFBS"][1B version][4B metadata length, big-endian][metadata JSON][body]
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* Three properties earn the format, and each replaces something a consumer was
|
|
62
|
+
* hand-rolling:
|
|
63
|
+
*
|
|
64
|
+
* - **The magic prefix makes a legacy blob legible.** Raw, unframed bytes
|
|
65
|
+
* decode as `notAnEnvelope` rather than as garbage metadata, so a store
|
|
66
|
+
* holding pre-envelope entries produces a clean miss instead of a corrupt
|
|
67
|
+
* read.
|
|
68
|
+
* - **The version lives in the blob, not in the key.** A consumer previously
|
|
69
|
+
* namespaced keys (`v2/...`) to represent a format change, because there was
|
|
70
|
+
* no in-band version. Here a revision is detected on read and reported
|
|
71
|
+
* typed, so **keys stay stable** and old entries age out naturally.
|
|
72
|
+
* - **The metadata is the caller's own schema.** This module owns *framing*;
|
|
73
|
+
* the caller owns *meaning*. Fields like a cache tag or a duration stop
|
|
74
|
+
* being bytes at fixed offsets in a consumer's private codec.
|
|
75
|
+
*
|
|
76
|
+
* **Pure**: `Result`-returning, no IO, no service — the framing is testable
|
|
77
|
+
* from a byte array, which is the point.
|
|
78
|
+
*
|
|
79
|
+
* @public
|
|
80
|
+
*/
|
|
81
|
+
var BlobEnvelope = class {
|
|
82
|
+
constructor() {}
|
|
83
|
+
/** The version this build writes. */
|
|
84
|
+
static version = VERSION;
|
|
85
|
+
/** Frame metadata and a body into a single blob. */
|
|
86
|
+
static encodeResult(metadata, body, schema) {
|
|
87
|
+
const encoded = Schema.encodeUnknownResult(schema)(metadata);
|
|
88
|
+
if (Result.isFailure(encoded)) return Result.fail(new BlobEnvelopeError({
|
|
89
|
+
reason: "metadataEncodeFailed",
|
|
90
|
+
cause: encoded.failure
|
|
91
|
+
}));
|
|
92
|
+
const metaBytes = new TextEncoder().encode(JSON.stringify(encoded.success));
|
|
93
|
+
const out = new Uint8Array(HEADER_BYTES + metaBytes.length + body.length);
|
|
94
|
+
out.set(MAGIC, 0);
|
|
95
|
+
out[MAGIC.length] = VERSION;
|
|
96
|
+
new DataView(out.buffer).setUint32(MAGIC.length + 1, metaBytes.length, false);
|
|
97
|
+
out.set(metaBytes, HEADER_BYTES);
|
|
98
|
+
out.set(body, HEADER_BYTES + metaBytes.length);
|
|
99
|
+
return Result.succeed(out);
|
|
100
|
+
}
|
|
101
|
+
/** Read a framed blob back into its metadata and body. */
|
|
102
|
+
static decodeResult(bytes, schema) {
|
|
103
|
+
if (bytes.length < MAGIC.length || !MAGIC.every((byte, index) => bytes[index] === byte)) return Result.fail(new BlobEnvelopeError({ reason: "notAnEnvelope" }));
|
|
104
|
+
if (bytes.length < HEADER_BYTES) return Result.fail(new BlobEnvelopeError({ reason: "truncated" }));
|
|
105
|
+
const version = bytes[MAGIC.length] ?? 0;
|
|
106
|
+
if (version !== VERSION) return Result.fail(new BlobEnvelopeError({
|
|
107
|
+
reason: "unsupportedVersion",
|
|
108
|
+
version
|
|
109
|
+
}));
|
|
110
|
+
const metaLength = new DataView(bytes.buffer, bytes.byteOffset).getUint32(MAGIC.length + 1, false);
|
|
111
|
+
if (bytes.length < HEADER_BYTES + metaLength) return Result.fail(new BlobEnvelopeError({ reason: "truncated" }));
|
|
112
|
+
const metaText = new TextDecoder().decode(bytes.subarray(HEADER_BYTES, HEADER_BYTES + metaLength));
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(metaText);
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
return Result.fail(new BlobEnvelopeError({
|
|
118
|
+
reason: "metadataDecodeFailed",
|
|
119
|
+
cause
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
const decoded = Schema.decodeUnknownResult(schema)(parsed);
|
|
123
|
+
if (Result.isFailure(decoded)) return Result.fail(new BlobEnvelopeError({
|
|
124
|
+
reason: "metadataDecodeFailed",
|
|
125
|
+
cause: decoded.failure
|
|
126
|
+
}));
|
|
127
|
+
return Result.succeed({
|
|
128
|
+
metadata: decoded.success,
|
|
129
|
+
body: bytes.slice(HEADER_BYTES + metaLength)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
export { BlobEnvelope, BlobEnvelopeError };
|