@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/ActionCache.js CHANGED
@@ -1,10 +1,14 @@
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, isOk, stringField, twirpCall } from "./internal/twirp.js";
5
- import { Context, Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
4
+ import { misconfiguredDetail, resultsBackend } from "./internal/actionsResults.js";
5
+ import { twirpCall, twirpFailureFields } from "./internal/twirp.js";
6
+ import { CACHE_SERVICE, finalizeUpload, lookupDownload, reserveUpload } from "./internal/cacheService.js";
7
+ import { sha256Hex } from "./internal/digest.js";
8
+ import { isWindowsRunner } from "./internal/runner.js";
9
+ import { spawnOnce } from "./internal/spawn.js";
10
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
6
11
  import { HttpClient } from "effect/unstable/http";
7
- import { createHash } from "node:crypto";
8
12
  import { BlobClient, BlockBlobClient } from "@azure/storage-blob";
9
13
  import { GlobPattern, GlobSet } from "@effected/glob";
10
14
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
@@ -53,8 +57,6 @@ var ActionCacheError = class extends Schema.TaggedError()("ActionCacheError", {
53
57
  }
54
58
  }
55
59
  };
56
- /** The Twirp service the Actions cache protocol lives under. */
57
- const SERVICE = "github.actions.results.api.v1.CacheService";
58
60
  /**
59
61
  * The entry version, byte-compatible with `actions/cache`.
60
62
  *
@@ -74,11 +76,11 @@ const SERVICE = "github.actions.results.api.v1.CacheService";
74
76
  * version the restore side cannot reproduce, and every save would be an entry
75
77
  * no restore ever finds.
76
78
  */
77
- const versionOf = (paths) => createHash("sha256").update([
79
+ const versionOf = (paths) => sha256Hex([
78
80
  ...paths,
79
81
  "gzip",
80
82
  "1.0"
81
- ].join("|")).digest("hex");
83
+ ].join("|"));
82
84
  /**
83
85
  * The Azure half, duplicated on purpose.
84
86
  *
@@ -109,17 +111,6 @@ const azure = {
109
111
  })
110
112
  }).pipe(Effect.asVoid)
111
113
  };
112
- const fromTwirp = (failure, key) => failure.kind === "status" ? new ActionCacheError({
113
- reason: "refused",
114
- key,
115
- detail: failure.method,
116
- ...failure.status === void 0 ? {} : { status: failure.status }
117
- }) : new ActionCacheError({
118
- reason: "unreachable",
119
- key,
120
- detail: `${failure.method} did not answer with a Twirp body`,
121
- ...failure.cause === void 0 ? {} : { cause: failure.cause }
122
- });
123
114
  /** The primary key and the ladder to fall back through. */
