@effected/github-actions 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ActionOutputs.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { ActionEnvironment } from "./ActionEnvironment.js";
2
2
  import { WorkflowCommand } from "./WorkflowCommand.js";
3
+ import { heredocBlock, isUsableName } from "./internal/runnerFile.js";
4
+ import { unstubbed } from "./internal/unstubbed.js";
3
5
  import { Console, Context, Effect, FileSystem, Layer, Schema } from "effect";
4
6
 
5
7
  //#region src/ActionOutputs.ts
@@ -83,26 +85,65 @@ var DetachedOutputError = class extends Schema.TaggedError()("DetachedOutputErro
83
85
  return `Runner file "${this.file}" cannot be reached from a detached worker — it configures the parent job's later steps, and a worker has none and may outlive the job. Publish this from the parent process instead.`;
84
86
  }
85
87
  };
86
- /** The base delimiter for a heredoc block. */
87
- const BASE_DELIMITER = "EFFECTED_EOF";
88
88
  /**
89
- * A delimiter guaranteed absent from `value`.
89
+ * The runner file each member appends to — spelled once, so the real layer,
90
+ * the detached layer and the recording double cannot disagree about which
91
+ * file a member's failure names.
92
+ */
93
+ const RUNNER_FILE = {
94
+ set: "GITHUB_OUTPUT",
95
+ setJson: "GITHUB_OUTPUT",
96
+ exportVariable: "GITHUB_ENV",
97
+ summary: "GITHUB_STEP_SUMMARY",
98
+ addPath: "GITHUB_PATH"
99
+ };
100
+ /**
101
+ * Run `effect` only if `name` can head a heredoc block; otherwise fail typed,
102
+ * naming the file. The one name check every construction of the service
103
+ * applies before a named write.
104
+ */
105
+ const withUsableName = (file, name, effect) => isUsableName(name) ? effect : Effect.fail(new InvalidOutputNameError({
106
+ file,
107
+ name
108
+ }));
109
+ /**
110
+ * The JSON text `setJson` publishes for `value`: encoded through `schema`,
111
+ * then stringified. The one step of `setJson` that can fail, shared by the
112
+ * real layer and both test doubles so that none of them can skip it.
113
+ */
114
+ const encodeJson = (name, value, schema) => Schema.encodeUnknownEffect(Schema.fromJsonString(schema))(value).pipe(Effect.mapError((cause) => new OutputEncodeError({
115
+ name,
116
+ cause
117
+ })));
118
+ /**
119
+ * One call an {@link ActionOutputs.recording} double observed.
90
120
  *
91
121
  * @remarks
92
- * GitHub's own toolkit uses a random UUID here and accepts the (tiny) chance
93
- * of collision. Deriving the delimiter instead makes collision **impossible**
94
- * rather than improbable, needs no randomness — so no `Crypto` in `R` and no
95
- * unreproducible output — and is trivially testable. A value that contains a
96
- * delimiter would terminate its block early and corrupt every entry after it,
97
- * which is a value-controlled injection into the runner's own file.
122
+ * `value` is always the string the runner would have seen: for `setJson` that
123
+ * is the **encoded** JSON text, after the same schema encode the real layer
124
+ * performs, so a test asserting on it reads exactly what a later step's
125
+ * `steps.<id>.outputs.<name>` expression would. `name` is present for the
126
+ * members that take one (`set`, `setJson`, `exportVariable`) and absent
127
+ * otherwise.
128
+ *
129
+ * @public
98
130
  */
99
- const delimiterFor = (value) => {
100
- let delimiter = BASE_DELIMITER;
101
- while (value.includes(delimiter)) delimiter = `${delimiter}_`;
102
- return delimiter;
103
- };
104
- /** A name that would break the block structure it lives in. */
105
- const isUsableName = (name) => name !== "" && !/[\r\n]/.test(name);
131
+ var RecordedOutput = class extends Schema.Class("RecordedOutput")({
132
+ /** Which {@link ActionOutputsShape} member was called. */
133
+ member: Schema.Literals([
134
+ "set",
135
+ "setJson",
136
+ "summary",
137
+ "exportVariable",
138
+ "addPath",
139
+ "setFailed",
140
+ "setSecret"
141
+ ]),
142
+ /** The output or variable name, for the members that take one. */
143
+ name: Schema.optionalKey(Schema.String),
144
+ /** What was published — value, message, path, content or secret — as the runner would have read it. */
145
+ value: Schema.String
146
+ }) {};
106
147
  const make = Effect.gen(function* () {
107
148
  const env = yield* ActionEnvironment;
108
149
  const fs = yield* FileSystem.FileSystem;
@@ -113,31 +154,19 @@ const make = Effect.gen(function* () {
113
154
  cause
114
155
  })));
