@effected/github-actions 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ActionCache.js CHANGED
@@ -2,10 +2,11 @@ import { ActionEnvironment } from "./ActionEnvironment.js";
2
2
  import { BlobTransferError } from "./BlobTransfer.js";
3
3
  import { resultsBackend } from "./internal/actionsResults.js";
4
4
  import { CONFLICT, isOk, stringField, twirpCall } from "./internal/twirp.js";
5
- import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
5
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
6
6
  import { HttpClient } from "effect/unstable/http";
7
7
  import { createHash } from "node:crypto";
8
8
  import { BlobClient, BlockBlobClient } from "@azure/storage-blob";
9
+ import { GlobPattern, GlobSet } from "@effected/glob";
9
10
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10
11
 
11
12
  //#region src/ActionCache.ts
@@ -63,6 +64,15 @@ const SERVICE = "github.actions.results.api.v1.CacheService";
63
64
  * because upstream does not sort them: sorting here would compute a version no
64
65
  * other cache step in the workflow agrees with, and the entry a sibling step
65
66
  * wrote would simply never be found.
67
+ *
68
+ * **The digest covers the LITERAL pattern list, never the resolved paths.**
69
+ * That is upstream's choice, not ours: `getCacheVersion` hashes the caller's
70
+ * `paths` argument verbatim (`actions/toolkit`, `cache/src/internal/`
71
+ * `cacheUtils.ts:136-159`), and both `saveCacheV2` (`cache/src/cache.ts:689`)
72
+ * and `restoreCacheV2` (`cache.ts:361`) hand it the un-resolved list — restore
73
+ * never resolves at all. Hashing the resolved list here would compute a
74
+ * version the restore side cannot reproduce, and every save would be an entry
75
+ * no restore ever finds.
66
76
  */
