@effected/github-actions 0.1.0 → 0.3.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",
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/ActionLogger.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ActionEnvironment } from "./ActionEnvironment.js";
2
2
  import { WorkflowCommand } from "./WorkflowCommand.js";
3
- import { Console, Context, Effect, Inspectable, Layer, LogLevel, Logger, References } from "effect";
3
+ import { Console, Context, Effect, Exit, Inspectable, Layer, LogLevel, Logger, References } from "effect";
4
4
 
5
5
  //#region src/ActionLogger.ts
6
6
  /**
@@ -91,6 +91,10 @@ const renderEntry = (level, message, annotations) => {
91
91
  * @internal
92
92
  */
93
93
  const ActiveBuffer = Context.Reference("@effected/github-actions/ActiveBuffer", { defaultValue: () => null });
94
+ /** Drop a transcript without writing it, so a green step stays quiet. */
95
+ const discard = (state) => Effect.sync(() => {
96
+ state.entries.length = 0;
97
+ });
94
98
  /** Write a transcript out and clear it, so a second flush is a no-op. */
95
99
  const flush = (state) => Effect.suspend(() => {
96
100
  if (state.entries.length === 0) return Effect.void;
@@ -107,7 +111,7 @@ const make = Effect.gen(function* () {
107
111
  const flushActive = Effect.flatMap(ActiveBuffer, (state) => state === null ? Effect.void : flush(state));
108
112
  return {
109
113
  group: (name, effect) => Effect.acquireUseRelease(Console.log(WorkflowCommand.group(name)), () => Effect.tapCause(effect, () => flushActive), () => Console.log(WorkflowCommand.endGroup())),
110
- withBuffer: (label, effect) => Effect.gen(function* () {
114
+ withBuffer: (label, effect, options) => Effect.gen(function* () {
111
115
  const minimum = yield* References.MinimumLogLevel;
112
116
  if ((yield* env.isDebug) || LogLevel.isLessThanOrEqualTo(minimum, "Debug")) return yield* effect;
113
117
  const state = {
@@ -122,7 +126,7 @@ const make = Effect.gen(function* () {
122
126
  }
123
127
  state.entries.push(formatMessage(options.message));
124
128
  });
125
- return yield* effect.pipe(Effect.provideService(References.MinimumLogLevel, "All"), Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([buffering])), Effect.provideService(ActiveBuffer, state), Effect.onExit(() => flush(state)));
129
+ return yield* effect.pipe(Effect.provideService(References.MinimumLogLevel, "All"), Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([buffering])), Effect.provideService(ActiveBuffer, state), Effect.onExit((exit) => Exit.isSuccess(exit) && options?.onSuccess === "discard" ? discard(state) : flush(state)));
126
130
  }),
127
131
  notice: (message, properties = {}) => Console.log(WorkflowCommand.notice(message, properties)),
128
132
  annotated: (properties, effect) => Effect.annotateLogs(effect, annotationRecord(properties))
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,
@@ -0,0 +1,61 @@
1
+ import { OidcTokenIssuer } from "./OidcTokenIssuer.js";
2
+ import { Effect, Layer } from "effect";
3
+ import { IdentityToken, IdentityTokenError } from "@effected/sbom";
4
+
5
+ //#region src/ActionsIdentityToken.ts
6
+ /**
7
+ * The Actions side of `@effected/sbom`'s inverted OIDC contract.
8
+ *
9
+ * @remarks
10
+ * `@effected/sbom` declares `IdentityToken` — one method, one audience,
11
+ * one redacted token — precisely so that signing does not drag the Actions
12
+ * runtime into every consumer that only wants to emit an SBOM. This is the
13
+ * layer that closes the inversion from the other side: it serves that contract
14
+ * from the runner's own OIDC token service ({@link OidcTokenIssuer}), the same
15
+ * arrangement as `@effected/workspaces` implementing `@effected/commands`'
16
+ * `LocalExec`.
17
+ *
18
+ * The audience is forwarded verbatim. `SigstoreSigner` asks its identity
19
+ * contract for the `sigstore` audience itself (`SIGSTORE_OIDC_AUDIENCE` is the
20
+ * signing protocol's requirement, owned there), so nothing here — and nothing
21
+ * in a consumer — needs to spell it.
22
+ *
23
+ * An `OidcTokenError` is rewrapped as `IdentityTokenError` with the
24
+ * original preserved structurally in `cause`, so a refused exchange still says
25
+ * which audience it was minting for **and** why the runner declined —
26
+ * `reason: "unavailable"` almost always means the workflow is missing
27
+ * `permissions: id-token: write`.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * import { ActionsIdentityToken, OidcTokenIssuer } from "@effected/github-actions";
32
+ * import { SigstoreSigner } from "@effected/sbom";
33
+ * import { Layer } from "effect";
34
+ *
35
+ * const signing = SigstoreSigner.layer.pipe(
36
+ * Layer.provide(ActionsIdentityToken.layer),
37
+ * Layer.provide(OidcTokenIssuer.layer),
38
+ * );
39
+ * ```
40
+ *
41
+ * @public
42
+ */
43
+ var ActionsIdentityToken = class {
44
+ constructor() {}
45
+ /**
46
+ * `IdentityToken` served by the runner's OIDC token service.
47
+ *
48
+ * @remarks
49
+ * Requires {@link OidcTokenIssuer} rather than composing
50
+ * `OidcTokenIssuer.layer` in, so an action that already wired the issuer —
51
+ * every action using `ActionRuntime.layer` has its requirements at hand —
52
+ * does not construct a second one.
53
+ */
54
+ static layer = Layer.effect(IdentityToken, Effect.map(OidcTokenIssuer, (issuer) => ({ token: (audience) => issuer.token(audience).pipe(Effect.mapError((cause) => new IdentityTokenError({
55
+ audience,
56
+ cause
57
+ }))) })));
58
+ };
59
+
60
+ //#endregion
61
+ export { ActionsIdentityToken };
@@ -0,0 +1,103 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { OidcTokenIssuer } from "./OidcTokenIssuer.js";
3
+ import { Effect, Option } from "effect";
4
+ import { SlsaProvenance } from "@effected/sbom";
5
+
6
+ //#region src/ActionsProvenance.ts
7
+ /**
8
+ * The server URL a workflow on github.com runs against.
9
+ *
10
+ * @remarks
11
+ * GHES runners export `GITHUB_SERVER_URL`; github.com runners do too, but a
12
+ * program running outside a runner — or under a partial test environment —
13
+ * legitimately sees it absent, and absence has a **correct answer** rather
14
+ * than being an error. See {@link ActionsProvenance.capture}.
15
+ */
16
+ const DEFAULT_SERVER_URL = "https://github.com";
17
+ /**
18
+ * The Actions side of `@effected/sbom`'s SLSA provenance constructor.
19
+ *
20
+ * @remarks
21
+ * `SlsaProvenance.forGitHubWorkflow` is total and does the real work, but it
22
+ * takes a camelCase input record — and the only input anyone inside a workflow
23
+ * actually holds is the runner's own OIDC claims, spelled in GitHub's
24
+ * snake_case claim names. The rename between the two is eleven all-string
25
+ * fields, so transposing `repository_id` and `repository_owner_id` compiles,
26
+ * passes every type check, and produces a validly signed attestation asserting
27
+ * the **wrong** provenance. This module owns that rename once, on the same
28
+ * `github-actions` ↔ `sbom` seam as {@link ActionsIdentityToken}: `sbom` must
29
+ * not depend on the Actions runtime, so the adapter lives here, beside the
30
+ * services it reads.
31
+ *
32
+ * The construct ends at the predicate. Assembling the in-toto statement,
33
+ * signing it and uploading the bundle stay with the consumer — that pipeline
34
+ * is deliberately consumer glue (see `@effected/sbom`'s `SigstoreSigner` and
35
+ * `@effected/github`'s `Attestation.upload`).
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { ActionsProvenance } from "@effected/github-actions";
40
+ * import { Effect } from "effect";
41
+ *
42
+ * const program = Effect.gen(function* () {
43
+ * const provenance = yield* ActionsProvenance.capture();
44
+ * return provenance.runDetails.builder.id;
45
+ * });
46
+ * ```
47
+ *
48
+ * @public
49
+ */
50
+ var ActionsProvenance = class {
51
+ constructor() {}
52
+ /**
53
+ * The current run's SLSA provenance, built from the runner's OIDC claims.
54
+ *
55
+ * @remarks
56
+ * Every field `GitHubWorkflowProvenance` declares is populated from
57
+ * {@link OidcClaims}, except `serverUrl`, which is read from
58
+ * `GITHUB_SERVER_URL` through {@link ActionEnvironment}'s `getOptional`
59
+ * with `https://github.com` as the default — **not** through the
60
+ * `GitHubContext` projection. The projection fails typed when a `GITHUB_*`
61
+ * variable is missing, and a missing server URL is not a failure: it has a
62
+ * correct default. GHES consumers set the variable, github.com consumers
63
+ * never should have to, and neither should think about it. (Upstream
64
+ * `@actions/attest` reads the same variable with no default and writes the
65
+ * literal string `undefined` into every URL it builds — the hazard
66
+ * `GitHubWorkflowProvenance.serverUrl` being a required field exists to
67
+ * prevent.)
68
+ *
69
+ * The audience is forwarded to {@link OidcTokenIssuerShape.claims}
70
+ * verbatim; the claims a provenance reads do not depend on it, so omitting
71
+ * it is fine and spends no extra token mint.
72
+ *
73
+ * An {@link OidcTokenError} passes through **untouched** — not caught, not
74
+ * defaulted, not wrapped. Whether attestation is mandatory or best-effort
75
+ * is the consumer's policy: an action that must not publish unattested
76
+ * lets the error propagate, and one that publishes anyway catches it at
77
+ * its own boundary. `reason: "unavailable"` almost always means the
78
+ * workflow is missing `permissions: id-token: write`.
79
+ */
80
+ static capture = Effect.fn("ActionsProvenance.capture")(function* (audience) {
81
+ const issuer = yield* OidcTokenIssuer;
82
+ const env = yield* ActionEnvironment;
83
+ const claims = yield* issuer.claims(audience);
84
+ const serverUrl = Option.getOrElse(yield* env.getOptional("GITHUB_SERVER_URL"), () => DEFAULT_SERVER_URL);
85
+ return SlsaProvenance.forGitHubWorkflow({
86
+ serverUrl,
87
+ repository: claims.repository,
88
+ ref: claims.ref,
89
+ sha: claims.sha,
90
+ eventName: claims.event_name,
91
+ workflowRef: claims.workflow_ref,
92
+ jobWorkflowRef: claims.job_workflow_ref,
93
+ repositoryId: claims.repository_id,
94
+ repositoryOwnerId: claims.repository_owner_id,
95
+ runnerEnvironment: claims.runner_environment,
96
+ runId: claims.run_id,
97
+ runAttempt: claims.run_attempt
98
+ });
99
+ });
100
+ };
101
+
102
+ //#endregion
103
+ export { ActionsProvenance };