115
156
  });
116
- const appendBlock = (file, name, value) => {
117
- if (!isUsableName(name)) return Effect.fail(new InvalidOutputNameError({
118
- file,
119
- name
120
- }));
121
- const delimiter = delimiterFor(value);
122
- return append(file, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
123
- };
124
- const set = (name, value) => appendBlock("GITHUB_OUTPUT", name, value);
157
+ const appendBlock = (file, name, value) => withUsableName(file, name, append(file, heredocBlock(name, value)));
158
+ const set = (name, value) => appendBlock(RUNNER_FILE.set, name, value);
125
159
  return {
126
160
  set,
127
- setJson: (name, value, schema) => Schema.encodeUnknownEffect(schema)(value).pipe(Effect.mapError((cause) => new OutputEncodeError({
128
- name,
129
- cause
130
- })), Effect.flatMap((encoded) => set(name, JSON.stringify(encoded)))),
131
- summary: (content) => append("GITHUB_STEP_SUMMARY", content),
132
- exportVariable: (name, value) => appendBlock("GITHUB_ENV", name, value),
133
- addPath: (path) => append("GITHUB_PATH", `${path}\n`),
161
+ setJson: (name, value, schema) => encodeJson(name, value, schema).pipe(Effect.flatMap((json) => set(name, json))),
162
+ summary: (content) => append(RUNNER_FILE.summary, content),
163
+ exportVariable: (name, value) => appendBlock(RUNNER_FILE.exportVariable, name, value),
164
+ addPath: (path) => append(RUNNER_FILE.addPath, `${path}\n`),
134
165
  setFailed: (message) => Console.log(WorkflowCommand.error(message)),
135
166
  setSecret: (value) => Console.log(WorkflowCommand.addMask(value))
136
167
  };
137
168
  });
