@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.
- package/Action.js +138 -0
- package/ActionCache.js +317 -0
- package/ActionEnvironment.js +255 -0
- package/ActionInput.js +346 -0
- package/ActionLogger.js +205 -0
- package/ActionOutputs.js +135 -0
- package/ActionState.js +117 -0
- package/Artifact.js +423 -0
- package/BlobEnvelope.js +135 -0
- package/BlobStore.githubCache.js +183 -0
- package/BlobStore.js +203 -0
- package/BlobTransfer.js +21 -0
- package/CacheKey.js +234 -0
- package/DetachedProcess.js +231 -0
- package/DryRun.js +73 -0
- package/GitHubToken.js +226 -0
- package/LICENSE +21 -0
- package/OidcTokenIssuer.js +246 -0
- package/Secret.js +106 -0
- package/ToolInstaller.js +293 -0
- package/WorkflowCommand.js +100 -0
- package/index.d.ts +2467 -0
- package/index.js +22 -0
- package/internal/actionsResults.js +98 -0
- package/internal/sigv4.js +117 -0
- package/internal/twirp.js +97 -0
- package/package.json +52 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -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 };
|