@effected/github-actions 0.1.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.
@@ -0,0 +1,255 @@
1
+ import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect";
2
+
3
+ //#region src/ActionEnvironment.ts
4
+ /**
5
+ * Raised when the runner environment does not say what an action needs.
6
+ *
7
+ * @remarks
8
+ * Two reasons, and the distinction is actionable: `missing` means the variable
9
+ * is absent or empty (the workflow did not grant something, or the action is
10
+ * running outside Actions); `malformed` means it is present but unusable (a
11
+ * non-numeric run id, an unparseable event payload). The first is usually a
12
+ * workflow fix, the second usually a bug report.
13
+ *
14
+ * @public
15
+ */
16
+ var ActionEnvironmentError = class extends Schema.TaggedErrorClass()("ActionEnvironmentError", {
17
+ reason: Schema.Literals(["missing", "malformed"]),
18
+ /** The environment variable involved. */
19
+ name: Schema.String,
20
+ /** What was wrong, when the reason is `malformed`. */
21
+ detail: Schema.optionalKey(Schema.String)
22
+ }) {
23
+ get message() {
24
+ return this.reason === "missing" ? `Required environment variable "${this.name}" is not set` : `Environment variable "${this.name}" is malformed${this.detail === void 0 ? "" : `: ${this.detail}`}`;
25
+ }
26
+ };
27
+ /**
28
+ * The workflow's GitHub context, projected from the `GITHUB_*` variables.
29
+ *
30
+ * @public
31
+ */
32
+ var GitHubContext = class extends Schema.Class("GitHubContext")({
33
+ /** `owner/repo`. */
34
+ repository: Schema.String,
35
+ repositoryOwner: Schema.String,
36
+ /** The full ref, e.g. `refs/heads/main`. */
37
+ ref: Schema.String,
38
+ /** The short ref, e.g. `main`. */
39
+ refName: Schema.String,
40
+ sha: Schema.String,
41
+ workflow: Schema.String,
42
+ job: Schema.String,
43
+ runId: Schema.Number,
44
+ runAttempt: Schema.Number,
45
+ eventName: Schema.String,
46
+ actor: Schema.String,
47
+ serverUrl: Schema.String,
48
+ apiUrl: Schema.String,
49
+ graphqlUrl: Schema.String,
50
+ workspace: Schema.String
51
+ }) {};
52
+ /**
53
+ * The runner's own context, projected from the `RUNNER_*` variables.
54
+ *
55
+ * @public
56
+ */
57
+ var RunnerContext = class extends Schema.Class("RunnerContext")({
58
+ os: Schema.String,
59
+ arch: Schema.String,
60
+ name: Schema.String,
61
+ /** A scratch directory emptied between jobs. */
62
+ temp: Schema.String,
63
+ /** Where `ToolInstaller` caches toolchains. */
64
+ toolCache: Schema.String
65
+ }) {};
66
+ /**
67
+ * Environment overrides layered on top of the seeded snapshot.
68
+ *
69
+ * @remarks
70
+ * A `Context.Reference` rather than a mutable global: overrides are
71
+ * **fiber-local**, so two concurrent fibers can override the same variable
72
+ * without seeing each other, and nothing needs restoring afterwards. This is
73
+ * what makes {@link ActionEnvironmentShape.withEnv} parallel-safe — the
74
+ * property the hand-rolled set/restore version it replaces could not have.
75
+ *
76
+ * @internal
77
+ */
78
+ const EnvOverrides = Context.Reference("@effected/github-actions/EnvOverrides", { defaultValue: () => ({}) });
79
+ const numeric = (name, raw) => {
80
+ const parsed = Number(raw);
81
+ return Number.isFinite(parsed) ? Effect.succeed(parsed) : Effect.fail(new ActionEnvironmentError({
82
+ reason: "malformed",
83
+ name,
84
+ detail: `expected a number, got "${raw}"`
85
+ }));
86
+ };
87
+ const make = (base) => Effect.gen(function* () {
88
+ const fs = yield* FileSystem.FileSystem;
89
+ const lookup = (name) => Effect.map(EnvOverrides, (overrides) => {
90
+ const value = overrides[name] ?? base[name];
91
+ return value === void 0 || value === "" ? Option.none() : Option.some(value);
92
+ });
93
+ const get = (name) => Effect.flatMap(lookup(name), (found) => Option.isSome(found) ? Effect.succeed(found.value) : Effect.fail(new ActionEnvironmentError({
94
+ reason: "missing",
95
+ name
96
+ })));
97
+ return {
98
+ get,
99
+ getOptional: lookup,
100
+ isDebug: Effect.map(lookup("RUNNER_DEBUG"), (value) => Option.isSome(value) && value.value === "1"),
101
+ github: Effect.gen(function* () {
102
+ const [repository, repositoryOwner, ref, refName, sha, workflow, job] = yield* Effect.all([
103
+ get("GITHUB_REPOSITORY"),
104
+ get("GITHUB_REPOSITORY_OWNER"),
105
+ get("GITHUB_REF"),
106
+ get("GITHUB_REF_NAME"),
107
+ get("GITHUB_SHA"),
108
+ get("GITHUB_WORKFLOW"),
109
+ get("GITHUB_JOB")
110
+ ]);
111
+ const runId = yield* Effect.flatMap(get("GITHUB_RUN_ID"), (raw) => numeric("GITHUB_RUN_ID", raw));
112
+ const runAttempt = yield* Effect.flatMap(get("GITHUB_RUN_ATTEMPT"), (raw) => numeric("GITHUB_RUN_ATTEMPT", raw));
113
+ const [eventName, actor, serverUrl, apiUrl, graphqlUrl, workspace] = yield* Effect.all([
114
+ get("GITHUB_EVENT_NAME"),
115
+ get("GITHUB_ACTOR"),
116
+ get("GITHUB_SERVER_URL"),
117
+ get("GITHUB_API_URL"),
118
+ get("GITHUB_GRAPHQL_URL"),
119
+ get("GITHUB_WORKSPACE")
120
+ ]);
121
+ return GitHubContext.make({
122
+ repository,
123
+ repositoryOwner,
124
+ ref,
125
+ refName,
126
+ sha,
127
+ workflow,
128
+ job,
129
+ runId,
130
+ runAttempt,
131
+ eventName,
132
+ actor,
133
+ serverUrl,
134
+ apiUrl,
135
+ graphqlUrl,
136
+ workspace
137
+ });
138
+ }),
139
+ runner: Effect.gen(function* () {
140
+ const [os, arch, name, temp, toolCache] = yield* Effect.all([
141
+ get("RUNNER_OS"),
142
+ get("RUNNER_ARCH"),
143
+ get("RUNNER_NAME"),
144
+ get("RUNNER_TEMP"),
145
+ get("RUNNER_TOOL_CACHE")
146
+ ]);
147
+ return RunnerContext.make({
148
+ os,
149
+ arch,
150
+ name,
151
+ temp,
152
+ toolCache
153
+ });
154
+ }),
155
+ payload: Effect.gen(function* () {
156
+ const path = yield* get("GITHUB_EVENT_PATH");
157
+ const raw = yield* fs.readFileString(path).pipe(Effect.mapError((cause) => new ActionEnvironmentError({
158
+ reason: "malformed",
159
+ name: "GITHUB_EVENT_PATH",
160
+ detail: `could not read "${path}": ${String(cause)}`
161
+ })));
162
+ return yield* Effect.try({
163
+ try: () => JSON.parse(raw),
164
+ catch: (cause) => new ActionEnvironmentError({
165
+ reason: "malformed",
166
+ name: "GITHUB_EVENT_PATH",
167
+ detail: `not valid JSON: ${String(cause)}`
168
+ })
169
+ });
170
+ }),
171
+ withEnv: (overrides, effect) => Effect.flatMap(EnvOverrides, (current) => Effect.provideService(effect, EnvOverrides, {
172
+ ...current,
173
+ ...overrides
174
+ }))
175
+ };
176
+ });
177
+ /** The `GITHUB_*` / `RUNNER_*` block a test double seeds so suites do not restate it. */
178
+ const TEST_DEFAULTS = {
179
+ GITHUB_REPOSITORY: "acme/example",
180
+ GITHUB_REPOSITORY_OWNER: "acme",
181
+ GITHUB_REF: "refs/heads/main",
182
+ GITHUB_REF_NAME: "main",
183
+ GITHUB_SHA: "0000000000000000000000000000000000000000",
184
+ GITHUB_WORKFLOW: "test",
185
+ GITHUB_JOB: "test",
186
+ GITHUB_RUN_ID: "1",
187
+ GITHUB_RUN_ATTEMPT: "1",
188
+ GITHUB_EVENT_NAME: "push",
189
+ GITHUB_ACTOR: "tester",
190
+ GITHUB_SERVER_URL: "https://github.com",
191
+ GITHUB_API_URL: "https://api.github.com",
192
+ GITHUB_GRAPHQL_URL: "https://api.github.com/graphql",
193
+ GITHUB_WORKSPACE: "/workspace",
194
+ RUNNER_OS: "Linux",
195
+ RUNNER_ARCH: "X64",
196
+ RUNNER_NAME: "test-runner",
197
+ RUNNER_TEMP: "/tmp",
198
+ RUNNER_TOOL_CACHE: "/opt/hostedtoolcache"
199
+ };
200
+ /**
201
+ * The runner environment an action is executing inside.
202
+ *
203
+ * @remarks
204
+ * The single reader of `process.env` in this package. Everything else — inputs,
205
+ * outputs, state, the token bridge — goes through a service rather than
206
+ * reaching for a global, which is what makes them all testable without
207
+ * mutating the test process.
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * import { ActionEnvironment } from "@effected/github-actions";
212
+ * import { Effect } from "effect";
213
+ *
214
+ * const program = Effect.gen(function* () {
215
+ * const env = yield* ActionEnvironment;
216
+ * const { repository, runId } = yield* env.github;
217
+ * return `${repository}#${runId}`;
218
+ * });
219
+ * ```
220
+ *
221
+ * @public
222
+ */
223
+ var ActionEnvironment = class ActionEnvironment extends Context.Service()("@effected/github-actions/ActionEnvironment") {
224
+ /** Seeded from `process.env`, once, at layer construction. */
225
+ static layer = Layer.effect(this, Effect.flatMap(Effect.sync(() => ({ ...process.env })), make));
226
+ /**
227
+ * Seeded from an explicit record.
228
+ *
229
+ * @remarks
230
+ * A parameterized layer factory mints a fresh reference per call and layers
231
+ * memoize by reference — bind it to a `const` rather than calling it at each
232
+ * composition site.
233
+ */
234
+ static layerFrom = (env) => Layer.effect(ActionEnvironment, make(env));
235
+ /**
236
+ * A test double seeded with a complete `GITHUB_*` / `RUNNER_*` block.
237
+ *
238
+ * @remarks
239
+ * **A recorded exception to the die-on-unstubbed rule.** Every other service
240
+ * in this package dies when a suite calls something it did not stub, because
241
+ * a plausible default hides a real gap. Here the opposite is true: the twelve
242
+ * context variables have one obviously-correct shape, and requiring each
243
+ * suite to restate them produced a byte-identical block duplicated six times
244
+ * across the consumers. Overrides merge on top.
245
+ */
246
+ static makeTest = (overrides = {}) => make({
247
+ ...TEST_DEFAULTS,
248
+ ...overrides
249
+ });
250
+ /** {@link ActionEnvironment.makeTest} behind a layer, with `FileSystem` stubbed out. */
251
+ static layerTest = (overrides = {}) => Layer.effect(ActionEnvironment, ActionEnvironment.makeTest(overrides)).pipe(Layer.provide(FileSystem.layerNoop({})));
252
+ };
253
+
254
+ //#endregion
255
+ export { ActionEnvironment, ActionEnvironmentError, GitHubContext, RunnerContext };
package/ActionInput.js ADDED
@@ -0,0 +1,346 @@
1
+ import { Config, ConfigProvider, Context, Effect, Option, Schema, SchemaIssue } from "effect";
2
+
3
+ //#region src/ActionInput.ts
4
+ /**
5
+ * The variable name the runner publishes an input under.
6
+ *
7
+ * @remarks
8
+ * GitHub uppercases the input name and replaces **spaces** with underscores —
9
+ * and leaves every other character, **dashes included**, alone. So the input
10
+ * `sbom-config` arrives as `INPUT_SBOM-CONFIG`, not `INPUT_SBOM_CONFIG`.
11
+ *
12
+ * That distinction is not academic: a consumer read `process.env["INPUT_SBOM_CONFIG"]`
13
+ * directly, silently got nothing, and shipped it. Every accessor in this module
14
+ * goes through this function so no caller ever spells the variable name, which
15
+ * makes that class of bug unrepresentable rather than merely documented.
16
+ *
17
+ * @internal
18
+ */
19
+ const inputVariable = (name) => `INPUT_${name.replaceAll(" ", "_").toUpperCase()}`;
20
+ /**
21
+ * A validation failure for an input that is present but unusable.
22
+ *
23
+ * @remarks
24
+ * `actual` carries the offending value, and that is load-bearing rather than
25
+ * decorative: `Config.withDefault` and `Config.option` fall back only for
26
+ * *missing data*, and their `isMissingDataOnly` check reads an `InvalidValue`
27
+ * whose `actual` is `None` as missing (`Config.ts:304`). An error built without
28
+ * it would therefore be **swallowed by a default** — so `dry-run: yes` would
29
+ * quietly become `false` and the action would perform the mutations the author
30
+ * meant to rehearse.
31
+ */
32
+ const configError = (message, actual) => new Config.ConfigError(new Schema.SchemaError(new SchemaIssue.InvalidValue(Option.some(actual), { message })));
33
+ /** YAML 1.2 core-schema booleans, which is what the runner documents. */
34
+ const TRUE = /* @__PURE__ */ new Set([
35
+ "true",
36
+ "True",
37
+ "TRUE"
38
+ ]);
39
+ const FALSE = /* @__PURE__ */ new Set([
40
+ "false",
41
+ "False",
42
+ "FALSE"
43
+ ]);
44
+ /** Strip a trailing `#` comment and surrounding whitespace from one line. */
45
+ const stripComment = (line) => {
46
+ const hash = line.indexOf("#");
47
+ return (hash === -1 ? line : line.slice(0, hash)).trim();
48
+ };
49
+ /**
50
+ * Action inputs, read as `Config` values.
51
+ *
52
+ * @remarks
53
+ * Inputs are **never** read from `process.env` directly. Every accessor names
54
+ * the input the way the workflow author wrote it and lets this module derive
55
+ * the runner's variable name — see `inputVariable` for why that matters. (A
56
+ * `{@link}` cannot reach it: it is internal, so it is not exported from the
57
+ * entrypoint and the reference would not resolve.)
58
+ *
59
+ * `lines`, `list` and `pairs` exist because the `@actions/core`-faithful
60
+ * newline split was not enough for real consumers: two of them reinvented
61
+ * richer parsing independently, one parsing JSON arrays and bullet lists, the
62
+ * other stripping comments and reading `key=value` pairs. Those are now one
63
+ * implementation with one set of tests instead of two divergent ones with
64
+ * none.
65
+ *
66
+ * These are grouped statics over one concept reaching nothing but `Config` —
67
+ * not a namespace object over engines.
68
+ *
69
+ * **The absence contract, shared by every accessor:** the runner writes `""`
70
+ * for an input the workflow left out, and the provider reads an empty string
71
+ * as absent — so *unset* and *empty* are the same case, **missing data**, and
72
+ * the read fails rather than yielding an empty value. `Config.withDefault`
73
+ * falls back for exactly that class, which makes
74
+ * `.pipe(Config.withDefault(…))` the idiom for every optional input. What a
75
+ * default never swallows is a *present but malformed* value — see
76
+ * {@link ActionInput.boolean}.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { ActionInput } from "@effected/github-actions";
81
+ * import { Effect } from "effect";
82
+ *
83
+ * const program = Effect.gen(function* () {
84
+ * const dryRun = yield* ActionInput.boolean("dry-run");
85
+ * const globs = yield* ActionInput.list("paths");
86
+ * });
87
+ * ```
88
+ *
89
+ * @public
90
+ */
91
+ var ActionInput = class ActionInput {
92
+ constructor() {}
93
+ /**
94
+ * A required string input.
95
+ *
96
+ * @remarks
97
+ * Fails as *missing data* when the input is absent **or set to the empty
98
+ * string** — the runner writes `""` for an unsupplied input, and both the
99
+ * runtime's provider and {@link ActionInput.provider} read that as absent.
100
+ * Every accessor on this class shares the rule, so an **optional** input
101
+ * needs `Config.withDefault` (or `Config.option`) at the call site or the
102
+ * read fails outright.
103
+ */
104
+ static string(name) {
105
+ return Config.string(inputVariable(name));
106
+ }
107
+ /**
108
+ * A boolean input, per the runner's documented YAML 1.2 core schema.
109
+ *
110
+ * @remarks
111
+ * Absent or `""` is **missing data** — wrap with `Config.withDefault` for
112
+ * an optional flag. A **present but malformed** value (`yes`, `on`, `1`) is
113
+ * a different class: it fails carrying its `actual`, deliberately, so a
114
+ * default does NOT swallow it — the shipped defect this guards against was
115
+ * a malformed `dry-run` quietly reading as `false`.
116
+ */
117
+ static boolean(name) {
118
+ return Config.string(inputVariable(name)).pipe(Config.mapOrFail((raw) => {
119
+ const value = raw.trim();
120
+ if (TRUE.has(value)) return Effect.succeed(true);
121
+ if (FALSE.has(value)) return Effect.succeed(false);
122
+ return Effect.fail(configError(`Input "${name}" does not meet the YAML 1.2 core schema for booleans. Supported: true | True | TRUE | false | False | FALSE`, raw));
123
+ }));
124
+ }
125
+ /**
126
+ * An integer input.
127
+ *
128
+ * @remarks
129
+ * Unset and `""` both fail as **missing data**; for an optional input,
130
+ * `.pipe(Config.withDefault(n))`.
131
+ */
132
+ static integer(name) {
133
+ return Config.int(inputVariable(name));
134
+ }
135
+ /**
136
+ * A secret input, kept redacted.
137
+ *
138
+ * @remarks
139
+ * Unset and `""` both fail as **missing data**; `Config.option` is the
140
+ * usual shape for an optional secret, since there is rarely a meaningful
141
+ * default to redact.
142
+ */
143
+ static redacted(name) {
144
+ return Config.redacted(inputVariable(name));
145
+ }
146
+ /**
147
+ * A multiline input, split on newlines.
148
+ *
149
+ * @remarks
150
+ * The `@actions/core`-faithful shape: blank lines are dropped and each entry
151
+ * is trimmed, because a YAML block scalar carries the workflow's own
152
+ * indentation.
153
+ *
154
+ * Absent or `""` fails as missing data (see {@link ActionInput.string}) —
155
+ * `Config.withDefault([])` is load-bearing for an optional multiline input.
156
+ */
157
+ static lines(name) {
158
+ return Config.string(inputVariable(name)).pipe(Config.map((raw) => raw.split("\n").map((line) => line.trim()).filter((line) => line !== "")));
159
+ }
160
+ /**
161
+ * A list input, accepting the shapes workflow authors actually write.
162
+ *
163
+ * @remarks
164
+ * A JSON array (`["a","b"]`), a bullet list (`- a` or `* a`), or comma- and
165
+ * newline-separated values, with full-line `#` comments dropped — any
166
+ * combination of these in the same input. Each shape was reinvented
167
+ * independently by a consumer (one a bullet-list-and-JSON parser, the other
168
+ * comment-stripping); accepting the union means a workflow author's first
169
+ * guess works and nothing either prior consumer relied on regresses.
170
+ *
171
+ * Parsing order, applied per newline/comma-separated item after trimming:
172
+ *
173
+ * 1. **Comment lines are dropped first.** A trimmed item whose first
174
+ * character is `#` is a full-line comment and is discarded entirely,
175
+ * before any bullet marker is considered. This means `- #tag` is a
176
+ * value, not a comment: the check runs on the untouched, still-bulleted
177
+ * item, and `-` is not `#`.
178
+ * 2. **Only then is a leading bullet marker stripped** — `- ` or `* `
179
+ * (dash-or-asterisk followed by a space), mirroring the indentation a
180
+ * YAML block scalar carries in. So `- #tag` becomes the value `#tag`.
181
+ * 3. Blank results (an empty line, or a comment line) are dropped from the
182
+ * final list.
183
+ *
184
+ * A `#` that is not the first character of a trimmed item — a trailing or
185
+ * mid-value `#` — is left alone; unlike {@link ActionInput.pairs}, `list`
186
+ * has no trailing-comment rule, only a whole-line one.
187
+ *
188
+ * Absent or `""` fails as **missing data** (see {@link ActionInput.string}),
189
+ * so `Config.withDefault([])` is load-bearing at every optional-input call
190
+ * site — omitting an optional multi-value input otherwise fails the read
191
+ * outright. A *whitespace-only* value (a block scalar holding only a
192
+ * newline) is present, and parses to `[]`.
193
+ */
194
+ static list(name) {
195
+ return Config.string(inputVariable(name)).pipe(Config.mapOrFail((raw) => {
196
+ const trimmed = raw.trim();
197
+ if (trimmed === "") return Effect.succeed([]);
198
+ if (trimmed.startsWith("[")) try {
199
+ const parsed = JSON.parse(trimmed);
200
+ if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) return Effect.succeed(parsed);
201
+ return Effect.fail(configError(`Input "${name}" is a JSON array but not an array of strings`, raw));
202
+ } catch {
203
+ return Effect.fail(configError(`Input "${name}" looks like JSON but could not be parsed`, raw));
204
+ }
205
+ const entries = trimmed.split(/[\n,]/).map((item) => item.trim()).filter((item) => !item.startsWith("#")).map((item) => item.startsWith("- ") || item.startsWith("* ") ? item.slice(2).trim() : item).filter((item) => item !== "");
206
+ return Effect.succeed(entries);
207
+ }));
208
+ }
209
+ /**
210
+ * A `key=value` input, one pair per line, with `#` comments stripped.
211
+ *
212
+ * @remarks
213
+ * Only the **first** `=` splits, so a value may contain one.
214
+ *
215
+ * Absent or `""` fails as missing data (see {@link ActionInput.string}) —
216
+ * `Config.withDefault({})` is the idiom for an optional pairs input.
217
+ */
218
+ static pairs(name) {
219
+ return Config.string(inputVariable(name)).pipe(Config.mapOrFail((raw) => {
220
+ const result = {};
221
+ for (const line of raw.split("\n")) {
222
+ const stripped = stripComment(line);
223
+ if (stripped === "") continue;
224
+ const split = stripped.indexOf("=");
225
+ if (split === -1) return Effect.fail(configError(`Input "${name}" has a line that is not \`key=value\`: "${stripped}"`, raw));
226
+ result[stripped.slice(0, split).trim()] = stripped.slice(split + 1).trim();
227
+ }
228
+ return Effect.succeed(result);
229
+ }));
230
+ }
231
+ /**
232
+ * A JSON-valued input, decoded through a schema.
233
+ *
234
+ * @remarks
235
+ * Absent or `""` fails as **missing data** before any JSON parsing runs
236
+ * (see {@link ActionInput.string}) — `Config.withDefault` or
237
+ * `Config.option` for an optional input. A present value that is not valid
238
+ * JSON, or does not satisfy the schema, fails carrying its `actual` and is
239
+ * never swallowed by a default.
240
+ */
241
+ static schema(name, schema) {
242
+ return Config.string(inputVariable(name)).pipe(Config.mapOrFail((raw) => {
243
+ let parsed;
244
+ try {
245
+ parsed = JSON.parse(raw);
246
+ } catch {
247
+ return Effect.fail(configError(`Input "${name}" is not valid JSON`, raw));
248
+ }
249
+ return Schema.decodeUnknownEffect(schema)(parsed).pipe(Effect.mapError(() => configError(`Input "${name}" did not satisfy its schema`, parsed)));
250
+ }));
251
+ }
252
+ /**
253
+ * A `ConfigProvider` over a record of runner variables.
254
+ *
255
+ * @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.
261
+ *
262
+ * Taking the environment as an argument rather than reading `process.env`
263
+ * is what makes inputs testable without mutating the test process.
264
+ */
265
+ static provider(env = process.env) {
266
+ 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));
269
+ });
270
+ }
271
+ /**
272
+ * A layer installing {@link ActionInput.provider}.
273
+ *
274
+ * @remarks
275
+ * `ConfigProvider.ConfigProvider` is a `Context.Reference` in v4, so this is
276
+ * a reference override rather than a service — there is no
277
+ * `Effect.withConfigProvider`.
278
+ *
279
+ * A parameterized layer factory mints a fresh reference per call; bind it to
280
+ * a `const` rather than calling it at each composition site.
281
+ */
282
+ static layer(env) {
283
+ return ConfigProvider.layer(ActionInput.provider(env));
284
+ }
285
+ /**
286
+ * Inputs-first resolution layered over an existing provider.
287
+ *
288
+ * @remarks
289
+ * A bare `Config.string("dry-run")` resolves a **flat** name — one string
290
+ * segment — by first trying the variable the runner would have published for
291
+ * an input of that name (the `INPUT_` derivation this module owns), and only
292
+ * then trying the name unchanged through `ambient`. Anything the runner
293
+ * could not have set as an input — a nested path, a numeric segment — is
294
+ * passed to `ambient` untouched, so no path semantics are invented that the
295
+ * runner does not have.
296
+ *
297
+ * This exists because a live action shipped a **false green**: every bare
298
+ * `Config` read fell back to its `withDefault` because the runner exposes
299
+ * inputs as `INPUT_<MANGLED>` variables and a plain-named lookup finds
300
+ * nothing. Under this provider a bare read resolves through the same
301
+ * derivation `ActionInput.string` uses, so side-stepping the typed accessors
302
+ * degrades to the right answer instead of to the default.
303
+ *
304
+ * The documented trade: a workflow input named like an environment variable
305
+ * **shadows it** for bare reads — and because the derivation uppercases,
306
+ * any casing of the name sees the input. An unsupplied input does not
307
+ * shadow: the runner writes `""` for it, and the ambient provider's
308
+ * empty-is-absent rule (which the attempt resolves through) drops it. The
309
+ * `ActionInput` accessors themselves are unaffected — they read
310
+ * `INPUT_<MANGLED>` names, whose re-mangled form (`INPUT_INPUT_…`) never
311
+ * matches, so they fall through to the ambient lookup they always used.
312
+ */
313
+ static providerOver(ambient) {
314
+ return ConfigProvider.orElse(ConfigProvider.make((path) => {
315
+ const name = path.length === 1 ? path[0] : void 0;
316
+ return typeof name === "string" ? ambient.load([inputVariable(name)]) : Effect.succeed(void 0);
317
+ }), ambient);
318
+ }
319
+ /**
320
+ * The provider the default action runtime installs: {@link ActionInput.providerOver}
321
+ * composed over the ambient environment lookup.
322
+ *
323
+ * @remarks
324
+ * Installed by `ActionRuntime.layer`, and therefore by `Action.run` — a bare
325
+ * `Config` read inside an action resolves through the runner's `INPUT_`
326
+ * derivation instead of silently missing. A caller-supplied
327
+ * `ConfigProvider` layer still wins by normal layer precedence.
328
+ *
329
+ * The ambient half is whatever provider is **explicitly installed** when the
330
+ * layer builds — which is how a test injects a deterministic environment by
331
+ * providing `ConfigProvider.layer(ConfigProvider.fromEnv({ env }))` beneath
332
+ * the runtime. When none is installed, a **fresh** `ConfigProvider.fromEnv()`
333
+ * is built rather than reading the reference's default: v4 caches that
334
+ * default once per process, and a snapshot taken before the runner's
335
+ * variables are visible would resurrect exactly the missed-input class this
336
+ * layer exists to kill. An action's environment is fixed before the process
337
+ * starts, so production cannot tell the difference; a test mutating
338
+ * `process.env` around `Action.run` can.
339
+ *
340
+ * A bound constant rather than a factory: layers memoize by reference.
341
+ */
342
+ static layerDefault = ConfigProvider.layer(Effect.map(Effect.context(), (context) => ActionInput.providerOver(Context.getOrUndefined(context, ConfigProvider.ConfigProvider) ?? ConfigProvider.fromEnv())));
343
+ };
344
+
345
+ //#endregion
346
+ export { ActionInput, inputVariable };