124
115
  const ladder = (key, restoreKeys) => typeof key === "string" ? {
125
116
  primary: key,
@@ -134,24 +125,38 @@ const make = (transfer) => Effect.gen(function* () {
134
125
  const path = yield* Path.Path;
135
126
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
136
127
  const env = yield* ActionEnvironment;
137
- const windows = yield* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.match(found, {
138
- onNone: () => false,
139
- onSome: (os) => os.toLowerCase() === "windows"
140
- }));
128
+ const windows = yield* isWindowsRunner(env);
129
+ /** A filesystem or subprocess failure on the way to or from the archive. */
130
+ const archiveFailed = (key) => (cause) => new ActionCacheError({
131
+ reason: "archiveFailed",
132
+ key,
133
+ cause
134
+ });
141
135
  const backend = resultsBackend(env).pipe(Effect.mapError((name) => new ActionCacheError({
142
136
  reason: "misconfigured",
143
- detail: `${name} is not set — the Actions cache is only reachable from a \`uses:\` step, never from \`run:\``
137
+ detail: misconfiguredDetail(name, "Actions cache")
144
138
  })));
145
- const call = (method, body, key) => Effect.gen(function* () {
146
- const { baseUrl, token } = yield* backend;
147
- return yield* twirpCall({
148
- http,
149
- baseUrl,
150
- service: SERVICE,
151
- token,
152
- method,
153
- body
154
- }).pipe(Effect.mapError((failure) => fromTwirp(failure, key)));
139
+ /** The cache service, for one key: every failure names it. */
140
+ const cacheService = (key) => ({
141
+ call: (method, body) => Effect.gen(function* () {
142
+ const { baseUrl, token } = yield* backend;
143
+ return yield* twirpCall({
144
+ http,
145
+ baseUrl,
146
+ service: CACHE_SERVICE,
147
+ token,
148
+ method,
149
+ body
150
+ }).pipe(Effect.mapError((failure) => new ActionCacheError({
151
+ ...twirpFailureFields(failure),
152
+ key
153
+ })));
154
+ }),
155
+ refused: (detail) => new ActionCacheError({
156
+ reason: "refused",
157
+ key,
158
+ detail
159
+ })
155
160
  });
156
161
  /**
157
162
  * Run `tar` ONCE, keeping its stderr.
@@ -162,39 +167,24 @@ const make = (transfer) => Effect.gen(function* () {
162
167
  * exits 1 to say so, which is a warning rather than an error. Exit 2 and
163
168
  * above stays a failure on every platform.
164
169
  *
165
- * Output and exit code come from the SAME `spawn` handle: the spawner's
166
- * `string` and `exitCode` convenience members each spawn independently
167
- * (core derives both from `spawn`), so calling them back-to-back
168
- * double-executed every archive operation — and on a failure reported the
169
- * FIRST run's output as the second run's complaint. `tar` being idempotent
170
- * kept this latent here; the same shape failed loudly in
171
- * `ToolInstaller.extractZip` on Windows.
170
+ * Output and exit code come from the SAME `spawn` handle
171
+ * (`internal/spawn.ts` — the spawner's convenience members each spawn
172
+ * independently, and the double run failed loudly in
173
+ * `ToolInstaller.extractZip` on Windows).
172
174
  */
173
- const tar = (args, key, tolerateWarnings) => Effect.scoped(Effect.gen(function* () {
174
- const command = ChildProcess.make("tar", [...args]);
175
- const archiveError = (cause) => new ActionCacheError({
176
- reason: "archiveFailed",
177
- key,
178
- cause
179
- });
180
- const handle = yield* spawner.spawn(command).pipe(Effect.mapError(archiveError));
181
- const output = yield* Stream.mkString(Stream.decodeText(handle.all)).pipe(Effect.mapError(archiveError));
182
- const code = yield* handle.exitCode.pipe(Effect.mapError(archiveError));
175
+ const tar = (args, key, tolerateWarnings) => Effect.gen(function* () {
176
+ const { output, code } = yield* spawnOnce(spawner, ChildProcess.make("tar", [...args])).pipe(Effect.mapError(archiveFailed(key)));
183
177
  if (code !== 0 && !(tolerateWarnings && code === 1)) return yield* Effect.fail(new ActionCacheError({
184
178
  reason: "archiveFailed",
185
179
  key,
186
180
  stderr: output.trim()
187
181
  }));
188
- }));
182
+ });
189
183
  /**
190
184
  * A scratch archive path — plus the scratch directory itself, where the
191
185
  * save side writes its tar manifest — removed on every exit path.
192
186
  */
193
- const withArchive = (key, use) => Effect.acquireUseRelease(fs.makeTempDirectory({ prefix: "effected-cache-" }).pipe(Effect.mapError((cause) => new ActionCacheError({
194
- reason: "archiveFailed",
195
- key,
196
- cause
197
- }))), (directory) => use(path.join(directory, "cache.tar.gz"), directory), (directory) => Effect.ignore(fs.remove(directory, {
187
+ const withArchive = (key, use) => Effect.acquireUseRelease(fs.makeTempDirectory({ prefix: "effected-cache-" }).pipe(Effect.mapError(archiveFailed(key))), (directory) => use(path.join(directory, "cache.tar.gz"), directory), (directory) => Effect.ignore(fs.remove(directory, {
198
188
  recursive: true,
199
189
  force: true
200
190
  })));
@@ -291,78 +281,44 @@ const make = (transfer) => Effect.gen(function* () {
291
281
  save: Effect.fn("ActionCache.save")(function* (paths, key) {
292
282
  const { primary } = ladder(key, void 0);
293
283
  yield* Effect.annotateCurrentSpan({ key: primary });
294
- const version = versionOf(paths);
295
284
  if (paths.length === 0) return yield* Effect.fail(new ActionCacheError({
296
285
  reason: "archiveFailed",
297
286
  key: primary,
298
287
  detail: "no paths were given to cache"
299
288
  }));
289
+ const version = versionOf(paths);
300
290
  const resolved = yield* resolvePaths(paths, primary);
301
291
  yield* withArchive(primary, (archive, scratch) => Effect.gen(function* () {
302
292
  const manifest = path.join(scratch, "manifest.txt");
303
- yield* fs.writeFileString(manifest, `${resolved.join("\n")}\n`).pipe(Effect.mapError((cause) => new ActionCacheError({
304
- reason: "archiveFailed",
305
- key: primary,
306
- cause
307
- })));
293
+ yield* fs.writeFileString(manifest, `${resolved.join("\n")}\n`).pipe(Effect.mapError(archiveFailed(primary)));
308
294
  yield* tar([
309
295
  "czPf",
310
296
  archive,
311
297
  "-T",
312
298
  manifest
313
299
  ], primary, false);
314
- const size = yield* fs.stat(archive).pipe(Effect.mapError((cause) => new ActionCacheError({
315
- reason: "archiveFailed",
316
- key: primary,
317
- cause
318
- })));
319
- const created = yield* call("CreateCacheEntry", {
320
- key: primary,
321
- version
322
- }, primary);
323
- if (created === CONFLICT) return;
324
- const url = stringField(created, "signedUploadUrl");
325
- if (!isOk(created) || url === void 0) return yield* Effect.fail(new ActionCacheError({
326
- reason: "refused",
327
- key: primary,
328
- detail: "CreateCacheEntry returned no upload url"
329
- }));
330
- yield* transfer.uploadFile(url, archive).pipe(moved(primary));
331
- const finalized = yield* call("FinalizeCacheEntryUpload", {
332
- key: primary,
333
- version,
334
- size_bytes: String(size.size)
335
- }, primary);
336
- if (finalized === CONFLICT || !isOk(finalized)) return yield* Effect.fail(new ActionCacheError({
337
- reason: "refused",
338
- key: primary,
339
- detail: "FinalizeCacheEntryUpload did not confirm the upload"
340
- }));
300
+ const size = yield* fs.stat(archive).pipe(Effect.mapError(archiveFailed(primary)));
301
+ const service = cacheService(primary);
302
+ const url = yield* reserveUpload(service, primary, version);
303
+ if (Option.isNone(url)) return;
304
+ yield* transfer.uploadFile(url.value, archive).pipe(moved(primary));
305
+ yield* finalizeUpload(service, primary, version, size.size);
341
306
  }));
342
307
  }),
343
308
  restore: Effect.fn("ActionCache.restore")(function* (paths, key, restoreKeys) {
344
309
  const { primary, fallbacks } = ladder(key, restoreKeys);
345
310
  yield* Effect.annotateCurrentSpan({ key: primary });
346
- const version = versionOf(paths);
347
- const found = yield* call("GetCacheEntryDownloadURL", {
348
- key: primary,
349
- restore_keys: [...fallbacks],
350
- version
351
- }, primary);
352
- if (found === CONFLICT || !isOk(found)) return Option.none();
353
- const url = stringField(found, "signedDownloadUrl");
354
- if (url === void 0) return Option.none();
311
+ const hit = yield* lookupDownload(cacheService(primary), primary, fallbacks, versionOf(paths));
312
+ if (Option.isNone(hit)) return Option.none();
355
313
  yield* withArchive(primary, (archive) => Effect.gen(function* () {
356
- yield* transfer.downloadToFile(url, archive).pipe(moved(primary));
314
+ yield* transfer.downloadToFile(hit.value.url, archive).pipe(moved(primary));
357
315
  yield* tar([windows ? "xzPkf" : "xzPf", archive], primary, windows);
358
316
  }));
359
- return Option.some(stringField(found, "matchedKey") ?? primary);
317
+ return Option.some(hit.value.matchedKey ?? primary);
360
318
  })
361
319
  };
362
320
  });
363
- const unimplemented = (member) => {
364
- throw new Error(`ActionCache.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
365
- };
321
+ const dies = unstubbed("ActionCache.makeTest");
366
322
  /**
367
323
  * The runner's own cache: archive a set of paths under a key, and get them back
368
324
  * in a later job.
@@ -416,8 +372,8 @@ var ActionCache = class ActionCache extends Context.Service()("@effected/github-
416
372
  static layerWith = (transfer) => Layer.effect(ActionCache, make(transfer));
417
373
  /** A test double. Unstubbed members die rather than reporting a miss. */
418
374
  static makeTest = (overrides = {}) => ({
419
- save: () => Effect.sync(() => unimplemented("save")),
420
- restore: () => Effect.sync(() => unimplemented("restore")),
375
+ save: () => dies("save"),
376
+ restore: () => dies("restore"),
421
377
  ...overrides
422
378
  });
423
379
  /** {@link ActionCache.makeTest} behind `Layer.succeed`. */
@@ -129,62 +129,31 @@ const make = (base) => Effect.gen(function* () {
129
129
  get,
130
130
  getOptional: lookup,
131
131
  isDebug: Effect.map(lookup("RUNNER_DEBUG"), (value) => Option.isSome(value) && value.value === "1"),
132
- github: Effect.gen(function* () {
133
- const [repository, repositoryOwner, ref, refName, sha, workflow, job] = yield* Effect.all([
134
- get("GITHUB_REPOSITORY"),
135
- get("GITHUB_REPOSITORY_OWNER"),
136
- get("GITHUB_REF"),
137
- get("GITHUB_REF_NAME"),
138
- get("GITHUB_SHA"),
139
- get("GITHUB_WORKFLOW"),
140
- get("GITHUB_JOB")
141
- ]);
142
- const runId = yield* Effect.flatMap(get("GITHUB_RUN_ID"), (raw) => numeric("GITHUB_RUN_ID", raw));
143
- const runAttempt = yield* Effect.flatMap(get("GITHUB_RUN_ATTEMPT"), (raw) => numeric("GITHUB_RUN_ATTEMPT", raw));
144
- const headRef = yield* lookup("GITHUB_HEAD_REF");
145
- const [eventName, actor, serverUrl, apiUrl, graphqlUrl, workspace] = yield* Effect.all([
146
- get("GITHUB_EVENT_NAME"),
147
- get("GITHUB_ACTOR"),
148
- get("GITHUB_SERVER_URL"),
149
- get("GITHUB_API_URL"),
150
- get("GITHUB_GRAPHQL_URL"),
151
- get("GITHUB_WORKSPACE")
152
- ]);
153
- return GitHubContext.make({
154
- repository,
155
- repositoryOwner,
156
- ref,
157
- refName,
158
- headRef,
159
- sha,
160
- workflow,
161
- job,
162
- runId,
163
- runAttempt,
164
- eventName,
165
- actor,
166
- serverUrl,
167
- apiUrl,
168
- graphqlUrl,
169
- workspace
170
- });
171
- }),
172
- runner: Effect.gen(function* () {
173
- const [os, arch, name, temp, toolCache] = yield* Effect.all([
174
- get("RUNNER_OS"),
175
- get("RUNNER_ARCH"),
176
- get("RUNNER_NAME"),
177
- get("RUNNER_TEMP"),
178
- get("RUNNER_TOOL_CACHE")
179
- ]);
180
- return RunnerContext.make({
181
- os,
182
- arch,
183
- name,
184
- temp,
185
- toolCache
186
- });
187
- }),
132
+ github: Effect.map(Effect.all({
133
+ repository: get("GITHUB_REPOSITORY"),
134
+ repositoryOwner: get("GITHUB_REPOSITORY_OWNER"),
135
+ ref: get("GITHUB_REF"),
136
+ refName: get("GITHUB_REF_NAME"),
137
+ headRef: lookup("GITHUB_HEAD_REF"),
138
+ sha: get("GITHUB_SHA"),
139
+ workflow: get("GITHUB_WORKFLOW"),
140
+ job: get("GITHUB_JOB"),
141
+ runId: Effect.flatMap(get("GITHUB_RUN_ID"), (raw) => numeric("GITHUB_RUN_ID", raw)),
142
+ runAttempt: Effect.flatMap(get("GITHUB_RUN_ATTEMPT"), (raw) => numeric("GITHUB_RUN_ATTEMPT", raw)),
143
+ eventName: get("GITHUB_EVENT_NAME"),
144
+ actor: get("GITHUB_ACTOR"),
145
+ serverUrl: get("GITHUB_SERVER_URL"),
146
+ apiUrl: get("GITHUB_API_URL"),
147
+ graphqlUrl: get("GITHUB_GRAPHQL_URL"),
148
+ workspace: get("GITHUB_WORKSPACE")
149
+ }), (fields) => GitHubContext.make(fields)),
150
+ runner: Effect.map(Effect.all({
151
+ os: get("RUNNER_OS"),
152
+ arch: get("RUNNER_ARCH"),
153
+ name: get("RUNNER_NAME"),
154
+ temp: get("RUNNER_TEMP"),
155
+ toolCache: get("RUNNER_TOOL_CACHE")
156
+ }), (fields) => RunnerContext.make(fields)),
188
157
  payload: Effect.gen(function* () {
189
158
  const path = yield* get("GITHUB_EVENT_PATH");
190
159
  const raw = yield* fs.readFileString(path).pipe(Effect.mapError((cause) => new ActionEnvironmentError({
package/ActionInput.js CHANGED
@@ -374,16 +374,17 @@ var ActionInput = class ActionInput {
374
374
  /** Unset, and the `""` the runner writes for an unsupplied input, are both absent. */
375
375
  const present = (value) => value !== void 0 && value !== "";
376
376
  /**
377
- * Resolve one runner-variable name: the verbatim entry wins; an
378
- * `INPUT_`-prefixed miss then consults the input-name entries through the
379
- * one spelling of the derivation (`inputVariable`).
377
+ * The input-name entries, keyed by the runner variable they would be
378
+ * (`inputVariable`, the one spelling of the derivation) — built once,
379
+ * so an `INPUT_`-prefixed miss is a map read rather than a scan of the
380
+ * whole environment per `Config` read.
380
381
  */
382
+ const byInputName = /* @__PURE__ */ new Map();
383
+ for (const [key, value] of Object.entries(env)) if (!key.startsWith("INPUT_") && present(value) && !byInputName.has(inputVariable(key))) byInputName.set(inputVariable(key), value);
384
+ /** Resolve one runner-variable name: the verbatim entry wins, then the input-name entry. */
381
385
  const lookup = (name) => {
382
386
  const direct = env[name];
383
- if (present(direct)) return direct;
384
- if (name.startsWith("INPUT_")) {
385
- for (const [key, value] of Object.entries(env)) if (!key.startsWith("INPUT_") && inputVariable(key) === name && present(value)) return value;
386
- }
387
+ return present(direct) ? direct : byInputName.get(name);
387
388
  };
388
389
  return ConfigProvider.make((path) => {
389
390
  const single = path.length === 1 && typeof path[0] === "string" ? path[0] : void 0;
package/ActionLogger.js CHANGED
@@ -54,15 +54,13 @@ const readAnnotations = (record) => {
54
54
  ...endColumn === void 0 ? {} : { endColumn }
55
55
  };
56
56
  };
57
- /** The inverse: annotation fields as a record `Effect.annotateLogs` accepts. */
58
- const annotationRecord = (properties) => ({
59
- ...properties.title === void 0 ? {} : { title: properties.title },
60
- ...properties.file === void 0 ? {} : { file: properties.file },
61
- ...properties.startLine === void 0 ? {} : { startLine: properties.startLine },
62
- ...properties.endLine === void 0 ? {} : { endLine: properties.endLine },
63
- ...properties.startColumn === void 0 ? {} : { startColumn: properties.startColumn },
64
- ...properties.endColumn === void 0 ? {} : { endColumn: properties.endColumn }
65
- });
57
+ /**
58
+ * The inverse: annotation fields as a record `Effect.annotateLogs` accepts.
59
+ * A plain copy: `exactOptionalPropertyTypes` keeps an absent field absent
60
+ * rather than present-and-`undefined`, and {@link readAnnotations} drops
61
+ * anything that is not a string or number on the way back out.
62
+ */
63
+ const annotationRecord = (properties) => ({ ...properties });
66
64
  /**
67
65
  * Map one log entry onto the line the runner should see.
68
66
  *
@@ -79,6 +77,10 @@ const renderEntry = (level, message, annotations) => {
79
77
  }
80
78
  return LogLevel.isGreaterThanOrEqualTo(level, "Info") ? text : WorkflowCommand.debug(text);
81
79
  };
80
+ /** The line the runner should see for one log event, with the fiber's annotations. */
81
+ const renderLogger = Logger.make((options) => renderEntry(options.logLevel, options.message, options.fiber.getRef(References.CurrentLogAnnotations)));
82
+ /** {@link renderLogger} written through core `Console`. */
83
+ const commandLogger = Logger.withConsoleLog(renderLogger);
82
84
  /**
83
85
  * The buffer the current fiber is writing into, or `null` when not buffering.
84
86
  *
@@ -118,8 +120,7 @@ const make = Effect.gen(function* () {
118
120
  };
119
121
  const buffering = Logger.make((options) => {
120
122
  if (LogLevel.isGreaterThanOrEqualTo(options.logLevel, "Warn")) {
121
- const annotations = options.fiber.getRef(References.CurrentLogAnnotations);
122
- options.fiber.getRef(Console.Console).log(renderEntry(options.logLevel, options.message, annotations));
123
+ commandLogger.log(options);
123
124
  return;
124
125
  }
125
126
  state.entries.push(formatMessage(options.message));
@@ -170,10 +171,7 @@ var ActionLogger = class ActionLogger extends Context.Service()("@effected/githu
170
171
  * It writes through core `Console`, which is what makes its output
171
172
  * observable in a test without a runner.
172
173
  */
173
- static logger = Logger.make((options) => {
174
- const annotations = options.fiber.getRef(References.CurrentLogAnnotations);
175
- options.fiber.getRef(Console.Console).log(renderEntry(options.logLevel, options.message, annotations));
176
- });
174
+ static logger = commandLogger;
177
175
  /** {@link ActionLogger.logger} installed as the only logger. */
178
176
  static layerLogger = Logger.layer([ActionLogger.logger]);
179
177
  /**
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,35 +85,36 @@ 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`.
90
- *
91
- * @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.
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.
98
92
  */
99
- const delimiterFor = (value) => {
100
- let delimiter = BASE_DELIMITER;
101
- while (value.includes(delimiter)) delimiter = `${delimiter}_`;
102
- return delimiter;
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"
103
99
  };
104
- /** A name that would break the block structure it lives in. */
105
- const isUsableName = (name) => name !== "" && !/[\r\n]/.test(name);
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
+ }));
106
109
  /**
107
110
  * The JSON text `setJson` publishes for `value`: encoded through `schema`,
108
111
  * then stringified. The one step of `setJson` that can fail, shared by the
109
112
  * real layer and both test doubles so that none of them can skip it.
110
113
  */
111
- const encodeJson = (name, value, schema) => Schema.encodeUnknownEffect(schema)(value).pipe(Effect.mapError((cause) => new OutputEncodeError({
114
+ const encodeJson = (name, value, schema) => Schema.encodeUnknownEffect(Schema.fromJsonString(schema))(value).pipe(Effect.mapError((cause) => new OutputEncodeError({
112
115
  name,
113
116
  cause
114
- })), Effect.map((encoded) => JSON.stringify(encoded)));
117
+ })));
115
118
  /**
116
119
  * One call an {@link ActionOutputs.recording} double observed.
117
120
  *
@@ -151,28 +154,19 @@ const make = Effect.gen(function* () {
151
154
  cause
152
155
  })));
153
156
  });
154
- const appendBlock = (file, name, value) => {
155
- if (!isUsableName(name)) return Effect.fail(new InvalidOutputNameError({
156
- file,
157
- name
158
- }));
159
- const delimiter = delimiterFor(value);
160
- return append(file, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
161
- };
162
- 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);
163
159
  return {
164
160
  set,
165
161
  setJson: (name, value, schema) => encodeJson(name, value, schema).pipe(Effect.flatMap((json) => set(name, json))),
166
- summary: (content) => append("GITHUB_STEP_SUMMARY", content),
167
- exportVariable: (name, value) => appendBlock("GITHUB_ENV", name, value),
168
- addPath: (path) => append("GITHUB_PATH", `${path}\n`),
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`),
169
165
  setFailed: (message) => Console.log(WorkflowCommand.error(message)),
170
166
  setSecret: (value) => Console.log(WorkflowCommand.addMask(value))
171
167
  };
172
168
  });
173
- const unimplemented = (member) => {
174
- throw new Error(`ActionOutputs.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
175
- };
169
+ const dies = unstubbed("ActionOutputs.makeTest");
176
170
  /**
177
171
  * Everything an action publishes: step outputs, exported variables, `PATH`
178
172
  * additions, the job summary, log masking and failure annotations.
@@ -230,19 +224,19 @@ var ActionOutputs = class ActionOutputs extends Context.Service()("@effected/git
230
224
  */
231
225
  static layerDetached = Layer.succeed(this, {
232
226
  set: (name) => Effect.fail(new DetachedOutputError({
233
- file: "GITHUB_OUTPUT",
227
+ file: RUNNER_FILE.set,
234
228
  name
235
229
  })),
236
230
  setJson: (name) => Effect.fail(new DetachedOutputError({
237
- file: "GITHUB_OUTPUT",
231
+ file: RUNNER_FILE.setJson,
238
232
  name
239
233
  })),
240
- summary: () => Effect.fail(new DetachedOutputError({ file: "GITHUB_STEP_SUMMARY" })),
234
+ summary: () => Effect.fail(new DetachedOutputError({ file: RUNNER_FILE.summary })),
241
235
  exportVariable: (name) => Effect.fail(new DetachedOutputError({
242
- file: "GITHUB_ENV",
236
+ file: RUNNER_FILE.exportVariable,
243
237
  name
244
238
  })),
245
- addPath: () => Effect.fail(new DetachedOutputError({ file: "GITHUB_PATH" })),
239
+ addPath: () => Effect.fail(new DetachedOutputError({ file: RUNNER_FILE.addPath })),
246
240
  setFailed: (message) => Console.error(message),
247
241
  setSecret: () => Effect.void
248
242
  });
@@ -262,14 +256,14 @@ var ActionOutputs = class ActionOutputs extends Context.Service()("@effected/git
262
256
  * {@link ActionOutputs.recording} instead.
263
257
  */
264
258
  static makeTest = (overrides = {}) => {
265
- const setJson = overrides.setJson ?? (() => Effect.sync(() => unimplemented("setJson")));
259
+ const setJson = overrides.setJson ?? (() => dies("setJson"));
266
260
  return {
267
- set: () => Effect.sync(() => unimplemented("set")),
268
- summary: () => Effect.sync(() => unimplemented("summary")),
269
- exportVariable: () => Effect.sync(() => unimplemented("exportVariable")),
270
- addPath: () => Effect.sync(() => unimplemented("addPath")),
271
- setFailed: () => Effect.sync(() => unimplemented("setFailed")),
272
- setSecret: () => Effect.sync(() => unimplemented("setSecret")),
261
+ set: () => dies("set"),
262
+ summary: () => dies("summary"),
263
+ exportVariable: () => dies("exportVariable"),
264
+ addPath: () => dies("addPath"),
265
+ setFailed: () => dies("setFailed"),
266
+ setSecret: () => dies("setSecret"),
273
267
  ...overrides,
274
268
  setJson: (name, value, schema) => encodeJson(name, value, schema).pipe(Effect.flatMap(() => setJson(name, value, schema)))
275
269
  };
@@ -309,19 +303,12 @@ var ActionOutputs = class ActionOutputs extends Context.Service()("@effected/git
309
303
  ...name === void 0 ? {} : { name }
310
304
  }));
311
305
  });
312
- const recordNamed = (member, file, name, value) => isUsableName(name) ? record(member, value, name) : Effect.fail(new InvalidOutputNameError({
313
- file,
314
- name
315
- }));
316
306
  return {
317
307
  layer: Layer.succeed(ActionOutputs, {
318
- set: (name, value) => recordNamed("set", "GITHUB_OUTPUT", name, value),
319
- setJson: (name, value, schema) => isUsableName(name) ? encodeJson(name, value, schema).pipe(Effect.flatMap((json) => record("setJson", json, name))) : Effect.fail(new InvalidOutputNameError({
320
- file: "GITHUB_OUTPUT",
321
- name
322
- })),
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)))),
323
310
  summary: (content) => record("summary", content),
324
- exportVariable: (name, value) => recordNamed("exportVariable", "GITHUB_ENV", name, value),
311
+ exportVariable: (name, value) => withUsableName(RUNNER_FILE.exportVariable, name, record("exportVariable", value, name)),
325
312
  addPath: (path) => record("addPath", path),
326
313
  setFailed: (message) => record("setFailed", message),
327
314
  setSecret: (value) => record("setSecret", value)