@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.
@@ -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 };
@@ -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 };