@effected/github-actions 0.2.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 +128 -17
- 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 +106 -8
- package/PackageManagerInstaller.js +494 -0
- package/Secret.js +21 -0
- package/ToolInstaller.js +98 -52
- package/index.d.ts +664 -124
- package/index.js +2 -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/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
|
})));
|
package/CacheKey.js
CHANGED
|
@@ -48,6 +48,20 @@ const MAX_KEY_LENGTH = 512;
|
|
|
48
48
|
const Segment = Schema.String.check(Schema.isPattern(/^[^,\n\r]+$/));
|
|
49
49
|
const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values) => values.join(SEPARATOR).length <= MAX_KEY_LENGTH, { title: `a cache key of at most ${MAX_KEY_LENGTH} characters` }));
|
|
50
50
|
/**
|
|
51
|
+
* One explicit rung: how many leading segments it keeps. The upper bound is
|
|
52
|
+
* cross-field (`segments.length - 1`) and lives on the class schema.
|
|
53
|
+
*/
|
|
54
|
+
const RestoreDepth = Schema.Number.check(Schema.isInt(), Schema.isBetween({
|
|
55
|
+
minimum: 1,
|
|
56
|
+
maximum: Number.MAX_SAFE_INTEGER
|
|
57
|
+
}));
|
|
58
|
+
/**
|
|
59
|
+
* The explicit ladder: possibly empty, because **zero rungs is a policy** —
|
|
60
|
+
* exact-match-only restores, spelled by {@link CacheKey.withoutRestoreKeys}.
|
|
61
|
+
* Only *absence* means the default every-prefix ladder.
|
|
62
|
+
*/
|
|
63
|
+
const RestoreDepths = Schema.Array(RestoreDepth);
|
|
64
|
+
/**
|
|
51
65
|
* A GitHub Actions cache key and the restore-key ladder that goes with it.
|
|
52
66
|
*
|
|
53
67
|
* @remarks
|
|
@@ -58,6 +72,12 @@ const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values)
|
|
|
58
72
|
* the primary key and its fallbacks impossible to drift apart, which is the
|
|
59
73
|
* failure that produces a cache that never hits and never says why.
|
|
60
74
|
*
|
|
75
|
+
* By default every prefix is a rung. When some segments must never be dropped
|
|
76
|
+
* — a rung without a version digest resurrects stale cross-version caches —
|
|
77
|
+
* give the key an explicit ladder policy with
|
|
78
|
+
* {@link CacheKey.withRestoreDepths}, or none at all with
|
|
79
|
+
* {@link CacheKey.withoutRestoreKeys}, instead of bypassing the typed key.
|
|
80
|
+
*
|
|
61
81
|
* @example
|
|
62
82
|
* ```ts
|
|
63
83
|
* import { CacheKey } from "@effected/github-actions";
|
|
@@ -69,9 +89,18 @@ const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values)
|
|
|
69
89
|
*
|
|
70
90
|
* @public
|
|
71
91
|
*/
|
|
72
|
-
var CacheKey = class CacheKey extends Schema.Class("CacheKey")({
|
|
73
|
-
/** The components, most general first. */
|
|
74
|
-
segments: Segments
|
|
92
|
+
var CacheKey = class CacheKey extends Schema.Class("CacheKey")(Schema.Struct({
|
|
93
|
+
/** The components, most general first. */
|
|
94
|
+
segments: Segments,
|
|
95
|
+
/**
|
|
96
|
+
* The explicit ladder policy, when one was given: how many leading
|
|
97
|
+
* segments each rung keeps, in the order the rungs are tried. Absent for
|
|
98
|
+
* the default every-prefix ladder; **empty for no ladder at all** —
|
|
99
|
+
* exact-match-only. See {@link CacheKey.withRestoreDepths} and
|
|
100
|
+
* {@link CacheKey.withoutRestoreKeys}.
|
|
101
|
+
*/
|
|
102
|
+
restoreDepths: Schema.optionalKey(RestoreDepths)
|
|
103
|
+
}).check(Schema.makeFilter((key) => key.restoreDepths === void 0 || key.restoreDepths.every((depth) => depth <= key.segments.length - 1) ? void 0 : "every restore depth must be between 1 and segments.length - 1 — a rung keeping every segment would just repeat the primary key"))) {
|
|
75
104
|
/** The primary key: every segment, joined. */
|
|
76
105
|
get key() {
|
|
77
106
|
return this.segments.join(SEPARATOR);
|
|
@@ -80,16 +109,85 @@ segments: Segments }) {
|
|
|
80
109
|
* The restore keys, most specific first.
|
|
81
110
|
*
|
|
82
111
|
* @remarks
|
|
83
|
-
*
|
|
84
|
-
* three-segment key falls back to two prefixes and a
|
|
85
|
-
* back to nothing — there is no rung that would match
|
|
86
|
-
* repository.
|
|
112
|
+
* By default each rung drops the last segment and keeps the trailing
|
|
113
|
+
* separator, so a three-segment key falls back to two prefixes and a
|
|
114
|
+
* one-segment key falls back to nothing — there is no rung that would match
|
|
115
|
+
* every cache in the repository.
|
|
116
|
+
*
|
|
117
|
+
* **The default ladder is every prefix**, which is wrong for a key whose
|
|
118
|
+
* later segments must never be dropped alone: a
|
|
119
|
+
* `platform-arch-versionHash-branchHash-lockfileHash` key derives rungs
|
|
120
|
+
* without the version digest, and those resurrect stale cross-version
|
|
121
|
+
* caches. A key built with {@link CacheKey.withRestoreDepths} carries its
|
|
122
|
+
* own ladder instead, and this getter answers exactly that ladder — so
|
|
123
|
+
* `ActionCache.restore` picks the policy up through the same typed-key
|
|
124
|
+
* path, no hand-built restore-key list required. An **empty** policy
|
|
125
|
+
* ({@link CacheKey.withoutRestoreKeys}) answers no rungs at all: only
|
|
126
|
+
* *absence* selects the default ladder, so "none" is never mistaken for
|
|
127
|
+
* "every prefix".
|
|
87
128
|
*/
|
|
88
129
|
get restoreKeys() {
|
|
130
|
+
const rung = (length) => `${this.segments.slice(0, length).join(SEPARATOR)}${SEPARATOR}`;
|
|
131
|
+
if (this.restoreDepths !== void 0) return this.restoreDepths.map(rung);
|
|
89
132
|
const rungs = [];
|
|
90
|
-
for (let length = this.segments.length - 1; length >= 1; length -= 1) rungs.push(
|
|
133
|
+
for (let length = this.segments.length - 1; length >= 1; length -= 1) rungs.push(rung(length));
|
|
91
134
|
return rungs;
|
|
92
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* A copy of this key carrying an explicit restore-key ladder policy.
|
|
138
|
+
*
|
|
139
|
+
* @remarks
|
|
140
|
+
* Each depth is the number of **leading segments** the rung keeps, and the
|
|
141
|
+
* rungs are emitted in exactly the order given — GitHub tries restore keys
|
|
142
|
+
* in order, so the order IS the policy. Descending depths (most specific
|
|
143
|
+
* first) are what a fallback ladder almost always wants; that convention is
|
|
144
|
+
* recommended rather than enforced, because rung ordering is the consumer's
|
|
145
|
+
* policy where commas, newlines and the length limit are the runner's
|
|
146
|
+
* protocol. A depth outside `1..segments.length - 1` is refused at
|
|
147
|
+
* construction: `0` would match every cache in the repository, and
|
|
148
|
+
* `segments.length` would just repeat the primary key.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
153
|
+
*
|
|
154
|
+
* const key = CacheKey.of("Linux", "X64", "v1hash", "main", "lockhash").withRestoreDepths([4, 3]);
|
|
155
|
+
* key.restoreKeys; // ["Linux-X64-v1hash-main-", "Linux-X64-v1hash-"]
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
withRestoreDepths(depths) {
|
|
159
|
+
return CacheKey.make({
|
|
160
|
+
segments: this.segments,
|
|
161
|
+
restoreDepths: depths
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A copy of this key that restores from its **exact key only** — no ladder.
|
|
166
|
+
*
|
|
167
|
+
* @remarks
|
|
168
|
+
* The third point in the policy space, and the one the typed key had no
|
|
169
|
+
* spelling for: absence of a policy means the default every-prefix ladder,
|
|
170
|
+
* so exact-match-only restores (a cache-bust mode, where a stale partial
|
|
171
|
+
* hit is worse than a cold start) previously forced the caller back to the
|
|
172
|
+
* string form. This is the same `restoreDepths` field carrying **zero
|
|
173
|
+
* rungs** — an honest value, not a sentinel: `ActionCache.restore` sends no
|
|
174
|
+
* `restore_keys` at all, so the backend answers this key or a miss.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
179
|
+
*
|
|
180
|
+
* const key = CacheKey.of("Linux", "pnpm-store", "abc123").withoutRestoreKeys();
|
|
181
|
+
* key.key; // "Linux-pnpm-store-abc123"
|
|
182
|
+
* key.restoreKeys; // []
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
withoutRestoreKeys() {
|
|
186
|
+
return CacheKey.make({
|
|
187
|
+
segments: this.segments,
|
|
188
|
+
restoreDepths: []
|
|
189
|
+
});
|
|
190
|
+
}
|
|
93
191
|
/** Build a key from its segments. */
|
|
94
192
|
static of(...segments) {
|
|
95
193
|
return CacheKey.make({ segments });
|