@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.
@@ -1,9 +1,12 @@
1
1
  import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { unstubbed } from "./internal/unstubbed.js";
3
+ import { digestFileHex } from "./internal/digest.js";
4
+ import { isWindowsRunner, toolCacheRoot } from "./internal/runner.js";
5
+ import { typeAt } from "./internal/fsProbe.js";
2
6
  import { ToolInstaller } from "./ToolInstaller.js";
3
- import { Context, Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
4
- import { createHash } from "node:crypto";
7
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
5
8
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
6
- import { PackageManagerPinName } from "@effected/npm";
9
+ import { DEFAULT_REGISTRY, PackageManagerPinName } from "@effected/npm";
7
10
 
8
11
  //#region src/PackageManagerInstaller.ts
9
12
  /**
@@ -130,7 +133,6 @@ var CachedPackageManager = class extends Schema.Class("CachedPackageManager")({
130
133
  * @public
131
134
  */
132
135
  const InstalledPackageManager = Schema.Union([AmbientPackageManager, CachedPackageManager]);
133
- const DEFAULT_REGISTRY = "https://registry.npmjs.org";
134
136
  /** The shim directory name inside a cached npm-registry-manager entry. */
135
137
  const SHIM_DIR = ".bin";
136
138
  /**
@@ -149,9 +151,18 @@ const cmdShim = (target) => `@echo off\r\nnode "${target}" %*\r\n`;
149
151
  * bun's spelling (`macOS` → `darwin`) and the Node arch spelling to bun's
150
152
  * (`arm64` → `aarch64`).
151
153
  */
154
+ const BUN_OS = {
155
+ linux: "linux",
156
+ macos: "darwin",
157
+ windows: "windows"
158
+ };
159
+ const BUN_CPU = {
160
+ x64: "x64",
161
+ arm64: "aarch64"
162
+ };
152
163
  const bunTarget = (runnerOs, arch) => {
153
- const os = runnerOs.toLowerCase() === "linux" ? "linux" : runnerOs.toLowerCase() === "macos" ? "darwin" : runnerOs.toLowerCase() === "windows" ? "windows" : void 0;
154
- const cpu = arch === "x64" ? "x64" : arch === "arm64" ? "aarch64" : void 0;
164
+ const os = BUN_OS[runnerOs.toLowerCase()];
165
+ const cpu = BUN_CPU[arch];
155
166
  return os === void 0 || cpu === void 0 ? Option.none() : Option.some(`bun-${os}-${cpu}`);
156
167
  };
157
168
  /**
@@ -174,7 +185,10 @@ const normalizeBins = (bin, fallbackName) => {
174
185
  return Object.keys(entries).length === 0 ? Option.none() : Option.some(entries);
175
186
  };
176
187
  /** Map a `RUNNER_ARCH` value (`X64`, `ARM64`) onto the Node arch spelling. */
177
- const archFromRunner = (runnerArch) => runnerArch.toLowerCase() === "x64" ? "x64" : runnerArch.toLowerCase() === "arm64" ? "arm64" : runnerArch;
188
+ const archFromRunner = (runnerArch) => {
189
+ const lowered = runnerArch.toLowerCase();
190
+ return lowered === "x64" || lowered === "arm64" ? lowered : runnerArch;
191
+ };
178
192
  const make = Effect.gen(function* () {
179
193
  const env = yield* ActionEnvironment;
180
194
  const fs = yield* FileSystem.FileSystem;
@@ -186,11 +200,11 @@ const make = Effect.gen(function* () {
186
200
  onNone: () => process.arch,
187
201
  onSome: archFromRunner
188
202
  }));
189
- const windows = runnerOs.toLowerCase() === "windows";
203
+ const windows = yield* isWindowsRunner(env);
190
204
  const bunBinaryName = windows ? "bun.exe" : "bun";
191
205
  const shimFileName = (name) => windows ? `${name}.cmd` : name;
192
206
  const shimBody = windows ? cmdShim : posixShim;
193
- const cacheRoot = yield* Effect.map(env.getOptional("RUNNER_TOOL_CACHE"), (found) => Option.getOrElse(found, () => path.join("/tmp", "runner-tool-cache")));
207
+ const cacheRoot = yield* toolCacheRoot(env, path);
194
208
  const finalCachePath = (tool, version) => ToolInstaller.cachePath({
195
209
  root: cacheRoot,
196
210
  tool,
@@ -209,16 +223,11 @@ const make = Effect.gen(function* () {
209
223
  cause
210
224
  });
211
225
  /** The hex digest of a file's bytes, streamed rather than buffered. */
212
- const hashFile = (pin, file, algorithm) => Effect.suspend(() => {
213
- const digest = createHash(algorithm);
214
- return fs.stream(file).pipe(Stream.runForEach((chunk) => Effect.sync(() => {
215
- digest.update(chunk);
216
- })), Effect.map(() => digest.digest("hex")), Effect.mapError((cause) => errorFor(pin)({
217
- reason: "integrityMismatch",
218
- subject: file,
219
- cause
220
- })));
221
- });
226
+ const hashFile = (pin, file, algorithm) => digestFileHex(fs, file, algorithm).pipe(Effect.mapError((cause) => errorFor(pin)({
227
+ reason: "integrityMismatch",
228
+ subject: file,
229
+ cause
230
+ })));
222
231
  /**
223
232
  * Verify a downloaded artifact against the pin's integrity, fail-closed.
224
233
  *
@@ -250,8 +259,7 @@ const make = Effect.gen(function* () {
250
259
  */
251
260
  const ambientNpmVersion = Effect.map(Effect.result(spawner.string(ChildProcess.make("npm", ["--version"]))), (result) => result._tag === "Success" ? Option.some(result.success.trim()) : Option.none());
252
261
  const assertFile = (pin, file, subject) => Effect.gen(function* () {
253
- const stat = yield* Effect.result(fs.stat(file));
254
- if (stat._tag !== "Success" || stat.success.type !== "File") return yield* Effect.fail(errorFor(pin)({
262
+ if ((yield* typeAt(fs, file)) !== "File") return yield* Effect.fail(errorFor(pin)({
255
263
  reason: "layoutUnexpected",
256
264
  subject
257
265
  }));
@@ -319,8 +327,7 @@ const make = Effect.gen(function* () {
319
327
  for (const [name, relative] of Object.entries(bins)) {
320
328
  const shim = path.join(shimDir, shimFileName(name));
321
329
  if (options.skipExisting) {
322
- const present = yield* Effect.result(fs.stat(shim));
323
- if (present._tag === "Success" && present.success.type === "File") continue;
330
+ if ((yield* typeAt(fs, shim)) === "File") continue;
324
331
  }
325
332
  yield* fs.writeFileString(shim, shimBody(path.join(finalDirectory, relative))).pipe(Effect.mapError(cacheError(shim)));
326
333
  if (!windows) yield* fs.chmod(shim, 493).pipe(Effect.mapError(cacheError(shim)));
@@ -386,8 +393,7 @@ const make = Effect.gen(function* () {
386
393
  if (!berry) yield* verifyIntegrity(pin, archive);
387
394
  const extracted = yield* installer.extractTar(archive).pipe(Effect.mapError(fromInstaller(pin)));
388
395
  const packageDir = path.join(extracted, "package");
389
- const stat = yield* Effect.result(fs.stat(packageDir));
390
- if (stat._tag !== "Success" || stat.success.type !== "Directory") return yield* Effect.fail(errorFor(pin)({
396
+ if ((yield* typeAt(fs, packageDir)) !== "Directory") return yield* Effect.fail(errorFor(pin)({
391
397
  reason: "layoutUnexpected",
392
398
  subject: "tarball has no package/ root"
393
399
  }));
@@ -437,9 +443,7 @@ const make = Effect.gen(function* () {
437
443
  return yield* installFromRegistry(pin, pin.name, registry);
438
444
  }) };
439
445
  });
440
- const unimplemented = (member) => {
441
- throw new Error(`PackageManagerInstaller.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
442
- };
446
+ const dies = unstubbed("PackageManagerInstaller.makeTest");
443
447
  /**
444
448
  * First-class exact-version package-manager provisioning on a GitHub runner.
445
449
  *
@@ -483,7 +487,7 @@ var PackageManagerInstaller = class PackageManagerInstaller extends Context.Serv
483
487
  static layer = Layer.effect(this, make);
484
488
  /** A test double. The unstubbed member dies rather than reporting an install that did not happen. */
485
489
  static makeTest = (overrides = {}) => ({
486
- install: () => Effect.sync(() => unimplemented("install")),
490
+ install: () => dies("install"),
487
491
  ...overrides
488
492
  });
489
493
  /** {@link PackageManagerInstaller.makeTest} behind `Layer.succeed`. */
package/ToolInstaller.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { unstubbed } from "./internal/unstubbed.js";
3
+ import { isWindowsRunner, toolCacheRoot } from "./internal/runner.js";
4
+ import { spawnOnce } from "./internal/spawn.js";
5
+ import { typeAt } from "./internal/fsProbe.js";
2
6
  import { Context, Effect, FileSystem, Layer, Option, Path, Schedule, Schema, Stream } from "effect";
3
7
  import { HttpClient } from "effect/unstable/http";
4
8
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
@@ -61,11 +65,8 @@ const make = Effect.gen(function* () {
61
65
  const path = yield* Path.Path;
62
66
  const http = yield* HttpClient.HttpClient;
63
67
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
64
- const root = yield* Effect.map(env.getOptional("RUNNER_TOOL_CACHE"), (found) => Option.getOrElse(found, () => path.join("/tmp", "runner-tool-cache")));
65
- const windows = yield* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.match(found, {
66
- onNone: () => false,
67
- onSome: (os) => os.toLowerCase() === "windows"
68
- }));
68
+ const root = yield* toolCacheRoot(env, path);
69
+ const windows = yield* isWindowsRunner(env);
69
70
  const cachePath = (tool, version) => ToolInstaller.cachePath({
70
71
  root,
71
72
  tool,
@@ -93,30 +94,22 @@ const make = Effect.gen(function* () {
93
94
  * the difference between "this is not a gzip archive" and "tar is not
94
95
  * installed". Discarding it is what makes an extraction failure unactionable.
95
96
  *
96
- * **One spawn, not two — load-bearing.** The spawner's convenience members
97
- * each spawn independently: `string` collects output without inspecting the
98
- * exit code, and `exitCode` runs the command AGAIN (core's
99
- * `ChildProcessSpawner.make` derives both from `spawn`). Calling them
100
- * back-to-back double-executed every extraction — harmless for idempotent
101
- * `tar`/`unzip -o`, but .NET's `ZipFile.ExtractToDirectory` refuses to
102
- * overwrite, so the second run failed 5/5 on real Windows runners while the
103
- * captured "complaint" was the FIRST run's silent success. Output and exit
104
- * code must come from the same `spawn` handle.
97
+ * **One spawn, not two — load-bearing**, and why is spelled once in
98
+ * `internal/spawn.ts`: the double run failed 5/5 on real Windows runners
99
+ * because .NET's `ZipFile.ExtractToDirectory` refuses to overwrite.
105
100
  */
106
- const extractWith = (command, archive) => Effect.scoped(Effect.gen(function* () {
107
- const extractError = (cause) => new ToolInstallerError({
101
+ const extractWith = (command, archive) => Effect.gen(function* () {
102
+ const { output, code } = yield* spawnOnce(spawner, command).pipe(Effect.mapError((cause) => new ToolInstallerError({
108
103
  reason: "extractFailed",
109
104
  subject: archive,
110
105
  cause
111
- });
112
- const handle = yield* spawner.spawn(command).pipe(Effect.mapError(extractError));
113
- const output = yield* Stream.mkString(Stream.decodeText(handle.all)).pipe(Effect.mapError(extractError));
114
- if ((yield* handle.exitCode.pipe(Effect.mapError(extractError))) !== 0) return yield* Effect.fail(new ToolInstallerError({
106
+ })));
107
+ if (code !== 0) return yield* Effect.fail(new ToolInstallerError({
115
108
  reason: "extractFailed",
116
109
  subject: archive,
117
110
  stderr: output.trim()
118
111
  }));
119
- }));
112
+ });
120
113
  /**
121
114
  * Install a staged directory into the tool cache by renaming it into place.
122
115
  *
@@ -191,7 +184,7 @@ const make = Effect.gen(function* () {
191
184
  while: (error) => error.retryable
192
185
  }));
193
186
  });
194
- const find = (tool, version) => Effect.map(Effect.result(fs.stat(cachePath(tool, version))), (result) => result._tag === "Success" && result.success.type === "Directory" ? Option.some(cachePath(tool, version)) : Option.none());
187
+ const find = (tool, version) => Effect.map(typeAt(fs, cachePath(tool, version)), (type) => type === "Directory" ? Option.some(cachePath(tool, version)) : Option.none());
195
188
  const cacheFile = Effect.fn("ToolInstaller.cacheFile")(function* (source, name, tool, version) {
196
189
  yield* Effect.annotateCurrentSpan({
197
190
  tool,
@@ -252,8 +245,7 @@ const make = Effect.gen(function* () {
252
245
  });
253
246
  const cached = yield* find(options.tool, options.version);
254
247
  if (Option.isSome(cached)) {
255
- const present = yield* Effect.result(fs.stat(path.join(cached.value, options.binary)));
256
- if (present._tag === "Success" && present.success.type === "File") return {
248
+ if ((yield* typeAt(fs, path.join(cached.value, options.binary))) === "File") return {
257
249
  directory: cached.value,
258
250
  binDir: cached.value
259
251
  };
@@ -272,9 +264,7 @@ const make = Effect.gen(function* () {
272
264
  })
273
265
  };
274
266
  });
275
- const unimplemented = (member) => {
276
- throw new Error(`ToolInstaller.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
277
- };
267
+ const dies = unstubbed("ToolInstaller.makeTest");
278
268
  /**
279
269
  * Download, extract and cache a toolchain in the runner's tool cache.
280
270
  *
@@ -323,13 +313,13 @@ var ToolInstaller = class ToolInstaller extends Context.Service()("@effected/git
323
313
  static cachePath = (options) => `${options.root}/${options.tool}/${options.version}/${options.arch}`;
324
314
  /** A test double. Unstubbed members die rather than reporting a tool that is not there. */
325
315
  static makeTest = (overrides = {}) => ({
326
- find: () => Effect.sync(() => unimplemented("find")),
327
- download: () => Effect.sync(() => unimplemented("download")),
328
- extractTar: () => Effect.sync(() => unimplemented("extractTar")),
329
- extractZip: () => Effect.sync(() => unimplemented("extractZip")),
330
- cacheDir: () => Effect.sync(() => unimplemented("cacheDir")),
331
- cacheFile: () => Effect.sync(() => unimplemented("cacheFile")),
332
- provisionFile: () => Effect.sync(() => unimplemented("provisionFile")),
316
+ find: () => dies("find"),
317
+ download: () => dies("download"),
318
+ extractTar: () => dies("extractTar"),
319
+ extractZip: () => dies("extractZip"),
320
+ cacheDir: () => dies("cacheDir"),
321
+ cacheFile: () => dies("cacheFile"),
322
+ provisionFile: () => dies("provisionFile"),
333
323
  ...overrides
334
324
  });
335
325
  /** {@link ToolInstaller.makeTest} behind `Layer.succeed`. */
package/index.d.ts CHANGED
@@ -586,6 +586,46 @@ export declare class DetachedOutputError extends DetachedOutputError_base {
586
586
  * @public
587
587
  */
588
588
  type ActionOutputError = RunnerFileUnavailableError | RunnerFileWriteError | InvalidOutputNameError | OutputEncodeError | DetachedOutputError;
589
+ declare const RecordedOutput_base: Schema.Class<RecordedOutput, Schema.Struct<{
590
+ /** Which {@link ActionOutputsShape} member was called. */
591
+ readonly member: Schema.Literals<readonly ["set", "setJson", "summary", "exportVariable", "addPath", "setFailed", "setSecret"]>;
592
+ /** The output or variable name, for the members that take one. */
593
+ readonly name: Schema.optionalKey<Schema.String>;
594
+ /** What was published — value, message, path, content or secret — as the runner would have read it. */
595
+ readonly value: Schema.String;
596
+ }>, {}>;
597
+ /**
598
+ * One call an {@link ActionOutputs.recording} double observed.
599
+ *
600
+ * @remarks
601
+ * `value` is always the string the runner would have seen: for `setJson` that
602
+ * is the **encoded** JSON text, after the same schema encode the real layer
603
+ * performs, so a test asserting on it reads exactly what a later step's
604
+ * `steps.<id>.outputs.<name>` expression would. `name` is present for the
605
+ * members that take one (`set`, `setJson`, `exportVariable`) and absent
606
+ * otherwise.
607
+ *
608
+ * @public
609
+ */
610
+ export declare class RecordedOutput extends RecordedOutput_base {}
611
+ /**
612
+ * The member names a {@link RecordedOutput} can carry.
613
+ *
614
+ * @public
615
+ */
616
+ type RecordedOutputMember = RecordedOutput["member"];
617
+ /**
618
+ * What {@link ActionOutputs.recording} returns: the layer to provide and the
619
+ * journal it fills.
620
+ *
621
+ * @public
622
+ */
623
+ interface RecordingOutputs {
624
+ /** An {@link ActionOutputs} whose every member records into this journal. */
625
+ readonly layer: Layer.Layer<ActionOutputs>;
626
+ /** A snapshot of every recorded call so far, in call order. */
627
+ readonly entries: () => ReadonlyArray<RecordedOutput>;
628
+ }
589
629
  /**
590
630
  * The {@link ActionOutputs} service shape.
591
631
  *
@@ -674,10 +714,49 @@ export declare class ActionOutputs extends ActionOutputs_base {
674
714
  * channel to fail through.
675
715
  */
676
716
  static readonly layerDetached: Layer.Layer<ActionOutputs>;
677
- /** A test double. Unstubbed members die rather than silently succeeding. */
717
+ /**
718
+ * A test double. Unstubbed members die rather than silently succeeding.
719
+ *
720
+ * @remarks
721
+ * **`setJson` always encodes first.** The value is encoded through its
722
+ * schema exactly as the real layer does — failing typed with
723
+ * {@link OutputEncodeError} — and only then is a supplied `setJson`
724
+ * override called, with the original `(name, value, schema)`. An override
725
+ * that accepts `schema` and ignores it therefore **cannot disable
726
+ * output-schema checking**: a value/schema drift is a typed failure under
727
+ * this double whether or not the override looks at the schema. Without an
728
+ * override, a valid value still dies unimplemented as every other unstubbed
729
+ * member does. Most tests want neither: reach for
730
+ * {@link ActionOutputs.recording} instead.
731
+ */
678
732
  static readonly makeTest: (overrides?: Partial<ActionOutputsShape>) => ActionOutputsShape;
679
733
  /** {@link ActionOutputs.makeTest} behind `Layer.succeed`. */
680
734
  static readonly layerTest: (overrides?: Partial<ActionOutputsShape>) => Layer.Layer<ActionOutputs>;
735
+ /**
736
+ * A recording double: every member appends a {@link RecordedOutput} to a
737
+ * journal the test reads back, in call order.
738
+ *
739
+ * @remarks
740
+ * This is the double most tests of an action want — "what did it publish?"
741
+ * — shipped so that nobody hand-writes a `setJson` override that drops the
742
+ * schema encode (the trap `makeTest` now closes too). `setJson` encodes
743
+ * through its schema exactly as the real layer does, failing typed with
744
+ * {@link OutputEncodeError} and recording nothing on a drift, and records
745
+ * the **encoded JSON text** — what the runner would have read — not the
746
+ * decoded value. `set`, `exportVariable` and `setJson` refuse an unusable
747
+ * name with {@link InvalidOutputNameError}, as the real layer does, rather
748
+ * than recording it.
749
+ *
750
+ * `setSecret` records the secret's **plaintext** under
751
+ * `member: "setSecret"`. That is deliberate for a recording double: the
752
+ * journal never leaves the test, and a test asserting that a value *was*
753
+ * masked needs to see which one. Do not hand this journal to anything that
754
+ * logs.
755
+ *
756
+ * Each call returns a fresh, independent journal — there is no state shared
757
+ * between two `recording()` calls.
758
+ */
759
+ static readonly recording: () => RecordingOutputs;
681
760
  }
682
761
  //#endregion
683
762
  //#region src/ActionState.d.ts
@@ -1228,20 +1307,30 @@ export declare class CacheKey extends CacheKey_base {
1228
1307
  *
1229
1308
  * @remarks
1230
1309
  * Discovery and matching are **two different jobs**, and separating them is
1231
- * what makes this testable and correct. The walk is core
1232
- * `FileSystem.readDirectory(recursive)`; the matching is `@effected/glob`'s
1233
- * full minimatch dialect — the same dialect `@actions/glob` uses, so a workflow
1234
- * author's `!**\/node_modules/**` behaves here exactly as it does in every
1235
- * other cache step in the same workflow. `node:fs.globSync` would have welded
1236
- * the two together behind one non-stubbable call *and* changed the dialect,
1237
- * and a dialect divergence surfaces as a silent cache-key difference — the
1238
- * worst failure mode a cache key has.
1239
- *
1240
- * Candidates are matched by their path **relative to the workspace**, which
1241
- * is what makes "never hash a file outside the workspace" structural rather
1242
- * than a rule someone has to remember. Directories are excluded: a directory
1310
+ * what makes this testable and correct. The walk is `@effected/walker`'s
1311
+ * `descend`, run once per include from that include's own literal prefix
1312
+ * (a literal include is one `stat`, never a walk) with nothing pruned
1313
+ * implicitly; the matching is `@effected/glob`'s full minimatch dialect —
1314
+ * the same dialect `@actions/glob` uses, so a workflow author's
1315
+ * `!**\/node_modules/**` behaves here exactly as it does in every other
1316
+ * cache step in the same workflow. `node:fs.globSync` would have welded the
1317
+ * two together behind one non-stubbable call *and* changed the dialect, and
1318
+ * a dialect divergence surfaces as a silent cache-key difference — the worst
1319
+ * failure mode a cache key has.
1320
+ *
1321
+ * Candidates are matched by their path **relative to the workspace**, and a
1322
+ * literal that climbs above it (`../lockfile`) is dropped, which is what
1323
+ * makes "never hash a file outside the workspace" structural rather than a
1324
+ * rule someone has to remember. Directories are excluded: a directory
1243
1325
  * called `notes.txt` matches `**\/*.txt` and is not a file, and hashing it
1244
- * would fail rather than being ignored.
1326
+ * would fail rather than being ignored. An absent literal is a miss; any
1327
+ * other failure to read one is a typed `CacheKeyReadError`, because a key
1328
+ * derived from an incomplete file set is wrong in a way nothing reports.
1329
+ *
1330
+ * **One knowing divergence from the runner's `hashFiles()`:** `descend`
1331
+ * never enters a symlinked directory (cycle safety), where `@actions/glob`
1332
+ * follows links by default. A file reachable only through a symlinked
1333
+ * directory does not contribute to the key.
1245
1334
  *
1246
1335
  * The answer is sorted, so a caller cannot make its key depend on the order
1247
1336
  * the filesystem happened to report.
@@ -3349,7 +3438,7 @@ export declare class DetachedProcess {
3349
3438
  * yet, and silently retrying the first is how a misconfigured probe becomes a
3350
3439
  * timeout six seconds later with nothing to show for it.
3351
3440
  */
3352
- static readonly awaitReady: <E, R>(probe: Effect.Effect<boolean, E, R>, options?: ReadinessOptions | undefined) => Effect.Effect<void, E | DetachedProcessError, R>;
3441
+ static readonly awaitReady: <E, R>(probe: Effect.Effect<boolean, E, R>, options?: ReadinessOptions | undefined) => Effect.Effect<undefined, E | DetachedNotReadyError, R>;
3353
3442
  /**
3354
3443
  * A readiness probe over HTTP: `true` when a `GET` answers 2xx.
3355
3444
  *
@@ -3471,15 +3560,6 @@ declare const DryRun_base: Context.ServiceClass<DryRun, "@effected/github-action
3471
3560
  * @public
3472
3561
  */
3473
3562
  export declare class DryRun extends DryRun_base {
3474
- /**
3475
- * Driven by the `dry-run` action input, defaulting to a real run.
3476
- *
3477
- * @remarks
3478
- * Fails with a `ConfigError` when the input is present but is not a YAML 1.2
3479
- * core-schema boolean — a workflow that writes `dry-run: yes` should stop,
3480
- * not quietly perform the mutations it meant to rehearse.
3481
- */
3482
- static readonly layer: Layer.Layer<DryRun, Config.ConfigError>;
3483
3563
  /**
3484
3564
  * Driven by a named input.
3485
3565
  *
@@ -3489,6 +3569,15 @@ export declare class DryRun extends DryRun_base {
3489
3569
  * composition site.
3490
3570
  */
3491
3571
  static readonly layerFromInput: (name: string) => Layer.Layer<DryRun, Config.ConfigError>;
3572
+ /**
3573
+ * Driven by the `dry-run` action input, defaulting to a real run.
3574
+ *
3575
+ * @remarks
3576
+ * Fails with a `ConfigError` when the input is present but is not a YAML 1.2
3577
+ * core-schema boolean — a workflow that writes `dry-run: yes` should stop,
3578
+ * not quietly perform the mutations it meant to rehearse.
3579
+ */
3580
+ static readonly layer: Layer.Layer<DryRun, Config.ConfigError>;
3492
3581
  /** Driven by an explicit decision the caller has already made. */
3493
3582
  static readonly layerFrom: (enabled: boolean) => Layer.Layer<DryRun>;
3494
3583
  /**
@@ -4051,6 +4140,7 @@ declare const ManagedDocument_base: Schema.Class<ManagedDocument, Schema.Struct<
4051
4140
  * @public
4052
4141
  */
4053
4142
  export declare class ManagedDocument extends ManagedDocument_base {
4143
+ #private;
4054
4144
  /**
4055
4145
  * Read a document out of existing text, or begin a fresh one.
4056
4146
  *
@@ -4137,6 +4227,9 @@ export declare class ManagedDocument extends ManagedDocument_base {
4137
4227
  * synchronous callers can use that variant directly.
4138
4228
  */
4139
4229
  withRegions(entries: ReadonlyArray<readonly [key: string, content: string, meta?: Readonly<Record<string, string>>]>): Effect.Effect<ManagedDocument, ManagedDocumentError>;
4230
+ private scan;
4231
+ /** What every wire key of this document's regions begins with. */
4232
+ private get wirePrefix();
4140
4233
  /** The marker key a region is written under: `namespace.key.region`. */
4141
4234
  private wireKey;
4142
4235
  /** The region key back out of a wire key, for error reporting. */
@@ -4791,5 +4884,5 @@ export declare class Secret {
4791
4884
  static readonly adopt: (name: string) => Config.Config<Redacted.Redacted<string>>;
4792
4885
  }
4793
4886
  //#endregion
4794
- export type { ActionCacheShape, ActionEnvironmentShape, ActionLoggerShape, ActionOutputError, ActionOutputsShape, ActionRunOptions, ActionServices, ActionStateShape, AnnotationProperties, ArtifactItem, ArtifactRef, ArtifactShape, BlobEnvelopeError, BlobStoreShape, CacheKeyError, CheckDocumentOptions, CheckDocumentShape, CheckDocumentSink, CheckFlushOutcome, CheckRunConclusion, CheckRunProjection, ClientLayerOptions, DataBlobTransfer, DetachedProcessError, DetachedProcessOps, DetachedSpawnOptions, DownloadOptions, DownloadResult, DryRunShape, ExtractOptions, FileBlobTransfer, GitHubHeadingDepth, GitHubListOptions, GitHubRowSchema, GitHubSchemaTable, GitHubSchemaTableColumn, GitHubSchemaTableColumns, GitHubSchemaTableFormatRequiredKeys, GitHubSchemaTableFormattedColumn, GitHubSchemaTableOptions, ManagedDocumentSource, OidcTokenIssuerShape, PackageManagerInstallOptions, PackageManagerInstallerShape, PairsOptions, PathPrependEnv, PathPrependOptions, ProvisionFileOptions, ProvisionOptions, ProvisionedFile, ReadOptions, ReadinessOptions, S3Config, StoredBlob, ToolDownloadOptions, ToolInstallerShape, UploadOptions, UploadResult, WithBufferOptions, WithStepOptions };
4887
+ export type { ActionCacheShape, ActionEnvironmentShape, ActionLoggerShape, ActionOutputError, ActionOutputsShape, ActionRunOptions, ActionServices, ActionStateShape, AnnotationProperties, ArtifactItem, ArtifactRef, ArtifactShape, BlobEnvelopeError, BlobStoreShape, CacheKeyError, CheckDocumentOptions, CheckDocumentShape, CheckDocumentSink, CheckFlushOutcome, CheckRunConclusion, CheckRunProjection, ClientLayerOptions, DataBlobTransfer, DetachedProcessError, DetachedProcessOps, DetachedSpawnOptions, DownloadOptions, DownloadResult, DryRunShape, ExtractOptions, FileBlobTransfer, GitHubHeadingDepth, GitHubListOptions, GitHubRowSchema, GitHubSchemaTable, GitHubSchemaTableColumn, GitHubSchemaTableColumns, GitHubSchemaTableFormatRequiredKeys, GitHubSchemaTableFormattedColumn, GitHubSchemaTableOptions, ManagedDocumentSource, OidcTokenIssuerShape, PackageManagerInstallOptions, PackageManagerInstallerShape, PairsOptions, PathPrependEnv, PathPrependOptions, ProvisionFileOptions, ProvisionOptions, ProvisionedFile, ReadOptions, ReadinessOptions, RecordedOutputMember, RecordingOutputs, S3Config, StoredBlob, ToolDownloadOptions, ToolInstallerShape, UploadOptions, UploadResult, WithBufferOptions, WithStepOptions };
4795
4888
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -2,7 +2,7 @@ import { ActionEnvironment, ActionEnvironmentError, GitHubContext, RunnerContext
2
2
  import { ActionInput } from "./ActionInput.js";
3
3
  import { WorkflowCommand } from "./WorkflowCommand.js";
4
4
  import { ActionLogger } from "./ActionLogger.js";
5
- import { ActionOutputs, DetachedOutputError, InvalidOutputNameError, OutputEncodeError, RunnerFileUnavailableError, RunnerFileWriteError } from "./ActionOutputs.js";
5
+ import { ActionOutputs, DetachedOutputError, InvalidOutputNameError, OutputEncodeError, RecordedOutput, RunnerFileUnavailableError, RunnerFileWriteError } from "./ActionOutputs.js";
6
6
  import { ActionState, ActionStateError } from "./ActionState.js";
7
7
  import { Action, ActionRuntime, describeCause } from "./Action.js";
8
8
  import { BlobTransferError } from "./BlobTransfer.js";
@@ -27,4 +27,4 @@ import { GitHubToken, GitHubTokenError } from "./GitHubToken.js";
27
27
  import { ToolInstaller, ToolInstallerError } from "./ToolInstaller.js";
28
28
  import { AmbientPackageManager, CachedPackageManager, InstalledPackageManager, PackageManagerInstaller, PackageManagerInstallerError } from "./PackageManagerInstaller.js";
29
29
 
30
- export { Action, ActionCache, ActionCacheError, ActionEnvironment, ActionEnvironmentError, ActionInput, ActionLogger, ActionOutputs, ActionRuntime, ActionState, ActionStateError, ActionsIdentityToken, ActionsProvenance, AmbientPackageManager, Artifact, ArtifactError, BlobEnvelope, BlobMetadataDecodeError, BlobMetadataEncodeError, BlobStore, BlobStoreError, BlobTransferError, CacheKey, CacheKeyBadPatternError, CacheKeyReadError, CachedPackageManager, CheckDocument, CheckDocumentError, CheckDocumentStamp, CheckReport, CheckState, ChildEnv, DetachedLogUnavailableError, DetachedNotReadyError, DetachedOutputError, DetachedProcess, DetachedSignalFailedError, DetachedSpawnFailedError, DryRun, GitHubCacheBlobStore, GitHubContext, GitHubMarkdown, GitHubToken, GitHubTokenError, InstalledPackageManager, InvalidOutputNameError, InvalidPidError, ManagedDocument, ManagedDocumentError, NotABlobEnvelopeError, OidcClaims, OidcTokenError, OidcTokenIssuer, OutputEncodeError, PackageManagerInstaller, PackageManagerInstallerError, ProcessId, RunnerContext, RunnerFileUnavailableError, RunnerFileWriteError, Secret, ToolInstaller, ToolInstallerError, TruncatedBlobEnvelopeError, UnsupportedBlobEnvelopeVersionError, WorkflowCommand, describeCause, projectCheckState };
30
+ export { Action, ActionCache, ActionCacheError, ActionEnvironment, ActionEnvironmentError, ActionInput, ActionLogger, ActionOutputs, ActionRuntime, ActionState, ActionStateError, ActionsIdentityToken, ActionsProvenance, AmbientPackageManager, Artifact, ArtifactError, BlobEnvelope, BlobMetadataDecodeError, BlobMetadataEncodeError, BlobStore, BlobStoreError, BlobTransferError, CacheKey, CacheKeyBadPatternError, CacheKeyReadError, CachedPackageManager, CheckDocument, CheckDocumentError, CheckDocumentStamp, CheckReport, CheckState, ChildEnv, DetachedLogUnavailableError, DetachedNotReadyError, DetachedOutputError, DetachedProcess, DetachedSignalFailedError, DetachedSpawnFailedError, DryRun, GitHubCacheBlobStore, GitHubContext, GitHubMarkdown, GitHubToken, GitHubTokenError, InstalledPackageManager, InvalidOutputNameError, InvalidPidError, ManagedDocument, ManagedDocumentError, NotABlobEnvelopeError, OidcClaims, OidcTokenError, OidcTokenIssuer, OutputEncodeError, PackageManagerInstaller, PackageManagerInstallerError, ProcessId, RecordedOutput, RunnerContext, RunnerFileUnavailableError, RunnerFileWriteError, Secret, ToolInstaller, ToolInstallerError, TruncatedBlobEnvelopeError, UnsupportedBlobEnvelopeVersionError, WorkflowCommand, describeCause, projectCheckState };
@@ -1,3 +1,4 @@
1
+ import { payloadOf } from "./jwt.js";
1
2
  import { Effect, Option, Redacted, Result } from "effect";
2
3
 
3
4
  //#region src/internal/actionsResults.ts
@@ -18,20 +19,13 @@ const RESULTS_URL = "ACTIONS_RESULTS_URL";
18
19
  /** The variable holding the run-scoped credential. Absent outside a `uses:` step. */
19
20
  const RUNTIME_TOKEN = "ACTIONS_RUNTIME_TOKEN";
20
21
  /**
21
- * Decode a JWT payload segment.
22
+ * The `misconfigured` detail every results-backend consumer reports when
23
+ * {@link resultsBackend} fails: names the missing variable, and says why it is
24
+ * missing — the single most common misuse of these services.
22
25
  *
23
- * @remarks
24
- * No signature verification, deliberately and for the same reason
25
- * `OidcTokenIssuer` gives: the token was handed to this process by the runner
26
- * that started it, and the claim read here scopes a request rather than
27
- * authorizing one.
26
+ * @internal
28
27
  */
29
- const payloadOf = (token) => {
30
- const segments = token.split(".");
31
- if (segments.length !== 3) throw new Error(`expected a three-segment JWT, got ${segments.length}`);
32
- const json = Buffer.from(segments[1] ?? "", "base64url").toString("utf8");
33
- return JSON.parse(json);
34
- };
28
+ const misconfiguredDetail = (variable, service) => `${variable} is not set — the ${service} is only reachable from a \`uses:\` step, never from \`run:\``;
35
29
  /**
36
30
  * The run and job ids the artifact protocol needs, from the runtime token's
37
31
  * `scp` claim.
@@ -44,13 +38,9 @@ const payloadOf = (token) => {
44
38
  * @internal
45
39
  */
46
40
  const backendIdsFrom = (token) => {
47
- let payload;
48
- try {
49
- payload = payloadOf(token);
50
- } catch (cause) {
51
- return Result.fail(`the runtime token is not a readable JWT: ${cause instanceof Error ? cause.message : cause}`);
52
- }
53
- const scope = payload.scp;
41
+ const payload = payloadOf(token);
42
+ if (Result.isFailure(payload)) return Result.fail(`the runtime token is not a readable JWT: ${payload.failure.detail}`);
43
+ const scope = payload.success?.scp;
54
44
  if (typeof scope !== "string") return Result.fail("the runtime token carries no `scp` claim");
55
45
  for (const entry of scope.split(" ")) {
56
46
  const parts = entry.split(":");
@@ -95,4 +85,4 @@ const resultsBackend = (env) => Effect.gen(function* () {
95
85
  });
96
86
 
97
87
  //#endregion
98
- export { RESULTS_URL, RUNTIME_TOKEN, backendIdsFrom, resultsBackend };
88
+ export { RESULTS_URL, RUNTIME_TOKEN, backendIdsFrom, misconfiguredDetail, resultsBackend };
@@ -0,0 +1,70 @@
1
+ import { CONFLICT, isOk, stringField } from "./twirp.js";
2
+ import { Effect, Option } from "effect";
3
+
4
+ //#region src/internal/cacheService.ts
5
+ /**
6
+ * The Actions `CacheService` choreography — reserve, upload, finalize; look up
7
+ * — spelled once for the two services that speak it.
8
+ *
9
+ * @remarks
10
+ * `ActionCache` files a tar archive under a path-derived version and
11
+ * `BlobStore.githubCache` files an envelope under a constant one, but the
12
+ * three RPCs between them and their conflict semantics are identical. Each
13
+ * caller supplies its own per-key `call` and its own `refused` constructor, so
14
+ * the errors stay the caller's; this module owns only the protocol. It reaches
15
+ * `effect` and the Twirp client and nothing heavier — the Azure transfer of
16
+ * the bytes between the two RPCs stays in the three modules licensed to import
17
+ * it (`__test__/reachability.test.ts`).
18
+ *
19
+ * @internal
20
+ */
21
+ /** The Twirp service the Actions cache protocol lives under. @internal */
22
+ const CACHE_SERVICE = "github.actions.results.api.v1.CacheService";
23
+ /**
24
+ * Reserve `(key, version)` for an upload. `Option.none()` means another job
25
+ * saved this key first — entries are immutable, so the cache already holds
26
+ * what the caller wanted and there is nothing left to do.
27
+ *
28
+ * @internal
29
+ */
30
+ const reserveUpload = (client, key, version) => Effect.flatMap(client.call("CreateCacheEntry", {
31
+ key,
32
+ version
33
+ }), (created) => {
34
+ if (created === CONFLICT) return Effect.succeedNone;
35
+ const url = stringField(created, "signedUploadUrl");
36
+ return !isOk(created) || url === void 0 ? Effect.fail(client.refused("CreateCacheEntry returned no upload url")) : Effect.succeedSome(url);
37
+ });
38
+ /**
39
+ * Confirm an upload. Not folded in with the reservation: an unfinalized upload
40
+ * leaves bytes in Azure that no lookup can reach — a silent no-op that reads
41
+ * as a successful save — so it is a failure even though every byte arrived.
42
+ *
43
+ * @internal
44
+ */
45
+ const finalizeUpload = (client, key, version, sizeBytes) => Effect.flatMap(client.call("FinalizeCacheEntryUpload", {
46
+ key,
47
+ version,
48
+ size_bytes: String(sizeBytes)
49
+ }), (finalized) => finalized === CONFLICT || !isOk(finalized) ? Effect.fail(client.refused("FinalizeCacheEntryUpload did not confirm the upload")) : Effect.void);
50
+ /**
51
+ * The signed download url for a key, walking the restore-key ladder.
52
+ * `Option.none()` is a miss, not a failure — a cold cache is the normal state.
53
+ *
54
+ * @internal
55
+ */
56
+ const lookupDownload = (client, key, restoreKeys, version) => Effect.map(client.call("GetCacheEntryDownloadURL", {
57
+ key,
58
+ restore_keys: [...restoreKeys],
59
+ version
60
+ }), (found) => {
61
+ if (found === CONFLICT || !isOk(found)) return Option.none();
62
+ const url = stringField(found, "signedDownloadUrl");
63
+ return url === void 0 ? Option.none() : Option.some({
64
+ url,
65
+ matchedKey: stringField(found, "matchedKey")
66
+ });
67
+ });
68
+
69
+ //#endregion
70
+ export { CACHE_SERVICE, finalizeUpload, lookupDownload, reserveUpload };
@@ -0,0 +1,42 @@
1
+ import { Effect, Encoding, Stream } from "effect";
2
+ import { createHash } from "node:crypto";
3
+
4
+ //#region src/internal/digest.ts
5
+ /**
6
+ * The sanctioned `node:crypto` digests, spelled once.
7
+ *
8
+ * @remarks
9
+ * Core `Crypto` is RNG-only — no digest, no HMAC — so this package's licence
10
+ * for a `node:` import covers hashing (`CLAUDE.md`). Keeping every `createHash`
11
+ * behind this module is what makes that licence auditable in one place rather
12
+ * than six, and keeps the streamed file digest — the shape a multi-gigabyte
13
+ * toolchain archive needs — from being re-derived per caller.
14
+ *
15
+ * @internal
16
+ */
17
+ /** The raw SHA-256 of a string or byte array held in memory. @internal */
18
+ const sha256 = (value) => new Uint8Array(createHash("sha256").update(value).digest());
19
+ /** The hex SHA-256 of a string or byte array held in memory. @internal */
20
+ const sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
21
+ /**
22
+ * The raw digest of a file's bytes under `algorithm`, streamed rather than
23
+ * buffered. Raw, because a caller that folds several file digests into one
24
+ * (`CacheKey.hashFiles`, byte-compatible with the runner's `hashFiles()`)
25
+ * hashes the digest BYTES, not their hex spelling.
26
+ *
27
+ * `algorithm` must be one `node:crypto` supports — every caller's grammar
28
+ * admits only `sha1`/`sha256`/`sha384`/`sha512`, so `createHash` cannot throw.
29
+ *
30
+ * @internal
31
+ */
32
+ const digestFile = (fs, file, algorithm) => Effect.suspend(() => {
33
+ const accumulator = createHash(algorithm);
34
+ return Stream.runForEach(fs.stream(file), (chunk) => Effect.sync(() => {
35
+ accumulator.update(chunk);
36
+ })).pipe(Effect.map(() => new Uint8Array(accumulator.digest())));
37
+ });
38
+ /** {@link digestFile}, as hex. @internal */
39
+ const digestFileHex = (fs, file, algorithm) => Effect.map(digestFile(fs, file, algorithm), Encoding.encodeHex);
40
+
41
+ //#endregion
42
+ export { digestFile, digestFileHex, sha256, sha256Hex };