@effected/github-actions 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ActionCache.js +128 -17
- package/ActionEnvironment.js +34 -1
- package/ActionInput.js +61 -8
- package/ActionOutputs.js +71 -2
- package/ActionState.js +33 -6
- package/Artifact.js +2 -2
- package/BlobStore.githubCache.js +2 -1
- package/CacheKey.js +147 -8
- package/ChildEnv.js +124 -0
- package/PackageManagerInstaller.js +494 -0
- package/Secret.js +21 -0
- package/ToolInstaller.js +98 -52
- package/index.d.ts +878 -125
- package/index.js +3 -1
- package/package.json +5 -4
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:
|
|
95
|
+
blockSize: 67108864,
|
|
86
96
|
concurrency: 8,
|
|
87
|
-
maxSingleShotSize:
|
|
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
|
|
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
|
|
175
|
+
const archiveError = (cause) => new ActionCacheError({
|
|
158
176
|
reason: "archiveFailed",
|
|
159
177
|
key,
|
|
160
178
|
cause
|
|
161
|
-
})
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
/**
|
|
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*
|
|
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
|
-
|
|
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/ActionEnvironment.js
CHANGED
|
@@ -37,6 +37,22 @@ var GitHubContext = class extends Schema.Class("GitHubContext")({
|
|
|
37
37
|
ref: Schema.String,
|
|
38
38
|
/** The short ref, e.g. `main`. */
|
|
39
39
|
refName: Schema.String,
|
|
40
|
+
/**
|
|
41
|
+
* The source branch of the pull request, when the event has one.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* `GITHUB_HEAD_REF` is only set for `pull_request` events — and on every
|
|
45
|
+
* other event the runner does not merely omit it, it may write the **empty
|
|
46
|
+
* string**, which a raw `process.env` read happily reports as present. That
|
|
47
|
+
* trap is encoded in the type: absent and `""` both decode to
|
|
48
|
+
* `Option.none()`, so a consumer cannot build a cache key segment out of an
|
|
49
|
+
* empty branch name by accident. For "the branch a human means", use
|
|
50
|
+
* {@link GitHubContext.branch}, which owns the fallback to `refName`.
|
|
51
|
+
*
|
|
52
|
+
* The encoded form is `string | null` rather than a serialized `Option`, so
|
|
53
|
+
* an encoded context stays plain JSON.
|
|
54
|
+
*/
|
|
55
|
+
headRef: Schema.OptionFromNullOr(Schema.String),
|
|
40
56
|
sha: Schema.String,
|
|
41
57
|
workflow: Schema.String,
|
|
42
58
|
job: Schema.String,
|
|
@@ -48,7 +64,22 @@ var GitHubContext = class extends Schema.Class("GitHubContext")({
|
|
|
48
64
|
apiUrl: Schema.String,
|
|
49
65
|
graphqlUrl: Schema.String,
|
|
50
66
|
workspace: Schema.String
|
|
51
|
-
}) {
|
|
67
|
+
}) {
|
|
68
|
+
/**
|
|
69
|
+
* The branch a human means: `headRef` when present (a pull request, where
|
|
70
|
+
* `refName` is the useless `123/merge`), otherwise `refName`.
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* This is the universal branch-scoped-cache-key fallback chain, written
|
|
74
|
+
* once. On a `push` or any other non-PR event `headRef` is `Option.none()`
|
|
75
|
+
* — including when the runner wrote it as the empty string — so the answer
|
|
76
|
+
* is the short ref. Note that on a tag push `refName` names the tag, not a
|
|
77
|
+
* branch; a consumer that must distinguish still has the full `ref`.
|
|
78
|
+
*/
|
|
79
|
+
get branch() {
|
|
80
|
+
return Option.getOrElse(this.headRef, () => this.refName);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
52
83
|
/**
|
|
53
84
|
* The runner's own context, projected from the `RUNNER_*` variables.
|
|
54
85
|
*
|
|
@@ -110,6 +141,7 @@ const make = (base) => Effect.gen(function* () {
|
|
|
110
141
|
]);
|
|
111
142
|
const runId = yield* Effect.flatMap(get("GITHUB_RUN_ID"), (raw) => numeric("GITHUB_RUN_ID", raw));
|
|
112
143
|
const runAttempt = yield* Effect.flatMap(get("GITHUB_RUN_ATTEMPT"), (raw) => numeric("GITHUB_RUN_ATTEMPT", raw));
|
|
144
|
+
const headRef = yield* lookup("GITHUB_HEAD_REF");
|
|
113
145
|
const [eventName, actor, serverUrl, apiUrl, graphqlUrl, workspace] = yield* Effect.all([
|
|
114
146
|
get("GITHUB_EVENT_NAME"),
|
|
115
147
|
get("GITHUB_ACTOR"),
|
|
@@ -123,6 +155,7 @@ const make = (base) => Effect.gen(function* () {
|
|
|
123
155
|
repositoryOwner,
|
|
124
156
|
ref,
|
|
125
157
|
refName,
|
|
158
|
+
headRef,
|
|
126
159
|
sha,
|
|
127
160
|
workflow,
|
|
128
161
|
job,
|
package/ActionInput.js
CHANGED
|
@@ -91,6 +91,21 @@ const stripComment = (line) => {
|
|
|
91
91
|
var ActionInput = class ActionInput {
|
|
92
92
|
constructor() {}
|
|
93
93
|
/**
|
|
94
|
+
* The exact environment variable the runner publishes an input under.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* `variable("biome-version")` is `"INPUT_BIOME-VERSION"` — GitHub
|
|
98
|
+
* uppercases, replaces **spaces** with underscores, and leaves every other
|
|
99
|
+
* character (**dashes included**) alone. This is the same derivation every
|
|
100
|
+
* accessor on this class resolves through, exported so a test that must
|
|
101
|
+
* spell a runner variable can derive it instead of hand-writing it: a
|
|
102
|
+
* hand-written `INPUT_BIOME_VERSION` reads as absent on a real runner, the
|
|
103
|
+
* default silently applies, and the suite goes green against the wrong
|
|
104
|
+
* value. Prefer keying the test environment by input name (see
|
|
105
|
+
* {@link ActionInput.provider}) so no variable is spelled at all.
|
|
106
|
+
*/
|
|
107
|
+
static variable = (name) => inputVariable(name);
|
|
108
|
+
/**
|
|
94
109
|
* A required string input.
|
|
95
110
|
*
|
|
96
111
|
* @remarks
|
|
@@ -250,22 +265,60 @@ var ActionInput = class ActionInput {
|
|
|
250
265
|
}));
|
|
251
266
|
}
|
|
252
267
|
/**
|
|
253
|
-
* A `ConfigProvider` over a record of runner variables
|
|
268
|
+
* A `ConfigProvider` over a record of runner variables **or input names**.
|
|
254
269
|
*
|
|
255
270
|
* @remarks
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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
|
|
268
|
-
|
|
319
|
+
const single = path.length === 1 && typeof path[0] === "string" ? path[0] : void 0;
|
|
320
|
+
const value = (single === void 0 ? void 0 : lookup(inputVariable(single))) ?? lookup(path.join("_").replaceAll(" ", "_").toUpperCase());
|
|
321
|
+
return Effect.succeed(value === void 0 ? void 0 : ConfigProvider.makeValue(value));
|
|
269
322
|
});
|
|
270
323
|
}
|
|
271
324
|
/**
|
package/ActionOutputs.js
CHANGED
|
@@ -14,13 +14,16 @@ var ActionOutputError = class extends Schema.TaggedErrorClass()("ActionOutputErr
|
|
|
14
14
|
* the code is running outside a GitHub runner. `writeFailed` — the file
|
|
15
15
|
* exists but could not be appended to. `invalidName` — the name would
|
|
16
16
|
* corrupt the file's block structure. `encodeFailed` — a value did not
|
|
17
|
-
* satisfy its schema.
|
|
17
|
+
* satisfy its schema. `detached` — the member was called under
|
|
18
|
+
* {@link ActionOutputs.layerDetached}, where the channel it writes cannot
|
|
19
|
+
* reach the job that would read it.
|
|
18
20
|
*/
|
|
19
21
|
reason: Schema.Literals([
|
|
20
22
|
"unavailable",
|
|
21
23
|
"writeFailed",
|
|
22
24
|
"invalidName",
|
|
23
|
-
"encodeFailed"
|
|
25
|
+
"encodeFailed",
|
|
26
|
+
"detached"
|
|
24
27
|
]),
|
|
25
28
|
/** Which runner file was involved, by environment variable name. */
|
|
26
29
|
file: Schema.optionalKey(Schema.String),
|
|
@@ -34,6 +37,7 @@ var ActionOutputError = class extends Schema.TaggedErrorClass()("ActionOutputErr
|
|
|
34
37
|
case "unavailable": return `Runner file "${this.file}" is not available; is this running on a GitHub runner?`;
|
|
35
38
|
case "writeFailed": return `Failed to write to runner file "${this.file}"`;
|
|
36
39
|
case "invalidName": return `"${this.name}" is not a usable output name`;
|
|
40
|
+
case "detached": return `Runner file "${this.file}" cannot be reached from a detached worker — it configures the parent job's later steps, and a worker has none and may outlive the job. Publish this from the parent process instead.`;
|
|
37
41
|
default: return `Failed to encode the value for "${this.name}"`;
|
|
38
42
|
}
|
|
39
43
|
}
|
|
@@ -116,6 +120,71 @@ const unimplemented = (member) => {
|
|
|
116
120
|
*/
|
|
117
121
|
var ActionOutputs = class ActionOutputs extends Context.Service()("@effected/github-actions/ActionOutputs") {
|
|
118
122
|
static layer = Layer.effect(this, make);
|
|
123
|
+
/**
|
|
124
|
+
* The outputs surface that is correct inside a **detached worker**.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* The masking model assumes the runner parses stdout. A detached worker's
|
|
128
|
+
* stdout is a **log file no runner parses**, so under the real layer
|
|
129
|
+
* `setSecret` emits `::add-mask::<plaintext>` into that file — a command
|
|
130
|
+
* that is simultaneously **inert** (nothing reads it, so nothing is masked)
|
|
131
|
+
* and a **secret leak** (the plaintext is now sitting verbatim in a log). A
|
|
132
|
+
* production action shipped exactly that for one round: an S3 secret and
|
|
133
|
+
* session token written into a worker's log by the masking call itself.
|
|
134
|
+
* This layer is the worker-side fix; the parent-side rule is that every
|
|
135
|
+
* secret a worker will hold is masked **before** the spawn, by
|
|
136
|
+
* `Secret.forChildEnv` under the real layer.
|
|
137
|
+
*
|
|
138
|
+
* The layer needs no environment and no filesystem — its `R` is `never` —
|
|
139
|
+
* so a worker composing it structurally *cannot* write a runner file.
|
|
140
|
+
* Per-member behavior:
|
|
141
|
+
*
|
|
142
|
+
* - **`setSecret` — silent no-op.** The value must not be written
|
|
143
|
+
* *anywhere*: there is no runner on this channel to hand it to, and any
|
|
144
|
+
* spelling of it in the log IS the leak. Masking is the parent's job,
|
|
145
|
+
* before the worker exists.
|
|
146
|
+
* - **`set`, `setJson`, `exportVariable`, `addPath`, `summary` — fail
|
|
147
|
+
* typed** (`reason: "detached"`, naming the file). Each writes a runner
|
|
148
|
+
* file that configures the parent job's *later steps* — `GITHUB_OUTPUT`,
|
|
149
|
+
* `GITHUB_ENV`, `GITHUB_PATH` — or is collected when the step completes
|
|
150
|
+
* (`GITHUB_STEP_SUMMARY`). A detached worker has no later steps, may
|
|
151
|
+
* outlive the job entirely, and even an inherited file path would be
|
|
152
|
+
* read at a moment unrelated to the write — so a worker calling one of
|
|
153
|
+
* these is a program error worth surfacing, not a write to degrade
|
|
154
|
+
* silently.
|
|
155
|
+
* - **`setFailed` — degrades to a plain log line** (`Console.error`,
|
|
156
|
+
* without the `::error::` syntax nothing here would parse). Unlike the
|
|
157
|
+
* file members it is log-like: "record that something failed" still
|
|
158
|
+
* means something in a worker's log, and its signature carries no error
|
|
159
|
+
* channel to fail through.
|
|
160
|
+
*/
|
|
161
|
+
static layerDetached = Layer.succeed(this, {
|
|
162
|
+
set: (name) => Effect.fail(new ActionOutputError({
|
|
163
|
+
reason: "detached",
|
|
164
|
+
file: "GITHUB_OUTPUT",
|
|
165
|
+
name
|
|
166
|
+
})),
|
|
167
|
+
setJson: (name) => Effect.fail(new ActionOutputError({
|
|
168
|
+
reason: "detached",
|
|
169
|
+
file: "GITHUB_OUTPUT",
|
|
170
|
+
name
|
|
171
|
+
})),
|
|
172
|
+
summary: () => Effect.fail(new ActionOutputError({
|
|
173
|
+
reason: "detached",
|
|
174
|
+
file: "GITHUB_STEP_SUMMARY"
|
|
175
|
+
})),
|
|
176
|
+
exportVariable: (name) => Effect.fail(new ActionOutputError({
|
|
177
|
+
reason: "detached",
|
|
178
|
+
file: "GITHUB_ENV",
|
|
179
|
+
name
|
|
180
|
+
})),
|
|
181
|
+
addPath: () => Effect.fail(new ActionOutputError({
|
|
182
|
+
reason: "detached",
|
|
183
|
+
file: "GITHUB_PATH"
|
|
184
|
+
})),
|
|
185
|
+
setFailed: (message) => Console.error(message),
|
|
186
|
+
setSecret: () => Effect.void
|
|
187
|
+
});
|
|
119
188
|
/** A test double. Unstubbed members die rather than silently succeeding. */
|
|
120
189
|
static makeTest = (overrides = {}) => ({
|
|
121
190
|
set: () => Effect.sync(() => unimplemented("set")),
|
package/ActionState.js
CHANGED
|
@@ -12,12 +12,16 @@ var ActionStateError = class extends Schema.TaggedErrorClass()("ActionStateError
|
|
|
12
12
|
/**
|
|
13
13
|
* `missing` — no value was saved under this key in an earlier phase.
|
|
14
14
|
* `malformed` — a value is there but is not JSON, or does not satisfy the
|
|
15
|
-
* schema it was read with. `
|
|
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) =>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
const save = (key, value, schema) => Effect.gen(function* () {
|
|
78
|
+
const encoded = yield* Schema.encodeUnknownEffect(schema)(value).pipe(Effect.mapError((cause) => new ActionStateError({
|
|
79
|
+
reason: "malformed",
|
|
80
|
+
key,
|
|
81
|
+
cause
|
|
82
|
+
})));
|
|
83
|
+
const { parsed, serialized } = yield* Effect.try({
|
|
84
|
+
try: () => {
|
|
85
|
+
const serialized = JSON.stringify(encoded);
|
|
86
|
+
return {
|
|
87
|
+
parsed: JSON.parse(serialized),
|
|
88
|
+
serialized
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
catch: (cause) => new ActionStateError({
|
|
92
|
+
reason: "notPlainJson",
|
|
93
|
+
key,
|
|
94
|
+
cause
|
|
95
|
+
})
|
|
96
|
+
});
|
|
97
|
+
yield* Schema.decodeUnknownEffect(schema)(parsed).pipe(Effect.mapError((cause) => new ActionStateError({
|
|
98
|
+
reason: "notPlainJson",
|
|
99
|
+
key,
|
|
100
|
+
cause
|
|
101
|
+
})));
|
|
102
|
+
yield* write(key, serialized);
|
|
103
|
+
});
|
|
77
104
|
return {
|
|
78
105
|
save,
|
|
79
106
|
getOptional: read,
|
package/Artifact.js
CHANGED
|
@@ -84,9 +84,9 @@ const ARTIFACT_VERSION = 7;
|
|
|
84
84
|
const azure = {
|
|
85
85
|
uploadFile: (url, file) => Effect.tryPromise({
|
|
86
86
|
try: () => new BlockBlobClient(url).uploadFile(file, {
|
|
87
|
-
blockSize:
|
|
87
|
+
blockSize: 67108864,
|
|
88
88
|
concurrency: 8,
|
|
89
|
-
maxSingleShotSize:
|
|
89
|
+
maxSingleShotSize: 134217728
|
|
90
90
|
}),
|
|
91
91
|
catch: (cause) => new BlobTransferError({
|
|
92
92
|
reason: "uploadFailed",
|
package/BlobStore.githubCache.js
CHANGED
|
@@ -64,7 +64,8 @@ const fromTwirp = (failure, key) => failure.kind === "status" ? new BlobStoreErr
|
|
|
64
64
|
});
|
|
65
65
|
const make = (transfer) => Effect.gen(function* () {
|
|
66
66
|
const http = yield* HttpClient.HttpClient;
|
|
67
|
-
const
|
|
67
|
+
const env = yield* ActionEnvironment;
|
|
68
|
+
const backend = resultsBackend(env).pipe(Effect.mapError((name) => new BlobStoreError({
|
|
68
69
|
reason: "misconfigured",
|
|
69
70
|
detail: `${name} is not set — the Actions results backend is only reachable from a \`uses:\` step, never from \`run:\``
|
|
70
71
|
})));
|