@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
|
@@ -0,0 +1,183 @@
|
|
|
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 { BlobEnvelope } from "./BlobEnvelope.js";
|
|
6
|
+
import { BlobStore, BlobStoreError } from "./BlobStore.js";
|
|
7
|
+
import { Effect, Layer, Option } from "effect";
|
|
8
|
+
import { HttpClient } from "effect/unstable/http";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { BlobClient, BlockBlobClient } from "@azure/storage-blob";
|
|
11
|
+
|
|
12
|
+
//#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
|
+
/**
|
|
16
|
+
* The `version` every entry is filed under.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* The cache protocol keys an entry on `(key, version)`, and `actions/cache`
|
|
20
|
+
* derives the version from the *paths* it archives. A blob store has no paths —
|
|
21
|
+
* the key is caller-supplied and the payload is opaque — so the version is a
|
|
22
|
+
* constant, which is what makes any key map to a reproducible slot.
|
|
23
|
+
*
|
|
24
|
+
* It is deliberately **not** a format version: {@link BlobEnvelope} carries that
|
|
25
|
+
* inside the blob, so a framing change is a clean miss rather than a key change
|
|
26
|
+
* that strands every existing entry.
|
|
27
|
+
*/
|
|
28
|
+
const VERSION = createHash("sha256").update("blobstore|1.0").digest("hex");
|
|
29
|
+
/**
|
|
30
|
+
* The Azure half, ~15 lines and duplicated on purpose.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* `@azure/storage-blob` may be imported by this module, `ActionCache` and
|
|
34
|
+
* `Artifact` and by nothing else. Hoisting these two calls into a shared
|
|
35
|
+
* `internal/` helper is exactly how the import would leak into the graph of a
|
|
36
|
+
* module that only sets an output — so the three modules each carry their own,
|
|
37
|
+
* and `__test__/reachability.test.ts` measures that they are the only three.
|
|
38
|
+
*/
|
|
39
|
+
const azure = {
|
|
40
|
+
uploadData: (url, data) => Effect.tryPromise({
|
|
41
|
+
try: () => new BlockBlobClient(url).uploadData(data),
|
|
42
|
+
catch: (cause) => new BlobTransferError({
|
|
43
|
+
reason: "uploadFailed",
|
|
44
|
+
cause
|
|
45
|
+
})
|
|
46
|
+
}).pipe(Effect.asVoid),
|
|
47
|
+
downloadToBuffer: (url) => Effect.tryPromise({
|
|
48
|
+
try: () => new BlobClient(url).downloadToBuffer(),
|
|
49
|
+
catch: (cause) => new BlobTransferError({
|
|
50
|
+
reason: "downloadFailed",
|
|
51
|
+
cause
|
|
52
|
+
})
|
|
53
|
+
}).pipe(Effect.map((buffer) => new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)))
|
|
54
|
+
};
|
|
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
|
+
const make = (transfer) => Effect.gen(function* () {
|
|
66
|
+
const http = yield* HttpClient.HttpClient;
|
|
67
|
+
const backend = resultsBackend(yield* ActionEnvironment).pipe(Effect.mapError((name) => new BlobStoreError({
|
|
68
|
+
reason: "misconfigured",
|
|
69
|
+
detail: `${name} is not set — the Actions results backend is only reachable from a \`uses:\` step, never from \`run:\``
|
|
70
|
+
})));
|
|
71
|
+
const call = (method, body, key) => Effect.gen(function* () {
|
|
72
|
+
const { baseUrl, token } = yield* backend;
|
|
73
|
+
return yield* twirpCall({
|
|
74
|
+
http,
|
|
75
|
+
baseUrl,
|
|
76
|
+
service: SERVICE,
|
|
77
|
+
token,
|
|
78
|
+
method,
|
|
79
|
+
body
|
|
80
|
+
}).pipe(Effect.mapError((failure) => fromTwirp(failure, key)));
|
|
81
|
+
});
|
|
82
|
+
/** The signed download url for a key, or nothing — a miss is not a failure. */
|
|
83
|
+
const download = (key) => Effect.map(call("GetCacheEntryDownloadURL", {
|
|
84
|
+
key,
|
|
85
|
+
restore_keys: [],
|
|
86
|
+
version: VERSION
|
|
87
|
+
}, key), (answer) => answer === CONFLICT || !isOk(answer) ? Option.none() : Option.fromNullishOr(stringField(answer, "signedDownloadUrl")));
|
|
88
|
+
const moved = (key) => Effect.mapError((cause) => new BlobStoreError({
|
|
89
|
+
reason: "unreachable",
|
|
90
|
+
key,
|
|
91
|
+
cause
|
|
92
|
+
}));
|
|
93
|
+
return {
|
|
94
|
+
get: (key, schema) => Effect.gen(function* () {
|
|
95
|
+
const url = yield* download(key);
|
|
96
|
+
if (Option.isNone(url)) return Option.none();
|
|
97
|
+
const bytes = yield* transfer.downloadToBuffer(url.value).pipe(moved(key));
|
|
98
|
+
return Option.some(yield* Effect.fromResult(BlobEnvelope.decodeResult(bytes, schema)));
|
|
99
|
+
}),
|
|
100
|
+
put: (key, blob, schema) => Effect.gen(function* () {
|
|
101
|
+
const framed = yield* Effect.fromResult(BlobEnvelope.encodeResult(blob.metadata, blob.body, schema));
|
|
102
|
+
const created = yield* call("CreateCacheEntry", {
|
|
103
|
+
key,
|
|
104
|
+
version: VERSION
|
|
105
|
+
}, key);
|
|
106
|
+
if (created === CONFLICT) return;
|
|
107
|
+
const url = stringField(created, "signedUploadUrl");
|
|
108
|
+
if (!isOk(created) || url === void 0) return yield* Effect.fail(new BlobStoreError({
|
|
109
|
+
reason: "refused",
|
|
110
|
+
key,
|
|
111
|
+
detail: "CreateCacheEntry returned no upload url"
|
|
112
|
+
}));
|
|
113
|
+
yield* transfer.uploadData(url, framed).pipe(moved(key));
|
|
114
|
+
const finalized = yield* call("FinalizeCacheEntryUpload", {
|
|
115
|
+
key,
|
|
116
|
+
version: VERSION,
|
|
117
|
+
size_bytes: String(framed.byteLength)
|
|
118
|
+
}, key);
|
|
119
|
+
if (finalized === CONFLICT || !isOk(finalized)) return yield* Effect.fail(new BlobStoreError({
|
|
120
|
+
reason: "refused",
|
|
121
|
+
key,
|
|
122
|
+
detail: "FinalizeCacheEntryUpload did not confirm the upload"
|
|
123
|
+
}));
|
|
124
|
+
}),
|
|
125
|
+
has: (key) => Effect.map(download(key), Option.isSome)
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
/**
|
|
129
|
+
* The {@link BlobStore} backend that stores blobs in the runner's own Actions
|
|
130
|
+
* cache.
|
|
131
|
+
*
|
|
132
|
+
* @remarks
|
|
133
|
+
* The Actions cache is reachable over a Twirp v2 protocol at
|
|
134
|
+
* `ACTIONS_RESULTS_URL`, which answers a `CreateCacheEntry` /
|
|
135
|
+
* `GetCacheEntryDownloadURL` RPC with a **pre-signed Azure blob url** — which is
|
|
136
|
+
* why this backend lives in its own module rather than beside the S3 one, and
|
|
137
|
+
* why the spec's "Azure is confined to the cache and the artifact service" is
|
|
138
|
+
* three modules rather than two.
|
|
139
|
+
*
|
|
140
|
+
* **Only reachable from a `uses:` step.** The runner injects
|
|
141
|
+
* `ACTIONS_RESULTS_URL` and `ACTIONS_RUNTIME_TOKEN` into action execution
|
|
142
|
+
* contexts and not into `run:` shell steps, so a program that works when
|
|
143
|
+
* invoked from a bundled action fails as `misconfigured` when the same code is
|
|
144
|
+
* run by `node ./main.js` in a workflow step. The failure names the variable
|
|
145
|
+
* for exactly that reason.
|
|
146
|
+
*
|
|
147
|
+
* The layer statics live here rather than on {@link BlobStore} because a static
|
|
148
|
+
* belongs to the module that owns the dependency it needs: putting
|
|
149
|
+
* `BlobStore.layerGitHubCache` on the service class would make `@azure/storage-blob`
|
|
150
|
+
* reachable from every module that reads a blob.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* import { GitHubCacheBlobStore } from "@effected/github-actions";
|
|
155
|
+
*
|
|
156
|
+
* const layer = GitHubCacheBlobStore.layer;
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* @public
|
|
160
|
+
*/
|
|
161
|
+
var GitHubCacheBlobStore = class {
|
|
162
|
+
constructor() {}
|
|
163
|
+
/** The backend, over the real Azure client. */
|
|
164
|
+
static layer = Layer.effect(BlobStore, make(azure));
|
|
165
|
+
/**
|
|
166
|
+
* The backend, over a supplied transport.
|
|
167
|
+
*
|
|
168
|
+
* @remarks
|
|
169
|
+
* The Twirp protocol — the RPC sequence, the conflict handling, the retry
|
|
170
|
+
* policy, the framing — is what this package owns and what a test needs to
|
|
171
|
+
* exercise; the Azure client is a pre-signed `PUT` that owns none of it. This
|
|
172
|
+
* is also the seam an integration test uses to point the same protocol at a
|
|
173
|
+
* local blob endpoint.
|
|
174
|
+
*
|
|
175
|
+
* A parameterized layer factory mints a fresh layer per call and layers
|
|
176
|
+
* memoize by reference — bind it to a `const` rather than calling it at each
|
|
177
|
+
* composition site.
|
|
178
|
+
*/
|
|
179
|
+
static layerWith = (transfer) => Layer.effect(BlobStore, make(transfer));
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
export { GitHubCacheBlobStore };
|
package/BlobStore.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { BlobEnvelope } from "./BlobEnvelope.js";
|
|
2
|
+
import { sign } from "./internal/sigv4.js";
|
|
3
|
+
import { Secret } from "./Secret.js";
|
|
4
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
5
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
6
|
+
|
|
7
|
+
//#region src/BlobStore.ts
|
|
8
|
+
/**
|
|
9
|
+
* Raised when a blob cannot be stored or retrieved.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
var BlobStoreError = class extends Schema.TaggedErrorClass()("BlobStoreError", {
|
|
14
|
+
/**
|
|
15
|
+
* `unreachable` — the store could not be contacted. `refused` — it answered,
|
|
16
|
+
* unhappily; `status` says how. `misconfigured` — the layer was built with
|
|
17
|
+
* settings the store cannot use.
|
|
18
|
+
*/
|
|
19
|
+
reason: Schema.Literals([
|
|
20
|
+
"unreachable",
|
|
21
|
+
"refused",
|
|
22
|
+
"misconfigured"
|
|
23
|
+
]),
|
|
24
|
+
/** The key involved. A stable identifier, never a value. */
|
|
25
|
+
key: Schema.optionalKey(Schema.String),
|
|
26
|
+
/** The HTTP status, when the store answered. */
|
|
27
|
+
status: Schema.optionalKey(Schema.Number),
|
|
28
|
+
/** What is wrong, when the reason alone does not say. */
|
|
29
|
+
detail: Schema.optionalKey(Schema.String),
|
|
30
|
+
/** The underlying failure, preserved structurally. */
|
|
31
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
32
|
+
}) {
|
|
33
|
+
get message() {
|
|
34
|
+
switch (this.reason) {
|
|
35
|
+
case "unreachable": return `The blob store could not be reached${this.key === void 0 ? "" : ` for "${this.key}"`}`;
|
|
36
|
+
case "refused": return `The blob store refused "${this.key}"${this.status === void 0 ? "" : ` with status ${this.status}`}${this.detail === void 0 ? "" : `: ${this.detail}`}`;
|
|
37
|
+
default: return `The blob store is misconfigured${this.detail === void 0 ? "" : `: ${this.detail}`}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Durable blob storage with a metadata channel.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* `get`/`put`/`has` over `Uint8Array` **plus the caller's own metadata**, framed
|
|
46
|
+
* by {@link BlobEnvelope}. The metadata channel is the reason this exists: the
|
|
47
|
+
* consumer it was designed against hand-rolled a binary frame
|
|
48
|
+
* (`[4B tagLen][4B durationMs][tag][body]`) and namespaced its keys with a `v2`
|
|
49
|
+
* prefix to represent a format change. Both are framing concerns that had
|
|
50
|
+
* leaked into a consumer, and both are absorbed here.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately no `list` and no `delete`: eviction belongs to the backend, both
|
|
53
|
+
* backends already have one, and no consumer asked for either.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* import { BlobStore } from "@effected/github-actions";
|
|
58
|
+
* import { Effect, Schema } from "effect";
|
|
59
|
+
*
|
|
60
|
+
* class Meta extends Schema.Class<Meta>("Meta")({ tag: Schema.String, durationMs: Schema.Number }) {}
|
|
61
|
+
*
|
|
62
|
+
* const program = Effect.gen(function* () {
|
|
63
|
+
* const store = yield* BlobStore;
|
|
64
|
+
* yield* store.put("build/1", { metadata: Meta.make({ tag: "x", durationMs: 12 }), body: bytes }, Meta);
|
|
65
|
+
* return yield* store.get("build/1", Meta);
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
var BlobStore = class BlobStore extends Context.Service()("@effected/github-actions/BlobStore") {
|
|
72
|
+
/**
|
|
73
|
+
* An S3-compatible backend, signed with SigV4.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* No `@aws-sdk/*` dependency: signing is a specified algorithm over strings
|
|
77
|
+
* and HMACs, and this package already has `node:crypto`. The SDK's weight is
|
|
78
|
+
* in credential management, retries and a service catalogue none of which
|
|
79
|
+
* this needs.
|
|
80
|
+
*
|
|
81
|
+
* A parameterized layer factory mints a fresh layer per call and layers
|
|
82
|
+
* memoize by reference — bind it to a `const` rather than calling it at each
|
|
83
|
+
* composition site.
|
|
84
|
+
*/
|
|
85
|
+
static layerS3 = (config) => Layer.effect(BlobStore, makeS3(config));
|
|
86
|
+
/** A test double. Unstubbed members die rather than reporting a miss. */
|
|
87
|
+
static makeTest = (overrides = {}) => ({
|
|
88
|
+
get: () => Effect.sync(() => unimplemented("get")),
|
|
89
|
+
put: () => Effect.sync(() => unimplemented("put")),
|
|
90
|
+
has: () => Effect.sync(() => unimplemented("has")),
|
|
91
|
+
...overrides
|
|
92
|
+
});
|
|
93
|
+
/** {@link BlobStore.makeTest} behind `Layer.succeed`. */
|
|
94
|
+
static layerTest = (overrides = {}) => Layer.succeed(BlobStore, BlobStore.makeTest(overrides));
|
|
95
|
+
/**
|
|
96
|
+
* An in-memory backend.
|
|
97
|
+
*
|
|
98
|
+
* @remarks
|
|
99
|
+
* Not a stub: it runs the real {@link BlobEnvelope} framing, so a round trip
|
|
100
|
+
* through it exercises encode and decode exactly as a network backend would.
|
|
101
|
+
* A test that wants to prove its metadata survives storage should use this
|
|
102
|
+
* rather than a double whose `get` returns whatever its `put` was handed.
|
|
103
|
+
*/
|
|
104
|
+
static layerMemory = Layer.sync(BlobStore, () => {
|
|
105
|
+
const entries = /* @__PURE__ */ new Map();
|
|
106
|
+
return {
|
|
107
|
+
get: (key, schema) => Effect.suspend(() => {
|
|
108
|
+
const stored = entries.get(key);
|
|
109
|
+
return stored === void 0 ? Effect.succeed(Option.none()) : Effect.map(Effect.fromResult(BlobEnvelope.decodeResult(stored, schema)), Option.some);
|
|
110
|
+
}),
|
|
111
|
+
put: (key, blob, schema) => Effect.suspend(() => Effect.map(Effect.fromResult(BlobEnvelope.encodeResult(blob.metadata, blob.body, schema)), (framed) => {
|
|
112
|
+
entries.set(key, framed);
|
|
113
|
+
})),
|
|
114
|
+
has: (key) => Effect.sync(() => entries.has(key))
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
const unimplemented = (member) => {
|
|
119
|
+
throw new Error(`BlobStore.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
120
|
+
};
|
|
121
|
+
const makeS3 = (config) => Effect.gen(function* () {
|
|
122
|
+
const http = yield* HttpClient.HttpClient;
|
|
123
|
+
let endpoint = config.endpoint ?? `https://s3.${config.region}.amazonaws.com`;
|
|
124
|
+
while (endpoint.endsWith("/")) endpoint = endpoint.slice(0, -1);
|
|
125
|
+
const host = new URL(endpoint).host;
|
|
126
|
+
const secretAccessKey = yield* Secret.forSigning(config.secretAccessKey);
|
|
127
|
+
const sessionToken = config.sessionToken === void 0 ? void 0 : yield* Secret.forSigning(config.sessionToken);
|
|
128
|
+
const objectPath = (key) => [
|
|
129
|
+
config.bucket,
|
|
130
|
+
...config.prefix === void 0 ? [] : [config.prefix],
|
|
131
|
+
key
|
|
132
|
+
].join("/").replaceAll(/\/{2,}/g, "/");
|
|
133
|
+
const request = (method, key, body) => {
|
|
134
|
+
const path = objectPath(key);
|
|
135
|
+
const headers = sign({
|
|
136
|
+
method,
|
|
137
|
+
path,
|
|
138
|
+
host,
|
|
139
|
+
headers: {},
|
|
140
|
+
body,
|
|
141
|
+
now: /* @__PURE__ */ new Date()
|
|
142
|
+
}, {
|
|
143
|
+
accessKeyId: config.accessKeyId,
|
|
144
|
+
secretAccessKey,
|
|
145
|
+
...sessionToken === void 0 ? {} : { sessionToken },
|
|
146
|
+
region: config.region,
|
|
147
|
+
service: "s3"
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
url: `${endpoint}/${path}`,
|
|
151
|
+
headers
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
const send = (method, key, body) => Effect.suspend(() => {
|
|
155
|
+
const { url, headers } = request(method, key, body);
|
|
156
|
+
const base = HttpClientRequest.make(method)(url, { headers });
|
|
157
|
+
const built = body.length === 0 ? base : HttpClientRequest.bodyUint8Array(base, body);
|
|
158
|
+
return http.execute(built).pipe(Effect.mapError((cause) => new BlobStoreError({
|
|
159
|
+
reason: "unreachable",
|
|
160
|
+
key,
|
|
161
|
+
cause
|
|
162
|
+
})));
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
get: (key, schema) => Effect.gen(function* () {
|
|
166
|
+
const response = yield* send("GET", key, /* @__PURE__ */ new Uint8Array(0));
|
|
167
|
+
if (response.status === 404) return Option.none();
|
|
168
|
+
if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new BlobStoreError({
|
|
169
|
+
reason: "refused",
|
|
170
|
+
key,
|
|
171
|
+
status: response.status
|
|
172
|
+
}));
|
|
173
|
+
const buffer = yield* response.arrayBuffer.pipe(Effect.mapError((cause) => new BlobStoreError({
|
|
174
|
+
reason: "unreachable",
|
|
175
|
+
key,
|
|
176
|
+
cause
|
|
177
|
+
})));
|
|
178
|
+
return Option.some(yield* Effect.fromResult(BlobEnvelope.decodeResult(new Uint8Array(buffer), schema)));
|
|
179
|
+
}),
|
|
180
|
+
put: (key, blob, schema) => Effect.gen(function* () {
|
|
181
|
+
const framed = yield* Effect.fromResult(BlobEnvelope.encodeResult(blob.metadata, blob.body, schema));
|
|
182
|
+
const response = yield* send("PUT", key, framed);
|
|
183
|
+
if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new BlobStoreError({
|
|
184
|
+
reason: "refused",
|
|
185
|
+
key,
|
|
186
|
+
status: response.status
|
|
187
|
+
}));
|
|
188
|
+
}),
|
|
189
|
+
has: (key) => Effect.gen(function* () {
|
|
190
|
+
const response = yield* send("HEAD", key, /* @__PURE__ */ new Uint8Array(0));
|
|
191
|
+
if (response.status === 404) return false;
|
|
192
|
+
if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new BlobStoreError({
|
|
193
|
+
reason: "refused",
|
|
194
|
+
key,
|
|
195
|
+
status: response.status
|
|
196
|
+
}));
|
|
197
|
+
return true;
|
|
198
|
+
})
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
//#endregion
|
|
203
|
+
export { BlobStore, BlobStoreError };
|
package/BlobTransfer.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/BlobTransfer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Raised when bytes could not be moved to or from a signed blob url.
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
var BlobTransferError = class extends Schema.TaggedErrorClass()("BlobTransferError", {
|
|
10
|
+
/** Which direction failed. */
|
|
11
|
+
reason: Schema.Literals(["uploadFailed", "downloadFailed"]),
|
|
12
|
+
/** The underlying failure, preserved structurally. */
|
|
13
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
14
|
+
}) {
|
|
15
|
+
get message() {
|
|
16
|
+
return this.reason === "uploadFailed" ? "Could not upload to the signed blob url" : "Could not download from the signed blob url";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
export { BlobTransferError };
|
package/CacheKey.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { GlobSet } from "@effected/glob";
|
|
4
|
+
|
|
5
|
+
//#region src/CacheKey.ts
|
|
6
|
+
/**
|
|
7
|
+
* Raised when a cache key cannot be derived from the filesystem.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
var CacheKeyError = class extends Schema.TaggedErrorClass()("CacheKeyError", {
|
|
12
|
+
/**
|
|
13
|
+
* `readFailed` — a file or directory that was going to be hashed could not be
|
|
14
|
+
* read. `badPattern` — a glob pattern would not compile. Key *derivation*
|
|
15
|
+
* itself is pure and total once the bytes are in hand, so these are the only
|
|
16
|
+
* two things that can go wrong.
|
|
17
|
+
*/
|
|
18
|
+
reason: Schema.Literals(["readFailed", "badPattern"]),
|
|
19
|
+
/** The path being read when it went wrong. */
|
|
20
|
+
path: Schema.optionalKey(Schema.String),
|
|
21
|
+
/** The pattern that would not compile. */
|
|
22
|
+
pattern: Schema.optionalKey(Schema.String),
|
|
23
|
+
/** The underlying failure, preserved structurally. */
|
|
24
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
25
|
+
}) {
|
|
26
|
+
get message() {
|
|
27
|
+
return this.reason === "readFailed" ? `Could not read "${this.path}" while deriving a cache key` : `"${this.pattern}" is not a usable glob pattern`;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The separator between key segments.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* `-` is not arbitrary: it is what every published Actions cache recipe uses,
|
|
35
|
+
* and a restore key is a **prefix match**, so the separator is what makes a
|
|
36
|
+
* partial key a meaningful boundary rather than a coincidence.
|
|
37
|
+
*/
|
|
38
|
+
const SEPARATOR = "-";
|
|
39
|
+
/** GitHub refuses a cache key longer than this. */
|
|
40
|
+
const MAX_KEY_LENGTH = 512;
|
|
41
|
+
/**
|
|
42
|
+
* One component of a cache key.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* Commas and newlines are refused because the runner uses both to delimit the
|
|
46
|
+
* restore-key list — a segment carrying one would silently become two keys.
|
|
47
|
+
*/
|
|
48
|
+
const Segment = Schema.String.check(Schema.isPattern(/^[^,\n\r]+$/));
|
|
49
|
+
const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values) => values.join(SEPARATOR).length <= MAX_KEY_LENGTH, { title: `a cache key of at most ${MAX_KEY_LENGTH} characters` }));
|
|
50
|
+
/**
|
|
51
|
+
* A GitHub Actions cache key and the restore-key ladder that goes with it.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* The ladder is the part every consumer re-derives and gets subtly wrong. A
|
|
55
|
+
* restore key is a **prefix match** on GitHub's side, so `Linux-pnpm` would
|
|
56
|
+
* also match `Linux-pnpm-store-…` from an unrelated cache; every rung here
|
|
57
|
+
* therefore ends in the separator. Deriving the rungs from the segments makes
|
|
58
|
+
* the primary key and its fallbacks impossible to drift apart, which is the
|
|
59
|
+
* failure that produces a cache that never hits and never says why.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
64
|
+
*
|
|
65
|
+
* const key = CacheKey.of("Linux", "pnpm-store", "abc123");
|
|
66
|
+
* key.key; // "Linux-pnpm-store-abc123"
|
|
67
|
+
* key.restoreKeys; // ["Linux-pnpm-store-", "Linux-"]
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @public
|
|
71
|
+
*/
|
|
72
|
+
var CacheKey = class CacheKey extends Schema.Class("CacheKey")({
|
|
73
|
+
/** The components, most general first. */
|
|
74
|
+
segments: Segments }) {
|
|
75
|
+
/** The primary key: every segment, joined. */
|
|
76
|
+
get key() {
|
|
77
|
+
return this.segments.join(SEPARATOR);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The restore keys, most specific first.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* Each rung drops the last segment and keeps the trailing separator, so a
|
|
84
|
+
* three-segment key falls back to two prefixes and a one-segment key falls
|
|
85
|
+
* back to nothing — there is no rung that would match every cache in the
|
|
86
|
+
* repository.
|
|
87
|
+
*/
|
|
88
|
+
get restoreKeys() {
|
|
89
|
+
const rungs = [];
|
|
90
|
+
for (let length = this.segments.length - 1; length >= 1; length -= 1) rungs.push(`${this.segments.slice(0, length).join(SEPARATOR)}${SEPARATOR}`);
|
|
91
|
+
return rungs;
|
|
92
|
+
}
|
|
93
|
+
/** Build a key from its segments. */
|
|
94
|
+
static of(...segments) {
|
|
95
|
+
return CacheKey.make({ segments });
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A branch-aware key.
|
|
99
|
+
*
|
|
100
|
+
* @remarks
|
|
101
|
+
* Ordering the segments `os → scope → branch → hash` is what makes the
|
|
102
|
+
* derived ladder do the right thing: the first rung falls back to any earlier
|
|
103
|
+
* cache **on this branch**, and only the next one reaches across branches. The
|
|
104
|
+
* reverse order would make the first fallback jump branches, which is how a
|
|
105
|
+
* feature branch ends up warming its cache from `main` and never noticing its
|
|
106
|
+
* own.
|
|
107
|
+
*
|
|
108
|
+
* Every part is an explicit argument rather than read from the environment,
|
|
109
|
+
* so a key is reproducible outside a runner and a test needs no ambient
|
|
110
|
+
* state.
|
|
111
|
+
*/
|
|
112
|
+
static forBranch(options) {
|
|
113
|
+
const segments = [];
|
|
114
|
+
if (options.os !== void 0) segments.push(options.os);
|
|
115
|
+
segments.push(options.scope, options.branch);
|
|
116
|
+
if (options.hash !== void 0) segments.push(options.hash);
|
|
117
|
+
return CacheKey.make({ segments });
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Hash a set of files into a single digest.
|
|
121
|
+
*
|
|
122
|
+
* @remarks
|
|
123
|
+
* **Byte-compatible with `@actions/glob`'s `hashFiles`**, and the three
|
|
124
|
+
* details that make it so are all easy to get wrong: the paths are sorted, each
|
|
125
|
+
* file is hashed on its own, and the per-file digest is fed into the
|
|
126
|
+
* accumulator as **binary** rather than hex. A hex-fed accumulator produces a
|
|
127
|
+
* perfectly plausible digest that simply never matches a cache entry written
|
|
128
|
+
* by any other action.
|
|
129
|
+
*
|
|
130
|
+
* Sorting and de-duplication happen here rather than at the call site, so a
|
|
131
|
+
* caller cannot accidentally make its key depend on the order it discovered
|
|
132
|
+
* files in.
|
|
133
|
+
*
|
|
134
|
+
* `Option.none()` for an empty set, because "nothing matched" is not a
|
|
135
|
+
* digest — it is the signal that the pattern is wrong, and a caller that
|
|
136
|
+
* folds it into a key silently caches against a constant.
|
|
137
|
+
*/
|
|
138
|
+
static hashFiles = Effect.fn("CacheKey.hashFiles")(function* (files) {
|
|
139
|
+
const fs = yield* FileSystem.FileSystem;
|
|
140
|
+
const ordered = [...new Set(files)].sort();
|
|
141
|
+
if (ordered.length === 0) return Option.none();
|
|
142
|
+
const accumulator = createHash("sha256");
|
|
143
|
+
for (const path of ordered) {
|
|
144
|
+
const bytes = yield* fs.readFile(path).pipe(Effect.mapError((cause) => new CacheKeyError({
|
|
145
|
+
reason: "readFailed",
|
|
146
|
+
path,
|
|
147
|
+
cause
|
|
148
|
+
})));
|
|
149
|
+
accumulator.update(createHash("sha256").update(bytes).digest());
|
|
150
|
+
}
|
|
151
|
+
return Option.some(accumulator.digest("hex"));
|
|
152
|
+
});
|
|
153
|
+
/**
|
|
154
|
+
* Every file under `workspace` that the pattern set matches.
|
|
155
|
+
*
|
|
156
|
+
* @remarks
|
|
157
|
+
* Discovery and matching are **two different jobs**, and separating them is
|
|
158
|
+
* what makes this testable and correct. The walk is core
|
|
159
|
+
* `FileSystem.readDirectory(recursive)`; the matching is `@effected/glob`'s
|
|
160
|
+
* full minimatch dialect — the same dialect `@actions/glob` uses, so a workflow
|
|
161
|
+
* author's `!**\/node_modules/**` behaves here exactly as it does in every
|
|
162
|
+
* other cache step in the same workflow. `node:fs.globSync` would have welded
|
|
163
|
+
* the two together behind one non-stubbable call *and* changed the dialect,
|
|
164
|
+
* and a dialect divergence surfaces as a silent cache-key difference — the
|
|
165
|
+
* worst failure mode a cache key has.
|
|
166
|
+
*
|
|
167
|
+
* Candidates are matched by their path **relative to the workspace**, which
|
|
168
|
+
* is what makes "never hash a file outside the workspace" structural rather
|
|
169
|
+
* than a rule someone has to remember. Directories are excluded: a directory
|
|
170
|
+
* called `notes.txt` matches `**\/*.txt` and is not a file, and hashing it
|
|
171
|
+
* would fail rather than being ignored.
|
|
172
|
+
*
|
|
173
|
+
* The answer is sorted, so a caller cannot make its key depend on the order
|
|
174
|
+
* the filesystem happened to report.
|
|
175
|
+
*/
|
|
176
|
+
static matchingFiles = Effect.fn("CacheKey.matchingFiles")(function* (options) {
|
|
177
|
+
const fs = yield* FileSystem.FileSystem;
|
|
178
|
+
const path = yield* Path.Path;
|
|
179
|
+
const set = yield* GlobSet.compile(options.patterns).pipe(Effect.mapError((cause) => new CacheKeyError({
|
|
180
|
+
reason: "badPattern",
|
|
181
|
+
pattern: cause.pattern,
|
|
182
|
+
cause
|
|
183
|
+
})));
|
|
184
|
+
const entries = yield* fs.readDirectory(options.workspace, { recursive: true }).pipe(Effect.mapError((cause) => new CacheKeyError({
|
|
185
|
+
reason: "readFailed",
|
|
186
|
+
path: options.workspace,
|
|
187
|
+
cause
|
|
188
|
+
})));
|
|
189
|
+
const matched = [];
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
if (!set.matches(entry.replaceAll("\\", "/"))) continue;
|
|
192
|
+
const absolute = path.join(options.workspace, entry);
|
|
193
|
+
if ((yield* fs.stat(absolute).pipe(Effect.mapError((cause) => new CacheKeyError({
|
|
194
|
+
reason: "readFailed",
|
|
195
|
+
path: absolute,
|
|
196
|
+
cause
|
|
197
|
+
})))).type === "File") matched.push(absolute);
|
|
198
|
+
}
|
|
199
|
+
return matched.sort();
|
|
200
|
+
});
|
|
201
|
+
/**
|
|
202
|
+
* Hash every file under `workspace` that the pattern set matches.
|
|
203
|
+
*
|
|
204
|
+
* @remarks
|
|
205
|
+
* {@link CacheKey.matchingFiles} into {@link CacheKey.hashFiles}, which is
|
|
206
|
+
* the pairing every consumer writes by hand — and the pairing whose two
|
|
207
|
+
* halves have to agree about ordering and about what counts as a file for the
|
|
208
|
+
* digest to match anything anyone else computed.
|
|
209
|
+
*
|
|
210
|
+
* `Option.none()` when nothing matched, for the same reason `hashFiles` gives:
|
|
211
|
+
* "no files" is not a digest, it is the signal that the patterns are wrong,
|
|
212
|
+
* and folding it into a key silently caches against a constant.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
217
|
+
* import { Effect, Option } from "effect";
|
|
218
|
+
*
|
|
219
|
+
* const key = Effect.gen(function* () {
|
|
220
|
+
* const hash = yield* CacheKey.hashMatching({
|
|
221
|
+
* workspace: "/home/runner/work/repo/repo",
|
|
222
|
+
* patterns: ["**\/pnpm-lock.yaml", "!**\/node_modules/**"],
|
|
223
|
+
* });
|
|
224
|
+
* return CacheKey.of("Linux", "pnpm-store", Option.getOrElse(hash, () => "empty"));
|
|
225
|
+
* });
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
static hashMatching = Effect.fn("CacheKey.hashMatching")(function* (options) {
|
|
229
|
+
return yield* CacheKey.hashFiles(yield* CacheKey.matchingFiles(options));
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
//#endregion
|
|
234
|
+
export { CacheKey, CacheKeyError };
|