@effected/github-actions 0.13.0 → 0.13.1
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/ActionCache.js +62 -106
- package/ActionEnvironment.js +25 -56
- package/ActionInput.js +8 -7
- package/ActionLogger.js +13 -15
- package/ActionOutputs.js +43 -56
- package/ActionState.js +13 -25
- package/Artifact.js +27 -47
- package/BlobEnvelope.js +3 -9
- package/BlobStore.githubCache.js +33 -54
- package/BlobStore.js +14 -21
- package/CacheKey.js +57 -32
- package/CheckDocument.js +18 -11
- package/DetachedProcess.js +11 -15
- package/DryRun.js +9 -9
- package/GitHubMarkdown.js +3 -3
- package/GitHubToken.js +1 -1
- package/ManagedDocument.js +26 -29
- package/OidcTokenIssuer.js +18 -42
- package/PackageManagerInstaller.js +33 -29
- package/ToolInstaller.js +24 -34
- package/index.d.ts +37 -23
- package/internal/actionsResults.js +10 -20
- package/internal/cacheService.js +70 -0
- package/internal/digest.js +42 -0
- package/internal/fsProbe.js +14 -0
- package/internal/jwt.js +63 -0
- package/internal/runner.js +31 -0
- package/internal/runnerFile.js +45 -0
- package/internal/sigv4.js +3 -3
- package/internal/spawn.js +18 -0
- package/internal/twirp.js +18 -1
- package/internal/unstubbed.js +28 -0
- package/package.json +3 -2
package/ActionState.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
|
+
import { heredocBlock, isUsableName } from "./internal/runnerFile.js";
|
|
3
|
+
import { unstubbed } from "./internal/unstubbed.js";
|
|
2
4
|
import { ActionOutputs } from "./ActionOutputs.js";
|
|
3
5
|
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect";
|
|
4
6
|
|
|
@@ -43,31 +45,19 @@ const make = Effect.gen(function* () {
|
|
|
43
45
|
const fs = yield* FileSystem.FileSystem;
|
|
44
46
|
const outputs = yield* ActionOutputs;
|
|
45
47
|
const write = (key, serialized) => Effect.gen(function* () {
|
|
46
|
-
const
|
|
48
|
+
const writeFailed = (cause) => new ActionStateError({
|
|
47
49
|
reason: "writeFailed",
|
|
48
50
|
key,
|
|
49
51
|
cause
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
yield* fs.writeFileString(path,
|
|
54
|
-
reason: "writeFailed",
|
|
55
|
-
key,
|
|
56
|
-
cause
|
|
57
|
-
})));
|
|
52
|
+
});
|
|
53
|
+
if (!isUsableName(key)) return yield* Effect.fail(writeFailed(/* @__PURE__ */ new Error(`"${key}" cannot name a GITHUB_STATE entry`)));
|
|
54
|
+
const path = yield* env.get("GITHUB_STATE").pipe(Effect.mapError(writeFailed));
|
|
55
|
+
yield* fs.writeFileString(path, heredocBlock(key, serialized), { flag: "a" }).pipe(Effect.mapError(writeFailed));
|
|
58
56
|
});
|
|
59
57
|
const read = (key, schema) => Effect.gen(function* () {
|
|
60
58
|
const raw = yield* env.getOptional(stateVariable(key));
|
|
61
59
|
if (Option.isNone(raw)) return Option.none();
|
|
62
|
-
const
|
|
63
|
-
try: () => JSON.parse(raw.value),
|
|
64
|
-
catch: (cause) => new ActionStateError({
|
|
65
|
-
reason: "malformed",
|
|
66
|
-
key,
|
|
67
|
-
cause
|
|
68
|
-
})
|
|
69
|
-
});
|
|
70
|
-
const decoded = yield* Schema.decodeUnknownEffect(schema)(parsed).pipe(Effect.mapError((cause) => new ActionStateError({
|
|
60
|
+
const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(raw.value).pipe(Effect.mapError((cause) => new ActionStateError({
|
|
71
61
|
reason: "malformed",
|
|
72
62
|
key,
|
|
73
63
|
cause
|
|
@@ -111,9 +101,7 @@ const make = Effect.gen(function* () {
|
|
|
111
101
|
saveSecret: (key, secret) => Effect.flatMap(outputs.setSecret(secret), () => write(key, JSON.stringify(secret)))
|
|
112
102
|
};
|
|
113
103
|
});
|
|
114
|
-
const
|
|
115
|
-
throw new Error(`ActionState.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
116
|
-
};
|
|
104
|
+
const dies = unstubbed("ActionState.makeTest");
|
|
117
105
|
/**
|
|
118
106
|
* State that survives the `pre` → `main` → `post` phase boundary.
|
|
119
107
|
*
|
|
@@ -130,10 +118,10 @@ var ActionState = class ActionState extends Context.Service()("@effected/github-
|
|
|
130
118
|
static layer = Layer.effect(this, make);
|
|
131
119
|
/** A test double. Unstubbed members die rather than answering wrongly. */
|
|
132
120
|
static makeTest = (overrides = {}) => ({
|
|
133
|
-
save: () =>
|
|
134
|
-
get: () =>
|
|
135
|
-
getOptional: () =>
|
|
136
|
-
saveSecret: () =>
|
|
121
|
+
save: () => dies("save"),
|
|
122
|
+
get: () => dies("get"),
|
|
123
|
+
getOptional: () => dies("getOptional"),
|
|
124
|
+
saveSecret: () => dies("saveSecret"),
|
|
137
125
|
...overrides
|
|
138
126
|
});
|
|
139
127
|
/** {@link ActionState.makeTest} behind `Layer.succeed`. */
|
package/Artifact.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
|
+
import { unstubbed } from "./internal/unstubbed.js";
|
|
2
3
|
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 {
|
|
4
|
+
import { misconfiguredDetail, resultsBackend } from "./internal/actionsResults.js";
|
|
5
|
+
import { CONFLICT, field, isOk, stringField, twirpCall, twirpFailureFields } from "./internal/twirp.js";
|
|
6
|
+
import { digestFileHex } from "./internal/digest.js";
|
|
7
|
+
import { isWindowsRunner } from "./internal/runner.js";
|
|
8
|
+
import { spawnOnce } from "./internal/spawn.js";
|
|
9
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
|
|
6
10
|
import { HttpClient } from "effect/unstable/http";
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
11
|
import { BlobClient, BlockBlobClient } from "@azure/storage-blob";
|
|
9
12
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
10
13
|
|
|
@@ -101,17 +104,6 @@ const azure = {
|
|
|
101
104
|
})
|
|
102
105
|
}).pipe(Effect.asVoid)
|
|
103
106
|
};
|
|
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
107
|
/** One row of a `ListArtifacts` answer, read under either field spelling. */
|
|
116
108
|
const toItem = (row) => {
|
|
117
109
|
const createdAt = stringField(row, "createdAt");
|
|
@@ -128,14 +120,11 @@ const make = (transfer) => Effect.gen(function* () {
|
|
|
128
120
|
const path = yield* Path.Path;
|
|
129
121
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
130
122
|
const env = yield* ActionEnvironment;
|
|
131
|
-
const windows = yield*
|
|
132
|
-
onNone: () => false,
|
|
133
|
-
onSome: (os) => os.toLowerCase() === "windows"
|
|
134
|
-
}));
|
|
123
|
+
const windows = yield* isWindowsRunner(env);
|
|
135
124
|
const backend = (artifact) => resultsBackend(env).pipe(Effect.mapError((name) => new ArtifactError({
|
|
136
125
|
reason: "misconfigured",
|
|
137
126
|
artifact,
|
|
138
|
-
detail:
|
|
127
|
+
detail: misconfiguredDetail(name, "artifact service")
|
|
139
128
|
})), Effect.flatMap((resolved) => Result.isFailure(resolved.backendIds) ? Effect.fail(new ArtifactError({
|
|
140
129
|
reason: "misconfigured",
|
|
141
130
|
artifact,
|
|
@@ -157,25 +146,24 @@ const make = (transfer) => Effect.gen(function* () {
|
|
|
157
146
|
...ids,
|
|
158
147
|
...body(ids)
|
|
159
148
|
}
|
|
160
|
-
}).pipe(Effect.mapError((failure) =>
|
|
149
|
+
}).pipe(Effect.mapError((failure) => new ArtifactError({
|
|
150
|
+
...twirpFailureFields(failure),
|
|
151
|
+
artifact
|
|
152
|
+
})));
|
|
161
153
|
});
|
|
162
154
|
const listAll = (artifact) => Effect.map(call("ListArtifacts", () => ({}), artifact), (answer) => {
|
|
163
155
|
if (answer === CONFLICT) return [];
|
|
164
156
|
const rows = field(answer, "artifacts");
|
|
165
157
|
return Array.isArray(rows) ? rows.map(toItem) : [];
|
|
166
158
|
});
|
|
167
|
-
/** Run an archiving command, keeping its stderr. */
|
|
159
|
+
/** Run an archiving command ONCE (`internal/spawn.ts`), keeping its stderr. */
|
|
168
160
|
const archive = (command, artifact) => Effect.gen(function* () {
|
|
169
|
-
const output = yield* spawner
|
|
161
|
+
const { output, code } = yield* spawnOnce(spawner, command).pipe(Effect.mapError((cause) => new ArtifactError({
|
|
170
162
|
reason: "archiveFailed",
|
|
171
163
|
artifact,
|
|
172
164
|
cause
|
|
173
165
|
})));
|
|
174
|
-
if (
|
|
175
|
-
reason: "archiveFailed",
|
|
176
|
-
artifact,
|
|
177
|
-
cause
|
|
178
|
-
})))) !== 0) return yield* Effect.fail(new ArtifactError({
|
|
166
|
+
if (code !== 0) return yield* Effect.fail(new ArtifactError({
|
|
179
167
|
reason: "archiveFailed",
|
|
180
168
|
artifact,
|
|
181
169
|
stderr: output.trim()
|
|
@@ -197,17 +185,11 @@ const make = (transfer) => Effect.gen(function* () {
|
|
|
197
185
|
* payload in this package with no upper bound on size, and a runner has
|
|
198
186
|
* roughly seven gigabytes of memory for everything.
|
|
199
187
|
*/
|
|
200
|
-
const digestOf = (file, artifact) => Effect.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
reason: "archiveFailed",
|
|
206
|
-
artifact,
|
|
207
|
-
cause
|
|
208
|
-
})));
|
|
209
|
-
return accumulator.digest("hex");
|
|
210
|
-
});
|
|
188
|
+
const digestOf = (file, artifact) => digestFileHex(fs, file, "sha256").pipe(Effect.mapError((cause) => new ArtifactError({
|
|
189
|
+
reason: "archiveFailed",
|
|
190
|
+
artifact,
|
|
191
|
+
cause
|
|
192
|
+
})));
|
|
211
193
|
const moved = (artifact) => Effect.mapError((cause) => new ArtifactError({
|
|
212
194
|
reason: "transferFailed",
|
|
213
195
|
artifact,
|
|
@@ -351,9 +333,7 @@ const make = (transfer) => Effect.gen(function* () {
|
|
|
351
333
|
})
|
|
352
334
|
};
|
|
353
335
|
});
|
|
354
|
-
const
|
|
355
|
-
throw new Error(`Artifact.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
356
|
-
};
|
|
336
|
+
const dies = unstubbed("Artifact.makeTest");
|
|
357
337
|
/**
|
|
358
338
|
* Upload, list, download and delete GitHub Actions artifacts.
|
|
359
339
|
*
|
|
@@ -408,11 +388,11 @@ var Artifact = class Artifact extends Context.Service()("@effected/github-action
|
|
|
408
388
|
static layerWith = (transfer) => Layer.effect(Artifact, make(transfer));
|
|
409
389
|
/** A test double. Unstubbed members die rather than reporting an empty run. */
|
|
410
390
|
static makeTest = (overrides = {}) => ({
|
|
411
|
-
upload: () =>
|
|
412
|
-
list: () =>
|
|
413
|
-
get: () =>
|
|
414
|
-
download: () =>
|
|
415
|
-
delete: () =>
|
|
391
|
+
upload: () => dies("upload"),
|
|
392
|
+
list: () => dies("list"),
|
|
393
|
+
get: () => dies("get"),
|
|
394
|
+
download: () => dies("download"),
|
|
395
|
+
delete: () => dies("delete"),
|
|
416
396
|
...overrides
|
|
417
397
|
});
|
|
418
398
|
/** {@link Artifact.makeTest} behind `Layer.succeed`. */
|
package/BlobEnvelope.js
CHANGED
|
@@ -111,9 +111,9 @@ var BlobEnvelope = class {
|
|
|
111
111
|
static version = VERSION;
|
|
112
112
|
/** Frame metadata and a body into a single blob. */
|
|
113
113
|
static encodeResult(metadata, body, schema) {
|
|
114
|
-
const encoded = Schema.encodeUnknownResult(schema)(metadata);
|
|
114
|
+
const encoded = Schema.encodeUnknownResult(Schema.fromJsonString(schema))(metadata);
|
|
115
115
|
if (Result.isFailure(encoded)) return Result.fail(new BlobMetadataEncodeError({ cause: encoded.failure }));
|
|
116
|
-
const metaBytes = new TextEncoder().encode(
|
|
116
|
+
const metaBytes = new TextEncoder().encode(encoded.success);
|
|
117
117
|
const out = new Uint8Array(HEADER_BYTES + metaBytes.length + body.length);
|
|
118
118
|
out.set(MAGIC, 0);
|
|
119
119
|
out[MAGIC.length] = VERSION;
|
|
@@ -131,13 +131,7 @@ var BlobEnvelope = class {
|
|
|
131
131
|
const metaLength = new DataView(bytes.buffer, bytes.byteOffset).getUint32(MAGIC.length + 1, false);
|
|
132
132
|
if (bytes.length < HEADER_BYTES + metaLength) return Result.fail(new TruncatedBlobEnvelopeError({}));
|
|
133
133
|
const metaText = new TextDecoder().decode(bytes.subarray(HEADER_BYTES, HEADER_BYTES + metaLength));
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
parsed = JSON.parse(metaText);
|
|
137
|
-
} catch (cause) {
|
|
138
|
-
return Result.fail(new BlobMetadataDecodeError({ cause }));
|
|
139
|
-
}
|
|
140
|
-
const decoded = Schema.decodeUnknownResult(schema)(parsed);
|
|
134
|
+
const decoded = Schema.decodeUnknownResult(Schema.fromJsonString(schema))(metaText);
|
|
141
135
|
if (Result.isFailure(decoded)) return Result.fail(new BlobMetadataDecodeError({ cause: decoded.failure }));
|
|
142
136
|
return Result.succeed({
|
|
143
137
|
metadata: decoded.success,
|
package/BlobStore.githubCache.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
2
|
import { BlobTransferError } from "./BlobTransfer.js";
|
|
3
|
-
import { resultsBackend } from "./internal/actionsResults.js";
|
|
4
|
-
import {
|
|
3
|
+
import { misconfiguredDetail, resultsBackend } from "./internal/actionsResults.js";
|
|
4
|
+
import { twirpCall, twirpFailureFields } from "./internal/twirp.js";
|
|
5
|
+
import { CACHE_SERVICE, finalizeUpload, lookupDownload, reserveUpload } from "./internal/cacheService.js";
|
|
6
|
+
import { sha256Hex } from "./internal/digest.js";
|
|
5
7
|
import { BlobEnvelope } from "./BlobEnvelope.js";
|
|
6
8
|
import { BlobStore, BlobStoreError } from "./BlobStore.js";
|
|
7
9
|
import { Effect, Layer, Option } from "effect";
|
|
8
10
|
import { HttpClient } from "effect/unstable/http";
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
10
11
|
import { BlobClient, BlockBlobClient } from "@azure/storage-blob";
|
|
11
12
|
|
|
12
13
|
//#region src/BlobStore.githubCache.ts
|
|
13
|
-
/** The Twirp service the Actions cache protocol lives under. */
|
|
14
|
-
const SERVICE = "github.actions.results.api.v1.CacheService";
|
|
15
14
|
/**
|
|
16
15
|
* The `version` every entry is filed under.
|
|
17
16
|
*
|
|
@@ -25,7 +24,7 @@ const SERVICE = "github.actions.results.api.v1.CacheService";
|
|
|
25
24
|
* inside the blob, so a framing change is a clean miss rather than a key change
|
|
26
25
|
* that strands every existing entry.
|
|
27
26
|
*/
|
|
28
|
-
const VERSION =
|
|
27
|
+
const VERSION = sha256Hex("blobstore|1.0");
|
|
29
28
|
/**
|
|
30
29
|
* The Azure half, ~15 lines and duplicated on purpose.
|
|
31
30
|
*
|
|
@@ -52,40 +51,37 @@ const azure = {
|
|
|
52
51
|
})
|
|
53
52
|
}).pipe(Effect.map((buffer) => new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)))
|
|
54
53
|
};
|
|
55
|
-
const fromTwirp = (failure, key) => failure.kind === "status" ? new BlobStoreError({
|
|
56
|
-
reason: "refused",
|
|
57
|
-
key,
|
|
58
|
-
...failure.status === void 0 ? {} : { status: failure.status }
|
|
59
|
-
}) : new BlobStoreError({
|
|
60
|
-
reason: "unreachable",
|
|
61
|
-
key,
|
|
62
|
-
detail: `${failure.method} answered with something that is not a Twirp body`,
|
|
63
|
-
...failure.cause === void 0 ? {} : { cause: failure.cause }
|
|
64
|
-
});
|
|
65
54
|
const make = (transfer) => Effect.gen(function* () {
|
|
66
55
|
const http = yield* HttpClient.HttpClient;
|
|
67
56
|
const env = yield* ActionEnvironment;
|
|
68
57
|
const backend = resultsBackend(env).pipe(Effect.mapError((name) => new BlobStoreError({
|
|
69
58
|
reason: "misconfigured",
|
|
70
|
-
detail:
|
|
59
|
+
detail: misconfiguredDetail(name, "Actions results backend")
|
|
71
60
|
})));
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
61
|
+
/** The cache service, for one key: every failure names it. */
|
|
62
|
+
const cacheService = (key) => ({
|
|
63
|
+
call: (method, body) => Effect.gen(function* () {
|
|
64
|
+
const { baseUrl, token } = yield* backend;
|
|
65
|
+
return yield* twirpCall({
|
|
66
|
+
http,
|
|
67
|
+
baseUrl,
|
|
68
|
+
service: CACHE_SERVICE,
|
|
69
|
+
token,
|
|
70
|
+
method,
|
|
71
|
+
body
|
|
72
|
+
}).pipe(Effect.mapError((failure) => new BlobStoreError({
|
|
73
|
+
...twirpFailureFields(failure),
|
|
74
|
+
key
|
|
75
|
+
})));
|
|
76
|
+
}),
|
|
77
|
+
refused: (detail) => new BlobStoreError({
|
|
78
|
+
reason: "refused",
|
|
79
|
+
key,
|
|
80
|
+
detail
|
|
81
|
+
})
|
|
82
82
|
});
|
|
83
83
|
/** The signed download url for a key, or nothing — a miss is not a failure. */
|
|
84
|
-
const download = (key) => Effect.map(
|
|
85
|
-
key,
|
|
86
|
-
restore_keys: [],
|
|
87
|
-
version: VERSION
|
|
88
|
-
}, key), (answer) => answer === CONFLICT || !isOk(answer) ? Option.none() : Option.fromNullishOr(stringField(answer, "signedDownloadUrl")));
|
|
84
|
+
const download = (key) => Effect.map(lookupDownload(cacheService(key), key, [], VERSION), Option.map((hit) => hit.url));
|
|
89
85
|
const moved = (key) => Effect.mapError((cause) => new BlobStoreError({
|
|
90
86
|
reason: "unreachable",
|
|
91
87
|
key,
|
|
@@ -100,28 +96,11 @@ const make = (transfer) => Effect.gen(function* () {
|
|
|
100
96
|
}),
|
|
101
97
|
put: (key, blob, schema) => Effect.gen(function* () {
|
|
102
98
|
const framed = yield* Effect.fromResult(BlobEnvelope.encodeResult(blob.metadata, blob.body, schema));
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const url = stringField(created, "signedUploadUrl");
|
|
109
|
-
if (!isOk(created) || url === void 0) return yield* Effect.fail(new BlobStoreError({
|
|
110
|
-
reason: "refused",
|
|
111
|
-
key,
|
|
112
|
-
detail: "CreateCacheEntry returned no upload url"
|
|
113
|
-
}));
|
|
114
|
-
yield* transfer.uploadData(url, framed).pipe(moved(key));
|
|
115
|
-
const finalized = yield* call("FinalizeCacheEntryUpload", {
|
|
116
|
-
key,
|
|
117
|
-
version: VERSION,
|
|
118
|
-
size_bytes: String(framed.byteLength)
|
|
119
|
-
}, key);
|
|
120
|
-
if (finalized === CONFLICT || !isOk(finalized)) return yield* Effect.fail(new BlobStoreError({
|
|
121
|
-
reason: "refused",
|
|
122
|
-
key,
|
|
123
|
-
detail: "FinalizeCacheEntryUpload did not confirm the upload"
|
|
124
|
-
}));
|
|
99
|
+
const service = cacheService(key);
|
|
100
|
+
const url = yield* reserveUpload(service, key, VERSION);
|
|
101
|
+
if (Option.isNone(url)) return;
|
|
102
|
+
yield* transfer.uploadData(url.value, framed).pipe(moved(key));
|
|
103
|
+
yield* finalizeUpload(service, key, VERSION, framed.byteLength);
|
|
125
104
|
}),
|
|
126
105
|
has: (key) => Effect.map(download(key), Option.isSome)
|
|
127
106
|
};
|
package/BlobStore.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unstubbed } from "./internal/unstubbed.js";
|
|
1
2
|
import { BlobEnvelope } from "./BlobEnvelope.js";
|
|
2
3
|
import { sign } from "./internal/sigv4.js";
|
|
3
4
|
import { Secret } from "./Secret.js";
|
|
@@ -85,9 +86,9 @@ var BlobStore = class BlobStore extends Context.Service()("@effected/github-acti
|
|
|
85
86
|
static layerS3 = (config) => Layer.effect(BlobStore, makeS3(config));
|
|
86
87
|
/** A test double. Unstubbed members die rather than reporting a miss. */
|
|
87
88
|
static makeTest = (overrides = {}) => ({
|
|
88
|
-
get: () =>
|
|
89
|
-
put: () =>
|
|
90
|
-
has: () =>
|
|
89
|
+
get: () => dies("get"),
|
|
90
|
+
put: () => dies("put"),
|
|
91
|
+
has: () => dies("has"),
|
|
91
92
|
...overrides
|
|
92
93
|
});
|
|
93
94
|
/** {@link BlobStore.makeTest} behind `Layer.succeed`. */
|
|
@@ -115,9 +116,7 @@ var BlobStore = class BlobStore extends Context.Service()("@effected/github-acti
|
|
|
115
116
|
};
|
|
116
117
|
});
|
|
117
118
|
};
|
|
118
|
-
const
|
|
119
|
-
throw new Error(`BlobStore.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
120
|
-
};
|
|
119
|
+
const dies = unstubbed("BlobStore.makeTest");
|
|
121
120
|
const makeS3 = (config) => Effect.gen(function* () {
|
|
122
121
|
const http = yield* HttpClient.HttpClient;
|
|
123
122
|
let endpoint = config.endpoint ?? `https://s3.${config.region}.amazonaws.com`;
|
|
@@ -161,15 +160,17 @@ const makeS3 = (config) => Effect.gen(function* () {
|
|
|
161
160
|
cause
|
|
162
161
|
})));
|
|
163
162
|
});
|
|
163
|
+
/** Anything outside 2xx is the store refusing; a caller reads `404` first where a miss is an answer. */
|
|
164
|
+
const accepted = (key, status) => status < 200 || status >= 300 ? Effect.fail(new BlobStoreError({
|
|
165
|
+
reason: "refused",
|
|
166
|
+
key,
|
|
167
|
+
status
|
|
168
|
+
})) : Effect.void;
|
|
164
169
|
return {
|
|
165
170
|
get: (key, schema) => Effect.gen(function* () {
|
|
166
171
|
const response = yield* send("GET", key, /* @__PURE__ */ new Uint8Array(0));
|
|
167
172
|
if (response.status === 404) return Option.none();
|
|
168
|
-
|
|
169
|
-
reason: "refused",
|
|
170
|
-
key,
|
|
171
|
-
status: response.status
|
|
172
|
-
}));
|
|
173
|
+
yield* accepted(key, response.status);
|
|
173
174
|
const buffer = yield* response.arrayBuffer.pipe(Effect.mapError((cause) => new BlobStoreError({
|
|
174
175
|
reason: "unreachable",
|
|
175
176
|
key,
|
|
@@ -180,20 +181,12 @@ const makeS3 = (config) => Effect.gen(function* () {
|
|
|
180
181
|
put: (key, blob, schema) => Effect.gen(function* () {
|
|
181
182
|
const framed = yield* Effect.fromResult(BlobEnvelope.encodeResult(blob.metadata, blob.body, schema));
|
|
182
183
|
const response = yield* send("PUT", key, framed);
|
|
183
|
-
|
|
184
|
-
reason: "refused",
|
|
185
|
-
key,
|
|
186
|
-
status: response.status
|
|
187
|
-
}));
|
|
184
|
+
yield* accepted(key, response.status);
|
|
188
185
|
}),
|
|
189
186
|
has: (key) => Effect.gen(function* () {
|
|
190
187
|
const response = yield* send("HEAD", key, /* @__PURE__ */ new Uint8Array(0));
|
|
191
188
|
if (response.status === 404) return false;
|
|
192
|
-
|
|
193
|
-
reason: "refused",
|
|
194
|
-
key,
|
|
195
|
-
status: response.status
|
|
196
|
-
}));
|
|
189
|
+
yield* accepted(key, response.status);
|
|
197
190
|
return true;
|
|
198
191
|
})
|
|
199
192
|
};
|
package/CacheKey.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { sha256, sha256Hex } from "./internal/digest.js";
|
|
1
2
|
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
3
|
import { GlobSet } from "@effected/glob";
|
|
4
|
+
import { descend } from "@effected/walker";
|
|
4
5
|
|
|
5
6
|
//#region src/CacheKey.ts
|
|
6
7
|
/**
|
|
@@ -257,9 +258,11 @@ var CacheKey = class CacheKey extends Schema.Class("CacheKey")(Schema.Struct({
|
|
|
257
258
|
* state.
|
|
258
259
|
*/
|
|
259
260
|
static forBranch(options) {
|
|
260
|
-
const segments = []
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
const segments = options.os === void 0 ? [options.scope, options.branch] : [
|
|
262
|
+
options.os,
|
|
263
|
+
options.scope,
|
|
264
|
+
options.branch
|
|
265
|
+
];
|
|
263
266
|
if (options.hash !== void 0) segments.push(options.hash);
|
|
264
267
|
return CacheKey.make({ segments });
|
|
265
268
|
}
|
|
@@ -302,7 +305,7 @@ var CacheKey = class CacheKey extends Schema.Class("CacheKey")(Schema.Struct({
|
|
|
302
305
|
*/
|
|
303
306
|
static digest(input, length = 8) {
|
|
304
307
|
if (!Number.isInteger(length) || length < 1 || length > 64) throw new RangeError(`A digest length must be an integer between 1 and 64, got ${length}`);
|
|
305
|
-
return
|
|
308
|
+
return sha256Hex(input).slice(0, length);
|
|
306
309
|
}
|
|
307
310
|
/**
|
|
308
311
|
* Hash a set of files into a single digest.
|
|
@@ -330,30 +333,38 @@ var CacheKey = class CacheKey extends Schema.Class("CacheKey")(Schema.Struct({
|
|
|
330
333
|
const digests = yield* Effect.all(ordered.map((path) => fs.readFile(path).pipe(Effect.mapError((cause) => new CacheKeyReadError({
|
|
331
334
|
path,
|
|
332
335
|
cause
|
|
333
|
-
})), Effect.map(
|
|
334
|
-
|
|
335
|
-
for (const digest of digests) accumulator.update(digest);
|
|
336
|
-
return Option.some(accumulator.digest("hex"));
|
|
336
|
+
})), Effect.map(sha256))), { concurrency: 8 });
|
|
337
|
+
return Option.some(sha256Hex(Uint8Array.from(digests.flatMap((digest) => [...digest]))));
|
|
337
338
|
});
|
|
338
339
|
/**
|
|
339
340
|
* Every file under `workspace` that the pattern set matches.
|
|
340
341
|
*
|
|
341
342
|
* @remarks
|
|
342
343
|
* Discovery and matching are **two different jobs**, and separating them is
|
|
343
|
-
* what makes this testable and correct. The walk is
|
|
344
|
-
* `
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
344
|
+
* what makes this testable and correct. The walk is `@effected/walker`'s
|
|
345
|
+
* `descend`, run once per include from that include's own literal prefix
|
|
346
|
+
* (a literal include is one `stat`, never a walk) with nothing pruned
|
|
347
|
+
* implicitly; the matching is `@effected/glob`'s full minimatch dialect —
|
|
348
|
+
* the same dialect `@actions/glob` uses, so a workflow author's
|
|
349
|
+
* `!**\/node_modules/**` behaves here exactly as it does in every other
|
|
350
|
+
* cache step in the same workflow. `node:fs.globSync` would have welded the
|
|
351
|
+
* two together behind one non-stubbable call *and* changed the dialect, and
|
|
352
|
+
* a dialect divergence surfaces as a silent cache-key difference — the worst
|
|
353
|
+
* failure mode a cache key has.
|
|
354
|
+
*
|
|
355
|
+
* Candidates are matched by their path **relative to the workspace**, and a
|
|
356
|
+
* literal that climbs above it (`../lockfile`) is dropped, which is what
|
|
357
|
+
* makes "never hash a file outside the workspace" structural rather than a
|
|
358
|
+
* rule someone has to remember. Directories are excluded: a directory
|
|
355
359
|
* called `notes.txt` matches `**\/*.txt` and is not a file, and hashing it
|
|
356
|
-
* would fail rather than being ignored.
|
|
360
|
+
* would fail rather than being ignored. An absent literal is a miss; any
|
|
361
|
+
* other failure to read one is a typed `CacheKeyReadError`, because a key
|
|
362
|
+
* derived from an incomplete file set is wrong in a way nothing reports.
|
|
363
|
+
*
|
|
364
|
+
* **One knowing divergence from the runner's `hashFiles()`:** `descend`
|
|
365
|
+
* never enters a symlinked directory (cycle safety), where `@actions/glob`
|
|
366
|
+
* follows links by default. A file reachable only through a symlinked
|
|
367
|
+
* directory does not contribute to the key.
|
|
357
368
|
*
|
|
358
369
|
* The answer is sorted, so a caller cannot make its key depend on the order
|
|
359
370
|
* the filesystem happened to report.
|
|
@@ -361,24 +372,38 @@ var CacheKey = class CacheKey extends Schema.Class("CacheKey")(Schema.Struct({
|
|
|
361
372
|
static matchingFiles = Effect.fn("CacheKey.matchingFiles")(function* (options) {
|
|
362
373
|
const fs = yield* FileSystem.FileSystem;
|
|
363
374
|
const path = yield* Path.Path;
|
|
375
|
+
const { workspace } = options;
|
|
364
376
|
const set = yield* GlobSet.compile(options.patterns).pipe(Effect.mapError((cause) => new CacheKeyBadPatternError({
|
|
365
377
|
pattern: cause.pattern,
|
|
366
378
|
cause
|
|
367
379
|
})));
|
|
368
|
-
|
|
369
|
-
path:
|
|
380
|
+
yield* fs.stat(workspace).pipe(Effect.mapError((cause) => new CacheKeyReadError({
|
|
381
|
+
path: workspace,
|
|
370
382
|
cause
|
|
371
383
|
})));
|
|
372
|
-
const
|
|
373
|
-
for (const
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
if (
|
|
377
|
-
|
|
384
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
385
|
+
for (const literal of set.literals) {
|
|
386
|
+
const target = path.join(workspace, literal);
|
|
387
|
+
const relative = path.relative(workspace, target);
|
|
388
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) continue;
|
|
389
|
+
const info = yield* fs.stat(target).pipe(Effect.map(Option.some), Effect.catchIf((error) => error.reason._tag === "NotFound", () => Effect.succeedNone), Effect.mapError((cause) => new CacheKeyReadError({
|
|
390
|
+
path: target,
|
|
391
|
+
cause
|
|
392
|
+
})));
|
|
393
|
+
if (Option.isSome(info) && info.value.type === "File") candidates.add(literal);
|
|
394
|
+
}
|
|
395
|
+
for (const wildcard of set.wildcards) {
|
|
396
|
+
if (wildcard.negated) continue;
|
|
397
|
+
const found = yield* descend(wildcard, {
|
|
398
|
+
cwd: workspace,
|
|
399
|
+
prune: []
|
|
400
|
+
}).pipe(Effect.mapError((cause) => new CacheKeyReadError({
|
|
401
|
+
path: path.join(workspace, cause.path),
|
|
378
402
|
cause
|
|
379
|
-
})))
|
|
403
|
+
})));
|
|
404
|
+
for (const match of found) candidates.add(match);
|
|
380
405
|
}
|
|
381
|
-
return
|
|
406
|
+
return [...candidates].filter((candidate) => set.matches(candidate)).sort().map((candidate) => path.join(workspace, candidate));
|
|
382
407
|
});
|
|
383
408
|
/**
|
|
384
409
|
* Hash every file under `workspace` that the pattern set matches.
|
package/CheckDocument.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unstubbed } from "./internal/unstubbed.js";
|
|
1
2
|
import { CheckState } from "./CheckState.js";
|
|
2
3
|
import { ManagedDocument } from "./ManagedDocument.js";
|
|
3
4
|
import { Clock, Context, Duration, Effect, Latch, Layer, Ref, Schema, Semaphore } from "effect";
|
|
@@ -28,6 +29,12 @@ var CheckReport = class extends Schema.Class("CheckReport")({
|
|
|
28
29
|
/** Where the check's name should link. */
|
|
29
30
|
url: Schema.optionalKey(Schema.String)
|
|
30
31
|
}) {};
|
|
32
|
+
/** What each {@link CheckDocumentError} kind reads as. */
|
|
33
|
+
const KIND_PROSE = {
|
|
34
|
+
render: "The check document could not be regenerated from the reported state",
|
|
35
|
+
read: "Reading the check document's current text failed",
|
|
36
|
+
sink: "Writing the check document failed"
|
|
37
|
+
};
|
|
31
38
|
/**
|
|
32
39
|
* Raised when the check document cannot be regenerated, read back or written.
|
|
33
40
|
*
|
|
@@ -53,7 +60,7 @@ var CheckDocumentError = class extends Schema.TaggedError()("CheckDocumentError"
|
|
|
53
60
|
cause: Schema.optionalKey(Schema.Defect())
|
|
54
61
|
}) {
|
|
55
62
|
get message() {
|
|
56
|
-
return this.kind
|
|
63
|
+
return KIND_PROSE[this.kind];
|
|
57
64
|
}
|
|
58
65
|
};
|
|
59
66
|
/**
|
|
@@ -90,12 +97,13 @@ var CheckDocumentStamp = class extends Schema.Class("CheckDocumentStamp")({
|
|
|
90
97
|
return compareRunId(incoming.runId, existing.runId) >= 0;
|
|
91
98
|
}
|
|
92
99
|
};
|
|
100
|
+
/** A total order over two comparable values. */
|
|
101
|
+
const order = (left, right) => left === right ? 0 : left < right ? -1 : 1;
|
|
93
102
|
/** Epoch order when both sides parse as dates, lexical otherwise. */
|
|
94
103
|
const compareAt = (left, right) => {
|
|
95
104
|
const leftEpoch = Date.parse(left);
|
|
96
105
|
const rightEpoch = Date.parse(right);
|
|
97
|
-
|
|
98
|
-
return left === right ? 0 : left < right ? -1 : 1;
|
|
106
|
+
return Number.isNaN(leftEpoch) || Number.isNaN(rightEpoch) ? order(left, right) : order(leftEpoch, rightEpoch);
|
|
99
107
|
};
|
|
100
108
|
/**
|
|
101
109
|
* Numeric order when both sides are non-blank finite numbers, lexical
|
|
@@ -112,9 +120,9 @@ const compareRunId = (left, right) => {
|
|
|
112
120
|
if (left.trim() !== "" && right.trim() !== "") {
|
|
113
121
|
const leftNumber = Number(left);
|
|
114
122
|
const rightNumber = Number(right);
|
|
115
|
-
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return leftNumber
|
|
123
|
+
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return order(leftNumber, rightNumber);
|
|
116
124
|
}
|
|
117
|
-
return left
|
|
125
|
+
return order(left, right);
|
|
118
126
|
};
|
|
119
127
|
/** The stamp a document's own regions carry, when they carry one. */
|
|
120
128
|
const stampOf = (meta) => {
|
|
@@ -293,16 +301,15 @@ var CheckDocument = class CheckDocument extends Context.Service()("@effected/git
|
|
|
293
301
|
}
|
|
294
302
|
/** An inert double; unstubbed members die naming themselves. */
|
|
295
303
|
static makeTest = (overrides = {}) => ({
|
|
296
|
-
report:
|
|
297
|
-
checks:
|
|
298
|
-
flush:
|
|
304
|
+
report: () => dies("report"),
|
|
305
|
+
checks: dies("checks"),
|
|
306
|
+
flush: dies("flush"),
|
|
307
|
+
...overrides
|
|
299
308
|
});
|
|
300
309
|
/** {@link CheckDocument.makeTest} behind a `Layer`. */
|
|
301
310
|
static layerTest = (overrides = {}) => Layer.succeed(CheckDocument, CheckDocument.makeTest(overrides));
|
|
302
311
|
};
|
|
303
|
-
const
|
|
304
|
-
throw new Error(`CheckDocument.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
305
|
-
};
|
|
312
|
+
const dies = unstubbed("CheckDocument.makeTest");
|
|
306
313
|
|
|
307
314
|
//#endregion
|
|
308
315
|
export { CheckDocument, CheckDocumentError, CheckDocumentStamp, CheckReport };
|