67
77
  const versionOf = (paths) => createHash("sha256").update([
68
78
  ...paths,
@@ -82,9 +92,9 @@ const versionOf = (paths) => createHash("sha256").update([
82
92
  const azure = {
83
93
  uploadFile: (url, file) => Effect.tryPromise({
84
94
  try: () => new BlockBlobClient(url).uploadFile(file, {
85
- blockSize: 64 * 1024 * 1024,
95
+ blockSize: 67108864,
86
96
  concurrency: 8,
87
- maxSingleShotSize: 128 * 1024 * 1024
97
+ maxSingleShotSize: 134217728
88
98
  }),
89
99
  catch: (cause) => new BlobTransferError({
90
100
  reason: "uploadFailed",
@@ -144,38 +154,47 @@ const make = (transfer) => Effect.gen(function* () {
144
154
  }).pipe(Effect.mapError((failure) => fromTwirp(failure, key)));
145
155
  });
146
156
  /**
147
- * Run `tar`, keeping its stderr.
157
+ * Run `tar` ONCE, keeping its stderr.
148
158
  *
149
159
  * @remarks
150
160
  * On Windows, extraction runs with `-k` so a file another process holds
151
161
  * open is skipped rather than failing the whole restore — and `tar` then
152
162
  * exits 1 to say so, which is a warning rather than an error. Exit 2 and
153
163
  * above stays a failure on every platform.
164
+ *
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.
154
172
  */
155
- const tar = (args, key, tolerateWarnings) => Effect.gen(function* () {
173
+ const tar = (args, key, tolerateWarnings) => Effect.scoped(Effect.gen(function* () {
156
174
  const command = ChildProcess.make("tar", [...args]);
157
- const output = yield* spawner.string(command, { includeStderr: true }).pipe(Effect.mapError((cause) => new ActionCacheError({
175
+ const archiveError = (cause) => new ActionCacheError({
158
176
  reason: "archiveFailed",
159
177
  key,
160
178
  cause
161
- })));
162
- const code = yield* spawner.exitCode(command).pipe(Effect.mapError((cause) => new ActionCacheError({
163
- reason: "archiveFailed",
164
- key,
165
- cause
166
- })));
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));
167
183
  if (code !== 0 && !(tolerateWarnings && code === 1)) return yield* Effect.fail(new ActionCacheError({
168
184
  reason: "archiveFailed",
169
185
  key,
170
186
  stderr: output.trim()
171
187
  }));
172
- });
173
- /** A scratch archive path, removed on every exit path. */
188
+ }));
189
+ /**
190
+ * A scratch archive path — plus the scratch directory itself, where the
191
+ * save side writes its tar manifest — removed on every exit path.
192
+ */
174
193
  const withArchive = (key, use) => Effect.acquireUseRelease(fs.makeTempDirectory({ prefix: "effected-cache-" }).pipe(Effect.mapError((cause) => new ActionCacheError({
175
194
  reason: "archiveFailed",
176
195
  key,
177
196
  cause
178
- }))), (directory) => use(path.join(directory, "cache.tar.gz")), (directory) => Effect.ignore(fs.remove(directory, {
197
+ }))), (directory) => use(path.join(directory, "cache.tar.gz"), directory), (directory) => Effect.ignore(fs.remove(directory, {
179
198
  recursive: true,
180
199
  force: true
181
200
  })));
@@ -184,6 +203,90 @@ const make = (transfer) => Effect.gen(function* () {
184
203
  key,
185
204
  cause
186
205
  }));
206
+ /** Platform separators to the dialect's — applied to paths WE produce, never to the caller's pattern text, where a backslash is minimatch's escape character. */
207
+ const posix = (value) => value.replaceAll("\\", "/");
208
+ /**
209
+ * Resolve the caller's patterns to the concrete files and directories
210
+ * `tar` will archive, with `actions/cache` parity.
211
+ *
212
+ * @remarks
213
+ * The toolkit resolves through `@actions/glob` with `implicitDescendants`
214
+ * disabled (`cacheUtils.ts:48-70`): a matched directory is answered as
215
+ * itself and `tar` recurses into it, a pattern that matches nothing —
216
+ * including a literal path that is not on disk — contributes nothing
217
+ * (`internal-globber.ts:90-100` existence-filters every search path), a
218
+ * leading `~` expands to the home directory with the expansion
219
+ * glob-escaped (`internal-pattern.ts:226-234`), and blank or `#` members
220
+ * are skipped (the toolkit joins the list with newlines, where both are
221
+ * inert). A fully empty resolution is the toolkit's hard
222
+ * "Path Validation Error" (`cache.ts:662-666`) and a typed failure here.
223
+ *
224
+ * Two knowing departures: matches stay ABSOLUTE for the `-P` archive
225
+ * posture rather than being re-rooted at the workspace (the version
226
+ * hashes the literal list, so the wire protocol never sees the
227
+ * difference), and the walk does not follow directory symlinks, where
228
+ * the toolkit's globber does.
229
+ */
230
+ const resolvePaths = (patterns, key) => Effect.gen(function* () {
231
+ const failed = (detail, cause) => new ActionCacheError({
232
+ reason: "archiveFailed",
233
+ key,
234
+ detail,
235
+ ...cause === void 0 ? {} : { cause }
236
+ });
237
+ const cleaned = patterns.map((pattern) => pattern.trim()).filter((p) => p !== "" && !p.startsWith("#"));
238
+ const workspace = Option.getOrElse(yield* env.getOptional("GITHUB_WORKSPACE"), () => ".");
239
+ const base = posix(path.resolve(workspace));
240
+ const rooted = [];
241
+ for (const pattern of cleaned) {
242
+ const excluded = pattern.startsWith("!");
243
+ let target = excluded ? pattern.slice(1) : pattern;
244
+ if (target === "~" || target.startsWith("~/")) {
245
+ let home = yield* env.getOptional("HOME");
246
+ if (Option.isNone(home)) home = yield* env.getOptional("USERPROFILE");
247
+ if (Option.isNone(home)) return yield* Effect.fail(failed(`cannot expand "~" in "${pattern}" — neither HOME nor USERPROFILE is set`));
248
+ target = `${GlobPattern.escape(posix(path.resolve(home.value)))}${target.slice(1)}`;
249
+ }
250
+ if (!path.isAbsolute(target)) target = `${GlobPattern.escape(base)}/${target}`;
251
+ rooted.push(excluded ? `!${target}` : target);
252
+ }
253
+ const set = yield* GlobSet.compile(rooted).pipe(Effect.mapError((cause) => failed(`"${cause.pattern}" is not a usable glob pattern`, cause)));
254
+ const roots = [];
255
+ const wildcardRoots = [];
256
+ const addRoot = (root, fromWildcard) => {
257
+ if (root === "") return;
258
+ if (!roots.includes(root)) roots.push(root);
259
+ if (fromWildcard && !wildcardRoots.includes(root)) wildcardRoots.push(root);
260
+ };
261
+ for (const literal of set.literals) addRoot(literal, false);
262
+ for (const wildcard of set.wildcards) {
263
+ if (wildcard.negated) continue;
264
+ const prefix = wildcard.enumerationPrefix;
265
+ addRoot(prefix === "" ? base : prefix === "/" ? "/" : prefix.slice(0, -1), true);
266
+ }
267
+ const under = (child, parent) => parent === "/" ? child !== "/" : child.startsWith(`${parent}/`);
268
+ const searchRoots = roots.filter((root) => !roots.some((other) => other !== root && under(root, other)));
269
+ const needsWalk = (root) => wildcardRoots.some((w) => w === root || under(w, root));
270
+ const matched = [];
271
+ const seen = /* @__PURE__ */ new Set();
272
+ const admit = (candidate) => {
273
+ if (!seen.has(candidate) && set.matches(candidate)) {
274
+ seen.add(candidate);
275
+ matched.push(candidate);
276
+ }
277
+ };
278
+ for (const root of searchRoots) {
279
+ const info = yield* fs.stat(root).pipe(Effect.option);
280
+ if (Option.isNone(info)) continue;
281
+ admit(root);
282
+ if (info.value.type === "Directory" && needsWalk(root)) {
283
+ const entries = yield* fs.readDirectory(root, { recursive: true }).pipe(Effect.mapError((cause) => failed(`could not enumerate "${root}"`, cause)));
284
+ for (const entry of entries) admit(`${root}/${posix(entry)}`);
285
+ }
286
+ }
287
+ if (matched.length === 0) return yield* Effect.fail(failed(`no file or directory matched the cache paths: ${cleaned.join(", ")}`));
288
+ return matched;
289
+ });
187
290
  return {
188
291
  save: Effect.fn("ActionCache.save")(function* (paths, key) {
189
292
  const { primary } = ladder(key, void 0);
@@ -194,11 +297,19 @@ const make = (transfer) => Effect.gen(function* () {
194
297
  key: primary,
195
298
  detail: "no paths were given to cache"
196
299
  }));
197
- yield* withArchive(primary, (archive) => Effect.gen(function* () {
300
+ const resolved = yield* resolvePaths(paths, primary);
301
+ yield* withArchive(primary, (archive, scratch) => Effect.gen(function* () {
302
+ 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
+ })));
198
308
  yield* tar([
199
309
  "czPf",
200
310
  archive,
201
- ...paths
311
+ "-T",
312
+ manifest
202
313
  ], primary, false);
203
314
  const size = yield* fs.stat(archive).pipe(Effect.mapError((cause) => new ActionCacheError({
204
315
  reason: "archiveFailed",
@@ -37,6 +37,22 @@ var GitHubContext = class extends Schema.Class("GitHubContext")({
37
37
  ref: Schema.String,
38
38
  /** The short ref, e.g. `main`. */
39
39
  refName: Schema.String,
40
+ /**
41
+ * The source branch of the pull request, when the event has one.
42
+ *
43
+ * @remarks
44
+ * `GITHUB_HEAD_REF` is only set for `pull_request` events — and on every
45
+ * other event the runner does not merely omit it, it may write the **empty
46
+ * string**, which a raw `process.env` read happily reports as present. That
47
+ * trap is encoded in the type: absent and `""` both decode to
48
+ * `Option.none()`, so a consumer cannot build a cache key segment out of an
49
+ * empty branch name by accident. For "the branch a human means", use
50
+ * {@link GitHubContext.branch}, which owns the fallback to `refName`.
51
+ *
52
+ * The encoded form is `string | null` rather than a serialized `Option`, so
53
+ * an encoded context stays plain JSON.
54
+ */
55
+ headRef: Schema.OptionFromNullOr(Schema.String),
40
56
  sha: Schema.String,
41
57
  workflow: Schema.String,
42
58
  job: Schema.String,
@@ -48,7 +64,22 @@ var GitHubContext = class extends Schema.Class("GitHubContext")({
48
64
  apiUrl: Schema.String,
49
65
  graphqlUrl: Schema.String,
50
66
  workspace: Schema.String
51
- }) {};
67
+ }) {
68
+ /**
69
+ * The branch a human means: `headRef` when present (a pull request, where
70
+ * `refName` is the useless `123/merge`), otherwise `refName`.
71
+ *
72
+ * @remarks
73
+ * This is the universal branch-scoped-cache-key fallback chain, written
74
+ * once. On a `push` or any other non-PR event `headRef` is `Option.none()`
75
+ * — including when the runner wrote it as the empty string — so the answer
76
+ * is the short ref. Note that on a tag push `refName` names the tag, not a
77
+ * branch; a consumer that must distinguish still has the full `ref`.
78
+ */
79
+ get branch() {
80
+ return Option.getOrElse(this.headRef, () => this.refName);
81
+ }
82
+ };
52
83
  /**
53
84
  * The runner's own context, projected from the `RUNNER_*` variables.
54
85
  *
@@ -110,6 +141,7 @@ const make = (base) => Effect.gen(function* () {
110
141
  ]);
111
142
  const runId = yield* Effect.flatMap(get("GITHUB_RUN_ID"), (raw) => numeric("GITHUB_RUN_ID", raw));
112
143
  const runAttempt = yield* Effect.flatMap(get("GITHUB_RUN_ATTEMPT"), (raw) => numeric("GITHUB_RUN_ATTEMPT", raw));
144
+ const headRef = yield* lookup("GITHUB_HEAD_REF");
113
145
  const [eventName, actor, serverUrl, apiUrl, graphqlUrl, workspace] = yield* Effect.all([
114
146
  get("GITHUB_EVENT_NAME"),
115
147
  get("GITHUB_ACTOR"),
@@ -123,6 +155,7 @@ const make = (base) => Effect.gen(function* () {
123
155
  repositoryOwner,
124
156
  ref,
125
157
  refName,
158
+ headRef,
126
159
  sha,
127
160
  workflow,
128
161
  job,
package/ActionInput.js CHANGED
@@ -91,6 +91,21 @@ const stripComment = (line) => {
91
91
  var ActionInput = class ActionInput {
92
92
  constructor() {}
93
93
  /**
94
+ * The exact environment variable the runner publishes an input under.
95
+ *
96
+ * @remarks
97
+ * `variable("biome-version")` is `"INPUT_BIOME-VERSION"` — GitHub
98
+ * uppercases, replaces **spaces** with underscores, and leaves every other
99
+ * character (**dashes included**) alone. This is the same derivation every
100
+ * accessor on this class resolves through, exported so a test that must
101
+ * spell a runner variable can derive it instead of hand-writing it: a
102
+ * hand-written `INPUT_BIOME_VERSION` reads as absent on a real runner, the
103
+ * default silently applies, and the suite goes green against the wrong
104
+ * value. Prefer keying the test environment by input name (see
105
+ * {@link ActionInput.provider}) so no variable is spelled at all.
106
+ */
107
+ static variable = (name) => inputVariable(name);
108
+ /**
94
109
  * A required string input.
95
110
  *
96
111
  * @remarks
@@ -250,22 +265,60 @@ var ActionInput = class ActionInput {
250
265
  }));
251
266
  }
252
267
  /**
253
- * A `ConfigProvider` over a record of runner variables.
268
+ * A `ConfigProvider` over a record of runner variables **or input names**.
254
269
  *
255
270
  * @remarks
256
- * Subsumes the source package's free-standing `ActionsConfigProvider`, whose
257
- * three behaviors are preserved exactly: the path is joined with `_`, spaces
258
- * become underscores and the whole is uppercased; and an **empty string
259
- * reads as absent**, because the runner sets unsupplied inputs to `""` and
260
- * treating that as present would make every optional input look supplied.
271
+ * The record dual-accepts, and the split is the `INPUT_` prefix:
272
+ *
273
+ * - A key already spelled as a runner variable (`INPUT_BIOME-VERSION`,
274
+ * `PLAIN_VAR`) is read verbatim, exactly as before.
275
+ * - Any other key is treated as an **input name**, `with:`-block style —
276
+ * `{ "biome-version": "2.3.14" }` — and serves the variable this module
277
+ * derives for it (`ActionInput.variable`). This is the spelling to
278
+ * prefer in tests: a hand-written `INPUT_BIOME_VERSION` (underscore where
279
+ * the runner keeps the dash) reads as absent and the default silently
280
+ * applies, and the input-name form makes that class of bug
281
+ * unrepresentable — the mangling never leaves this module.
282
+ *
283
+ * When both spellings of the same input are present and non-empty, the
284
+ * explicit `INPUT_`-spelled entry wins.
285
+ *
286
+ * A **flat single-segment** lookup additionally tries the `INPUT_`
287
+ * derivation of the name first, mirroring the order the production runtime
288
+ * installs ({@link ActionInput.providerOver} via `layerDefault`) — so a
289
+ * bare `Config.string("biome-version")` resolves under this provider
290
+ * exactly as it does inside `Action.run`. Nested and numeric paths pass
291
+ * through as the joined spelling only, as ever.
292
+ *
293
+ * The source package's `ActionsConfigProvider` behaviors are otherwise
294
+ * preserved exactly: the path is joined with `_`, spaces become underscores
295
+ * and the whole is uppercased; and an **empty string reads as absent**,
296
+ * because the runner sets unsupplied inputs to `""` and treating that as
297
+ * present would make every optional input look supplied. That rule applies
298
+ * to input-name entries too: `{ "flag": "" }` is an unsupplied input.
261
299
  *
262
300
  * Taking the environment as an argument rather than reading `process.env`
263
301
  * is what makes inputs testable without mutating the test process.
264
302
  */
265
303
  static provider(env = process.env) {
304
+ /** Unset, and the `""` the runner writes for an unsupplied input, are both absent. */
305
+ const present = (value) => value !== void 0 && value !== "";
306
+ /**
307
+ * Resolve one runner-variable name: the verbatim entry wins; an
308
+ * `INPUT_`-prefixed miss then consults the input-name entries through the
309
+ * one spelling of the derivation (`inputVariable`).
310
+ */
311
+ const lookup = (name) => {
312
+ const direct = env[name];
313
+ if (present(direct)) return direct;
314
+ if (name.startsWith("INPUT_")) {
315
+ for (const [key, value] of Object.entries(env)) if (!key.startsWith("INPUT_") && inputVariable(key) === name && present(value)) return value;
316
+ }
317
+ };
266
318
  return ConfigProvider.make((path) => {
267
- const value = env[path.join("_").replaceAll(" ", "_").toUpperCase()];
268
- return Effect.succeed(value === void 0 || value === "" ? void 0 : ConfigProvider.makeValue(value));
319
+ const single = path.length === 1 && typeof path[0] === "string" ? path[0] : void 0;
320
+ const value = (single === void 0 ? void 0 : lookup(inputVariable(single))) ?? lookup(path.join("_").replaceAll(" ", "_").toUpperCase());
321
+ return Effect.succeed(value === void 0 ? void 0 : ConfigProvider.makeValue(value));
269
322
  });
270
323
  }
271
324
  /**
package/ActionOutputs.js CHANGED
@@ -14,13 +14,16 @@ var ActionOutputError = class extends Schema.TaggedErrorClass()("ActionOutputErr
14
14
  * the code is running outside a GitHub runner. `writeFailed` — the file
15
15
  * exists but could not be appended to. `invalidName` — the name would
16
16
  * corrupt the file's block structure. `encodeFailed` — a value did not
17
- * satisfy its schema.
17
+ * satisfy its schema. `detached` — the member was called under
18
+ * {@link ActionOutputs.layerDetached}, where the channel it writes cannot
19
+ * reach the job that would read it.
18
20
  */
19
21
  reason: Schema.Literals([
20
22
  "unavailable",
21
23
  "writeFailed",
22
24
  "invalidName",
23
- "encodeFailed"
25
+ "encodeFailed",
26
+ "detached"
24
27
  ]),
25
28
  /** Which runner file was involved, by environment variable name. */
26
29
  file: Schema.optionalKey(Schema.String),
@@ -34,6 +37,7 @@ var ActionOutputError = class extends Schema.TaggedErrorClass()("ActionOutputErr
34
37
  case "unavailable": return `Runner file "${this.file}" is not available; is this running on a GitHub runner?`;
35
38
  case "writeFailed": return `Failed to write to runner file "${this.file}"`;
36
39
  case "invalidName": return `"${this.name}" is not a usable output name`;
40
+ case "detached": 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.`;
37
41
  default: return `Failed to encode the value for "${this.name}"`;
38
42
  }
39
43
  }
@@ -116,6 +120,71 @@ const unimplemented = (member) => {
116
120
  */
117
121
  var ActionOutputs = class ActionOutputs extends Context.Service()("@effected/github-actions/ActionOutputs") {
118
122
  static layer = Layer.effect(this, make);
123
+ /**
124
+ * The outputs surface that is correct inside a **detached worker**.
125
+ *
126
+ * @remarks
127
+ * The masking model assumes the runner parses stdout. A detached worker's
128
+ * stdout is a **log file no runner parses**, so under the real layer
129
+ * `setSecret` emits `::add-mask::<plaintext>` into that file — a command
130
+ * that is simultaneously **inert** (nothing reads it, so nothing is masked)
131
+ * and a **secret leak** (the plaintext is now sitting verbatim in a log). A
132
+ * production action shipped exactly that for one round: an S3 secret and
133
+ * session token written into a worker's log by the masking call itself.
134
+ * This layer is the worker-side fix; the parent-side rule is that every
135
+ * secret a worker will hold is masked **before** the spawn, by
136
+ * `Secret.forChildEnv` under the real layer.
137
+ *
138
+ * The layer needs no environment and no filesystem — its `R` is `never` —
139
+ * so a worker composing it structurally *cannot* write a runner file.
140
+ * Per-member behavior:
141
+ *
142
+ * - **`setSecret` — silent no-op.** The value must not be written
143
+ * *anywhere*: there is no runner on this channel to hand it to, and any
144
+ * spelling of it in the log IS the leak. Masking is the parent's job,
145
+ * before the worker exists.
146
+ * - **`set`, `setJson`, `exportVariable`, `addPath`, `summary` — fail
147
+ * typed** (`reason: "detached"`, naming the file). Each writes a runner
148
+ * file that configures the parent job's *later steps* — `GITHUB_OUTPUT`,
149
+ * `GITHUB_ENV`, `GITHUB_PATH` — or is collected when the step completes
150
+ * (`GITHUB_STEP_SUMMARY`). A detached worker has no later steps, may
151
+ * outlive the job entirely, and even an inherited file path would be
152
+ * read at a moment unrelated to the write — so a worker calling one of
153
+ * these is a program error worth surfacing, not a write to degrade
154
+ * silently.
155
+ * - **`setFailed` — degrades to a plain log line** (`Console.error`,
156
+ * without the `::error::` syntax nothing here would parse). Unlike the
157
+ * file members it is log-like: "record that something failed" still
158
+ * means something in a worker's log, and its signature carries no error
159
+ * channel to fail through.
160
+ */
161
+ static layerDetached = Layer.succeed(this, {
162
+ set: (name) => Effect.fail(new ActionOutputError({
163
+ reason: "detached",
164
+ file: "GITHUB_OUTPUT",
165
+ name
166
+ })),
167
+ setJson: (name) => Effect.fail(new ActionOutputError({
168
+ reason: "detached",
169
+ file: "GITHUB_OUTPUT",
170
+ name
171
+ })),
172
+ summary: () => Effect.fail(new ActionOutputError({
173
+ reason: "detached",
174
+ file: "GITHUB_STEP_SUMMARY"
175
+ })),
176
+ exportVariable: (name) => Effect.fail(new ActionOutputError({
177
+ reason: "detached",
178
+ file: "GITHUB_ENV",
179
+ name
180
+ })),
181
+ addPath: () => Effect.fail(new ActionOutputError({
182
+ reason: "detached",
183
+ file: "GITHUB_PATH"
184
+ })),
185
+ setFailed: (message) => Console.error(message),
186
+ setSecret: () => Effect.void
187
+ });
119
188
  /** A test double. Unstubbed members die rather than silently succeeding. */
120
189
  static makeTest = (overrides = {}) => ({
121
190
  set: () => Effect.sync(() => unimplemented("set")),
package/ActionState.js CHANGED
@@ -12,12 +12,16 @@ var ActionStateError = class extends Schema.TaggedErrorClass()("ActionStateError
12
12
  /**
13
13
  * `missing` — no value was saved under this key in an earlier phase.
14
14
  * `malformed` — a value is there but is not JSON, or does not satisfy the
15
- * schema it was read with. `writeFailed` — the state file could not be
15
+ * schema it was read with. `notPlainJson` — caught at SAVE time: the
16
+ * schema's encoded form does not survive `JSON.stringify`/`JSON.parse`, so
17
+ * persisting it would present one phase later as a `malformed` mystery with
18
+ * no pointer to the cause. `writeFailed` — the state file could not be
16
19
  * appended to.
17
20
  */
18
21
  reason: Schema.Literals([
19
22
  "missing",
20
23
  "malformed",
24
+ "notPlainJson",
21
25
  "writeFailed"
22
26
  ]),
23
27
  key: Schema.String,
@@ -27,6 +31,7 @@ var ActionStateError = class extends Schema.TaggedErrorClass()("ActionStateError
27
31
  switch (this.reason) {
28
32
  case "missing": return `No action state was saved under "${this.key}"`;
29
33
  case "malformed": return `Action state under "${this.key}" could not be decoded`;
34
+ case "notPlainJson": return `The encoded form of action state under "${this.key}" is not plain JSON and would not survive the phase boundary — encode to a plain-JSON form (e.g. Schema.OptionFromNullOr rather than Schema.Option)`;
30
35
  default: return `Failed to persist action state under "${this.key}"`;
31
36
  }
32
37
  }
@@ -69,11 +74,33 @@ const make = Effect.gen(function* () {
69
74
  })));
70
75
  return Option.some(decoded);
71
76
  });
72
- const save = (key, value, schema) => Schema.encodeUnknownEffect(schema)(value).pipe(Effect.mapError((cause) => new ActionStateError({
73
- reason: "malformed",
74
- key,
75
- cause
76
- })), Effect.flatMap((encoded) => write(key, JSON.stringify(encoded))));
77
+ const save = (key, value, schema) => Effect.gen(function* () {
78
+ const encoded = yield* Schema.encodeUnknownEffect(schema)(value).pipe(Effect.mapError((cause) => new ActionStateError({
79
+ reason: "malformed",
80
+ key,
81
+ cause
82
+ })));
83
+ const { parsed, serialized } = yield* Effect.try({
84
+ try: () => {
85
+ const serialized = JSON.stringify(encoded);
86
+ return {
87
+ parsed: JSON.parse(serialized),
88
+ serialized
89
+ };
90
+ },
91
+ catch: (cause) => new ActionStateError({
92
+ reason: "notPlainJson",
93
+ key,
94
+ cause
95
+ })
96
+ });
97
+ yield* Schema.decodeUnknownEffect(schema)(parsed).pipe(Effect.mapError((cause) => new ActionStateError({
98
+ reason: "notPlainJson",
99
+ key,
100
+ cause
101
+ })));
102
+ yield* write(key, serialized);
103
+ });
77
104
  return {
78
105
  save,
79
106
  getOptional: read,
package/Artifact.js CHANGED
@@ -84,9 +84,9 @@ const ARTIFACT_VERSION = 7;
84
84
  const azure = {
85
85
  uploadFile: (url, file) => Effect.tryPromise({
86
86
  try: () => new BlockBlobClient(url).uploadFile(file, {
87
- blockSize: 64 * 1024 * 1024,
87
+ blockSize: 67108864,
88
88
  concurrency: 8,
89
- maxSingleShotSize: 128 * 1024 * 1024
89
+ maxSingleShotSize: 134217728
90
90
  }),
91
91
  catch: (cause) => new BlobTransferError({
92
92
  reason: "uploadFailed",
@@ -64,7 +64,8 @@ const fromTwirp = (failure, key) => failure.kind === "status" ? new BlobStoreErr
64
64
  });
65
65
  const make = (transfer) => Effect.gen(function* () {
66
66
  const http = yield* HttpClient.HttpClient;
67
- const backend = resultsBackend(yield* ActionEnvironment).pipe(Effect.mapError((name) => new BlobStoreError({
67
+ const env = yield* ActionEnvironment;
68
+ const backend = resultsBackend(env).pipe(Effect.mapError((name) => new BlobStoreError({
68
69
  reason: "misconfigured",
69
70
  detail: `${name} is not set — the Actions results backend is only reachable from a \`uses:\` step, never from \`run:\``
70
71
  })));