@effected/github-actions 0.13.0 → 0.13.2

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/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 path = yield* env.get("GITHUB_STATE").pipe(Effect.mapError((cause) => new ActionStateError({
48
+ const writeFailed = (cause) => new ActionStateError({
47
49
  reason: "writeFailed",
48
50
  key,
49
51
  cause
50
- })));
51
- let delimiter = "EFFECTED_EOF";
52
- while (serialized.includes(delimiter)) delimiter = `${delimiter}_`;
53
- yield* fs.writeFileString(path, `${key}<<${delimiter}\n${serialized}\n${delimiter}\n`, { flag: "a" }).pipe(Effect.mapError((cause) => new ActionStateError({
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 parsed = yield* Effect.try({
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 unimplemented = (member) => {
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: () => Effect.sync(() => unimplemented("save")),
134
- get: () => Effect.sync(() => unimplemented("get")),
135
- getOptional: () => Effect.sync(() => unimplemented("getOptional")),
136
- saveSecret: () => Effect.sync(() => unimplemented("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 { Context, Effect, FileSystem, Layer, Option, Path, Result, Schema, Stream } from "effect";
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* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.match(found, {
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: `${name} is not set — the artifact service is only reachable from a \`uses:\` step, never from \`run:\``
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) => fromTwirp(failure, artifact)));
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.string(command, { includeStderr: true }).pipe(Effect.mapError((cause) => new ArtifactError({
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 ((yield* spawner.exitCode(command).pipe(Effect.mapError((cause) => new ArtifactError({
175
- reason: "archiveFailed",
176
- artifact,
177
- cause
178
- })))) !== 0) return yield* Effect.fail(new ArtifactError({
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.gen(function* () {
201
- const accumulator = createHash("sha256");
202
- yield* Stream.runForEach(fs.stream(file), (chunk) => Effect.sync(() => {
203
- accumulator.update(chunk);
204
- })).pipe(Effect.mapError((cause) => new ArtifactError({
205
- reason: "archiveFailed",
206
- artifact,
207
- cause
208
- })));
209
- return accumulator.digest("hex");
210
- });
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 unimplemented = (member) => {
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: () => Effect.sync(() => unimplemented("upload")),
412
- list: () => Effect.sync(() => unimplemented("list")),
413
- get: () => Effect.sync(() => unimplemented("get")),
414
- download: () => Effect.sync(() => unimplemented("download")),
415
- delete: () => Effect.sync(() => unimplemented("delete")),
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(JSON.stringify(encoded.success));
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
- let parsed;
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,
@@ -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 { CONFLICT, isOk, stringField, twirpCall } from "./internal/twirp.js";
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 = createHash("sha256").update("blobstore|1.0").digest("hex");
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: `${name} is not set — the Actions results backend is only reachable from a \`uses:\` step, never from \`run:\``
59
+ detail: misconfiguredDetail(name, "Actions results backend")
71
60
  })));
72
- const call = (method, body, key) => Effect.gen(function* () {
73
- const { baseUrl, token } = yield* backend;
74
- return yield* twirpCall({
75
- http,
76
- baseUrl,
77
- service: SERVICE,
78
- token,
79
- method,
80
- body
81
- }).pipe(Effect.mapError((failure) => fromTwirp(failure, key)));
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(call("GetCacheEntryDownloadURL", {
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 created = yield* call("CreateCacheEntry", {
104
- key,
105
- version: VERSION
106
- }, key);
107
- if (created === CONFLICT) return;
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: () => Effect.sync(() => unimplemented("get")),
89
- put: () => Effect.sync(() => unimplemented("put")),
90
- has: () => Effect.sync(() => unimplemented("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 unimplemented = (member) => {
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
- if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new BlobStoreError({
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
- if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new BlobStoreError({
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
- if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new BlobStoreError({
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
- if (options.os !== void 0) segments.push(options.os);
262
- segments.push(options.scope, options.branch);
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 createHash("sha256").update(input).digest("hex").slice(0, length);
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((bytes) => createHash("sha256").update(bytes).digest()))), { concurrency: 8 });
334
- const accumulator = createHash("sha256");
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 core
344
- * `FileSystem.readDirectory(recursive)`; the matching is `@effected/glob`'s
345
- * full minimatch dialect — the same dialect `@actions/glob` uses, so a workflow
346
- * author's `!**\/node_modules/**` behaves here exactly as it does in every
347
- * other cache step in the same workflow. `node:fs.globSync` would have welded
348
- * the two together behind one non-stubbable call *and* changed the dialect,
349
- * and a dialect divergence surfaces as a silent cache-key difference — the
350
- * worst failure mode a cache key has.
351
- *
352
- * Candidates are matched by their path **relative to the workspace**, which
353
- * is what makes "never hash a file outside the workspace" structural rather
354
- * than a rule someone has to remember. Directories are excluded: a directory
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
- const entries = yield* fs.readDirectory(options.workspace, { recursive: true }).pipe(Effect.mapError((cause) => new CacheKeyReadError({
369
- path: options.workspace,
380
+ yield* fs.stat(workspace).pipe(Effect.mapError((cause) => new CacheKeyReadError({
381
+ path: workspace,
370
382
  cause
371
383
  })));
372
- const matched = [];
373
- for (const entry of entries) {
374
- if (!set.matches(entry.replaceAll("\\", "/"))) continue;
375
- const absolute = path.join(options.workspace, entry);
376
- if ((yield* fs.stat(absolute).pipe(Effect.mapError((cause) => new CacheKeyReadError({
377
- path: absolute,
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
- })))).type === "File") matched.push(absolute);
403
+ })));
404
+ for (const match of found) candidates.add(match);
380
405
  }
381
- return matched.sort();
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 === "render" ? "The check document could not be regenerated from the reported state" : this.kind === "read" ? "Reading the check document's current text failed" : "Writing the check document failed";
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
- if (!Number.isNaN(leftEpoch) && !Number.isNaN(rightEpoch)) return leftEpoch === rightEpoch ? 0 : leftEpoch < rightEpoch ? -1 : 1;
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 === rightNumber ? 0 : leftNumber < rightNumber ? -1 : 1;
123
+ if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return order(leftNumber, rightNumber);
116
124
  }
117
- return left === right ? 0 : left < right ? -1 : 1;
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: overrides.report ?? (() => Effect.sync(() => unstubbed("report"))),
297
- checks: overrides.checks ?? Effect.sync(() => unstubbed("checks")),
298
- flush: overrides.flush ?? Effect.sync(() => unstubbed("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 unstubbed = (member) => {
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 };