138
- const unimplemented = (member) => {
139
- throw new Error(`ActionOutputs.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
140
- };
169
+ const dies = unstubbed("ActionOutputs.makeTest");
141
170
  /**
142
171
  * Everything an action publishes: step outputs, exported variables, `PATH`
143
172
  * additions, the job summary, log masking and failure annotations.
@@ -195,36 +224,99 @@ var ActionOutputs = class ActionOutputs extends Context.Service()("@effected/git
195
224
  */
196
225
  static layerDetached = Layer.succeed(this, {
197
226
  set: (name) => Effect.fail(new DetachedOutputError({
198
- file: "GITHUB_OUTPUT",
227
+ file: RUNNER_FILE.set,
199
228
  name
200
229
  })),
201
230
  setJson: (name) => Effect.fail(new DetachedOutputError({
202
- file: "GITHUB_OUTPUT",
231
+ file: RUNNER_FILE.setJson,
203
232
  name
204
233
  })),
205
- summary: () => Effect.fail(new DetachedOutputError({ file: "GITHUB_STEP_SUMMARY" })),
234
+ summary: () => Effect.fail(new DetachedOutputError({ file: RUNNER_FILE.summary })),
206
235
  exportVariable: (name) => Effect.fail(new DetachedOutputError({
207
- file: "GITHUB_ENV",
236
+ file: RUNNER_FILE.exportVariable,
208
237
  name
209
238
  })),
210
- addPath: () => Effect.fail(new DetachedOutputError({ file: "GITHUB_PATH" })),
239
+ addPath: () => Effect.fail(new DetachedOutputError({ file: RUNNER_FILE.addPath })),
211
240
  setFailed: (message) => Console.error(message),
212
241
  setSecret: () => Effect.void
213
242
  });
214
- /** A test double. Unstubbed members die rather than silently succeeding. */
215
- static makeTest = (overrides = {}) => ({
216
- set: () => Effect.sync(() => unimplemented("set")),
217
- setJson: () => Effect.sync(() => unimplemented("setJson")),
218
- summary: () => Effect.sync(() => unimplemented("summary")),
219
- exportVariable: () => Effect.sync(() => unimplemented("exportVariable")),
220
- addPath: () => Effect.sync(() => unimplemented("addPath")),
221
- setFailed: () => Effect.sync(() => unimplemented("setFailed")),
222
- setSecret: () => Effect.sync(() => unimplemented("setSecret")),
223
- ...overrides
224
- });
243
+ /**
244
+ * A test double. Unstubbed members die rather than silently succeeding.
245
+ *
246
+ * @remarks
247
+ * **`setJson` always encodes first.** The value is encoded through its
248
+ * schema exactly as the real layer does — failing typed with
249
+ * {@link OutputEncodeError} — and only then is a supplied `setJson`
250
+ * override called, with the original `(name, value, schema)`. An override
251
+ * that accepts `schema` and ignores it therefore **cannot disable
252
+ * output-schema checking**: a value/schema drift is a typed failure under
253
+ * this double whether or not the override looks at the schema. Without an
254
+ * override, a valid value still dies unimplemented as every other unstubbed
255
+ * member does. Most tests want neither: reach for
256
+ * {@link ActionOutputs.recording} instead.
257
+ */
258
+ static makeTest = (overrides = {}) => {
259
+ const setJson = overrides.setJson ?? (() => dies("setJson"));
260
+ return {
261
+ set: () => dies("set"),
262
+ summary: () => dies("summary"),
263
+ exportVariable: () => dies("exportVariable"),
264
+ addPath: () => dies("addPath"),
265
+ setFailed: () => dies("setFailed"),
266
+ setSecret: () => dies("setSecret"),
267
+ ...overrides,
268
+ setJson: (name, value, schema) => encodeJson(name, value, schema).pipe(Effect.flatMap(() => setJson(name, value, schema)))
269
+ };
270
+ };
225
271
  /** {@link ActionOutputs.makeTest} behind `Layer.succeed`. */
226
272
  static layerTest = (overrides = {}) => Layer.succeed(ActionOutputs, ActionOutputs.makeTest(overrides));
273
+ /**
274
+ * A recording double: every member appends a {@link RecordedOutput} to a
275
+ * journal the test reads back, in call order.
276
+ *
277
+ * @remarks
278
+ * This is the double most tests of an action want — "what did it publish?"
279
+ * — shipped so that nobody hand-writes a `setJson` override that drops the
280
+ * schema encode (the trap `makeTest` now closes too). `setJson` encodes
281
+ * through its schema exactly as the real layer does, failing typed with
282
+ * {@link OutputEncodeError} and recording nothing on a drift, and records
283
+ * the **encoded JSON text** — what the runner would have read — not the
284
+ * decoded value. `set`, `exportVariable` and `setJson` refuse an unusable
285
+ * name with {@link InvalidOutputNameError}, as the real layer does, rather
286
+ * than recording it.
287
+ *
288
+ * `setSecret` records the secret's **plaintext** under
289
+ * `member: "setSecret"`. That is deliberate for a recording double: the
290
+ * journal never leaves the test, and a test asserting that a value *was*
291
+ * masked needs to see which one. Do not hand this journal to anything that
292
+ * logs.
293
+ *
294
+ * Each call returns a fresh, independent journal — there is no state shared
295
+ * between two `recording()` calls.
296
+ */
297
+ static recording = () => {
298
+ const entries = [];
299
+ const record = (member, value, name) => Effect.sync(() => {
300
+ entries.push(RecordedOutput.make({
301
+ member,
302
+ value,
303
+ ...name === void 0 ? {} : { name }
304
+ }));
305
+ });
306
+ return {
307
+ layer: Layer.succeed(ActionOutputs, {
308
+ set: (name, value) => withUsableName(RUNNER_FILE.set, name, record("set", value, name)),
309
+ setJson: (name, value, schema) => withUsableName(RUNNER_FILE.setJson, name, encodeJson(name, value, schema).pipe(Effect.flatMap((json) => record("setJson", json, name)))),
310
+ summary: (content) => record("summary", content),
311
+ exportVariable: (name, value) => withUsableName(RUNNER_FILE.exportVariable, name, record("exportVariable", value, name)),
312
+ addPath: (path) => record("addPath", path),
313
+ setFailed: (message) => record("setFailed", message),
314
+ setSecret: (value) => record("setSecret", value)
315
+ }),
316
+ entries: () => entries.slice()
317
+ };
318
+ };
227
319
  };
228
320
 
229
321
  //#endregion
230
- export { ActionOutputs, DetachedOutputError, InvalidOutputNameError, OutputEncodeError, RunnerFileUnavailableError, RunnerFileWriteError };
322
+ export { ActionOutputs, DetachedOutputError, InvalidOutputNameError, OutputEncodeError, RecordedOutput, RunnerFileUnavailableError, RunnerFileWriteError };
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
  };