@effected/github-actions 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ActionCache.js +128 -17
- package/ActionInput.js +61 -8
- package/ActionLogger.js +7 -3
- package/ActionOutputs.js +71 -2
- package/ActionState.js +33 -6
- package/ActionsIdentityToken.js +61 -0
- package/ActionsProvenance.js +103 -0
- package/Artifact.js +2 -2
- package/BlobStore.githubCache.js +2 -1
- package/CacheKey.js +106 -8
- package/CheckDocument.js +193 -0
- package/CheckState.js +75 -0
- package/GitHubMarkdown.js +197 -0
- package/ManagedDocument.js +281 -0
- package/PackageManagerInstaller.js +494 -0
- package/README.md +241 -0
- package/Secret.js +34 -0
- package/ToolInstaller.js +98 -52
- package/index.d.ts +1612 -230
- package/index.js +9 -2
- package/package.json +7 -3
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/ActionLogger.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
2
|
import { WorkflowCommand } from "./WorkflowCommand.js";
|
|
3
|
-
import { Console, Context, Effect, Inspectable, Layer, LogLevel, Logger, References } from "effect";
|
|
3
|
+
import { Console, Context, Effect, Exit, Inspectable, Layer, LogLevel, Logger, References } from "effect";
|
|
4
4
|
|
|
5
5
|
//#region src/ActionLogger.ts
|
|
6
6
|
/**
|
|
@@ -91,6 +91,10 @@ const renderEntry = (level, message, annotations) => {
|
|
|
91
91
|
* @internal
|
|
92
92
|
*/
|
|
93
93
|
const ActiveBuffer = Context.Reference("@effected/github-actions/ActiveBuffer", { defaultValue: () => null });
|
|
94
|
+
/** Drop a transcript without writing it, so a green step stays quiet. */
|
|
95
|
+
const discard = (state) => Effect.sync(() => {
|
|
96
|
+
state.entries.length = 0;
|
|
97
|
+
});
|
|
94
98
|
/** Write a transcript out and clear it, so a second flush is a no-op. */
|
|
95
99
|
const flush = (state) => Effect.suspend(() => {
|
|
96
100
|
if (state.entries.length === 0) return Effect.void;
|
|
@@ -107,7 +111,7 @@ const make = Effect.gen(function* () {
|
|
|
107
111
|
const flushActive = Effect.flatMap(ActiveBuffer, (state) => state === null ? Effect.void : flush(state));
|
|
108
112
|
return {
|
|
109
113
|
group: (name, effect) => Effect.acquireUseRelease(Console.log(WorkflowCommand.group(name)), () => Effect.tapCause(effect, () => flushActive), () => Console.log(WorkflowCommand.endGroup())),
|
|
110
|
-
withBuffer: (label, effect) => Effect.gen(function* () {
|
|
114
|
+
withBuffer: (label, effect, options) => Effect.gen(function* () {
|
|
111
115
|
const minimum = yield* References.MinimumLogLevel;
|
|
112
116
|
if ((yield* env.isDebug) || LogLevel.isLessThanOrEqualTo(minimum, "Debug")) return yield* effect;
|
|
113
117
|
const state = {
|
|
@@ -122,7 +126,7 @@ const make = Effect.gen(function* () {
|
|
|
122
126
|
}
|
|
123
127
|
state.entries.push(formatMessage(options.message));
|
|
124
128
|
});
|
|
125
|
-
return yield* effect.pipe(Effect.provideService(References.MinimumLogLevel, "All"), Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([buffering])), Effect.provideService(ActiveBuffer, state), Effect.onExit(() => flush(state)));
|
|
129
|
+
return yield* effect.pipe(Effect.provideService(References.MinimumLogLevel, "All"), Effect.provideService(Logger.CurrentLoggers, /* @__PURE__ */ new Set([buffering])), Effect.provideService(ActiveBuffer, state), Effect.onExit((exit) => Exit.isSuccess(exit) && options?.onSuccess === "discard" ? discard(state) : flush(state)));
|
|
126
130
|
}),
|
|
127
131
|
notice: (message, properties = {}) => Console.log(WorkflowCommand.notice(message, properties)),
|
|
128
132
|
annotated: (properties, effect) => Effect.annotateLogs(effect, annotationRecord(properties))
|
package/ActionOutputs.js
CHANGED
|
@@ -14,13 +14,16 @@ var ActionOutputError = class extends Schema.TaggedErrorClass()("ActionOutputErr
|
|
|
14
14
|
* the code is running outside a GitHub runner. `writeFailed` — the file
|
|
15
15
|
* exists but could not be appended to. `invalidName` — the name would
|
|
16
16
|
* corrupt the file's block structure. `encodeFailed` — a value did not
|
|
17
|
-
* satisfy its schema.
|
|
17
|
+
* satisfy its schema. `detached` — the member was called under
|
|
18
|
+
* {@link ActionOutputs.layerDetached}, where the channel it writes cannot
|
|
19
|
+
* reach the job that would read it.
|
|
18
20
|
*/
|
|
19
21
|
reason: Schema.Literals([
|
|
20
22
|
"unavailable",
|
|
21
23
|
"writeFailed",
|
|
22
24
|
"invalidName",
|
|
23
|
-
"encodeFailed"
|
|
25
|
+
"encodeFailed",
|
|
26
|
+
"detached"
|
|
24
27
|
]),
|
|
25
28
|
/** Which runner file was involved, by environment variable name. */
|
|
26
29
|
file: Schema.optionalKey(Schema.String),
|
|
@@ -34,6 +37,7 @@ var ActionOutputError = class extends Schema.TaggedErrorClass()("ActionOutputErr
|
|
|
34
37
|
case "unavailable": return `Runner file "${this.file}" is not available; is this running on a GitHub runner?`;
|
|
35
38
|
case "writeFailed": return `Failed to write to runner file "${this.file}"`;
|
|
36
39
|
case "invalidName": return `"${this.name}" is not a usable output name`;
|
|
40
|
+
case "detached": return `Runner file "${this.file}" cannot be reached from a detached worker — it configures the parent job's later steps, and a worker has none and may outlive the job. Publish this from the parent process instead.`;
|
|
37
41
|
default: return `Failed to encode the value for "${this.name}"`;
|
|
38
42
|
}
|
|
39
43
|
}
|
|
@@ -116,6 +120,71 @@ const unimplemented = (member) => {
|
|
|
116
120
|
*/
|
|
117
121
|
var ActionOutputs = class ActionOutputs extends Context.Service()("@effected/github-actions/ActionOutputs") {
|
|
118
122
|
static layer = Layer.effect(this, make);
|
|
123
|
+
/**
|
|
124
|
+
* The outputs surface that is correct inside a **detached worker**.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* The masking model assumes the runner parses stdout. A detached worker's
|
|
128
|
+
* stdout is a **log file no runner parses**, so under the real layer
|
|
129
|
+
* `setSecret` emits `::add-mask::<plaintext>` into that file — a command
|
|
130
|
+
* that is simultaneously **inert** (nothing reads it, so nothing is masked)
|
|
131
|
+
* and a **secret leak** (the plaintext is now sitting verbatim in a log). A
|
|
132
|
+
* production action shipped exactly that for one round: an S3 secret and
|
|
133
|
+
* session token written into a worker's log by the masking call itself.
|
|
134
|
+
* This layer is the worker-side fix; the parent-side rule is that every
|
|
135
|
+
* secret a worker will hold is masked **before** the spawn, by
|
|
136
|
+
* `Secret.forChildEnv` under the real layer.
|
|
137
|
+
*
|
|
138
|
+
* The layer needs no environment and no filesystem — its `R` is `never` —
|
|
139
|
+
* so a worker composing it structurally *cannot* write a runner file.
|
|
140
|
+
* Per-member behavior:
|
|
141
|
+
*
|
|
142
|
+
* - **`setSecret` — silent no-op.** The value must not be written
|
|
143
|
+
* *anywhere*: there is no runner on this channel to hand it to, and any
|
|
144
|
+
* spelling of it in the log IS the leak. Masking is the parent's job,
|
|
145
|
+
* before the worker exists.
|
|
146
|
+
* - **`set`, `setJson`, `exportVariable`, `addPath`, `summary` — fail
|
|
147
|
+
* typed** (`reason: "detached"`, naming the file). Each writes a runner
|
|
148
|
+
* file that configures the parent job's *later steps* — `GITHUB_OUTPUT`,
|
|
149
|
+
* `GITHUB_ENV`, `GITHUB_PATH` — or is collected when the step completes
|
|
150
|
+
* (`GITHUB_STEP_SUMMARY`). A detached worker has no later steps, may
|
|
151
|
+
* outlive the job entirely, and even an inherited file path would be
|
|
152
|
+
* read at a moment unrelated to the write — so a worker calling one of
|
|
153
|
+
* these is a program error worth surfacing, not a write to degrade
|
|
154
|
+
* silently.
|
|
155
|
+
* - **`setFailed` — degrades to a plain log line** (`Console.error`,
|
|
156
|
+
* without the `::error::` syntax nothing here would parse). Unlike the
|
|
157
|
+
* file members it is log-like: "record that something failed" still
|
|
158
|
+
* means something in a worker's log, and its signature carries no error
|
|
159
|
+
* channel to fail through.
|
|
160
|
+
*/
|
|
161
|
+
static layerDetached = Layer.succeed(this, {
|
|
162
|
+
set: (name) => Effect.fail(new ActionOutputError({
|
|
163
|
+
reason: "detached",
|
|
164
|
+
file: "GITHUB_OUTPUT",
|
|
165
|
+
name
|
|
166
|
+
})),
|
|
167
|
+
setJson: (name) => Effect.fail(new ActionOutputError({
|
|
168
|
+
reason: "detached",
|
|
169
|
+
file: "GITHUB_OUTPUT",
|
|
170
|
+
name
|
|
171
|
+
})),
|
|
172
|
+
summary: () => Effect.fail(new ActionOutputError({
|
|
173
|
+
reason: "detached",
|
|
174
|
+
file: "GITHUB_STEP_SUMMARY"
|
|
175
|
+
})),
|
|
176
|
+
exportVariable: (name) => Effect.fail(new ActionOutputError({
|
|
177
|
+
reason: "detached",
|
|
178
|
+
file: "GITHUB_ENV",
|
|
179
|
+
name
|
|
180
|
+
})),
|
|
181
|
+
addPath: () => Effect.fail(new ActionOutputError({
|
|
182
|
+
reason: "detached",
|
|
183
|
+
file: "GITHUB_PATH"
|
|
184
|
+
})),
|
|
185
|
+
setFailed: (message) => Console.error(message),
|
|
186
|
+
setSecret: () => Effect.void
|
|
187
|
+
});
|
|
119
188
|
/** A test double. Unstubbed members die rather than silently succeeding. */
|
|
120
189
|
static makeTest = (overrides = {}) => ({
|
|
121
190
|
set: () => Effect.sync(() => unimplemented("set")),
|
package/ActionState.js
CHANGED
|
@@ -12,12 +12,16 @@ var ActionStateError = class extends Schema.TaggedErrorClass()("ActionStateError
|
|
|
12
12
|
/**
|
|
13
13
|
* `missing` — no value was saved under this key in an earlier phase.
|
|
14
14
|
* `malformed` — a value is there but is not JSON, or does not satisfy the
|
|
15
|
-
* schema it was read with. `
|
|
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,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { OidcTokenIssuer } from "./OidcTokenIssuer.js";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { IdentityToken, IdentityTokenError } from "@effected/sbom";
|
|
4
|
+
|
|
5
|
+
//#region src/ActionsIdentityToken.ts
|
|
6
|
+
/**
|
|
7
|
+
* The Actions side of `@effected/sbom`'s inverted OIDC contract.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* `@effected/sbom` declares `IdentityToken` — one method, one audience,
|
|
11
|
+
* one redacted token — precisely so that signing does not drag the Actions
|
|
12
|
+
* runtime into every consumer that only wants to emit an SBOM. This is the
|
|
13
|
+
* layer that closes the inversion from the other side: it serves that contract
|
|
14
|
+
* from the runner's own OIDC token service ({@link OidcTokenIssuer}), the same
|
|
15
|
+
* arrangement as `@effected/workspaces` implementing `@effected/commands`'
|
|
16
|
+
* `LocalExec`.
|
|
17
|
+
*
|
|
18
|
+
* The audience is forwarded verbatim. `SigstoreSigner` asks its identity
|
|
19
|
+
* contract for the `sigstore` audience itself (`SIGSTORE_OIDC_AUDIENCE` is the
|
|
20
|
+
* signing protocol's requirement, owned there), so nothing here — and nothing
|
|
21
|
+
* in a consumer — needs to spell it.
|
|
22
|
+
*
|
|
23
|
+
* An `OidcTokenError` is rewrapped as `IdentityTokenError` with the
|
|
24
|
+
* original preserved structurally in `cause`, so a refused exchange still says
|
|
25
|
+
* which audience it was minting for **and** why the runner declined —
|
|
26
|
+
* `reason: "unavailable"` almost always means the workflow is missing
|
|
27
|
+
* `permissions: id-token: write`.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { ActionsIdentityToken, OidcTokenIssuer } from "@effected/github-actions";
|
|
32
|
+
* import { SigstoreSigner } from "@effected/sbom";
|
|
33
|
+
* import { Layer } from "effect";
|
|
34
|
+
*
|
|
35
|
+
* const signing = SigstoreSigner.layer.pipe(
|
|
36
|
+
* Layer.provide(ActionsIdentityToken.layer),
|
|
37
|
+
* Layer.provide(OidcTokenIssuer.layer),
|
|
38
|
+
* );
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
var ActionsIdentityToken = class {
|
|
44
|
+
constructor() {}
|
|
45
|
+
/**
|
|
46
|
+
* `IdentityToken` served by the runner's OIDC token service.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* Requires {@link OidcTokenIssuer} rather than composing
|
|
50
|
+
* `OidcTokenIssuer.layer` in, so an action that already wired the issuer —
|
|
51
|
+
* every action using `ActionRuntime.layer` has its requirements at hand —
|
|
52
|
+
* does not construct a second one.
|
|
53
|
+
*/
|
|
54
|
+
static layer = Layer.effect(IdentityToken, Effect.map(OidcTokenIssuer, (issuer) => ({ token: (audience) => issuer.token(audience).pipe(Effect.mapError((cause) => new IdentityTokenError({
|
|
55
|
+
audience,
|
|
56
|
+
cause
|
|
57
|
+
}))) })));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
export { ActionsIdentityToken };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
|
+
import { OidcTokenIssuer } from "./OidcTokenIssuer.js";
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
import { SlsaProvenance } from "@effected/sbom";
|
|
5
|
+
|
|
6
|
+
//#region src/ActionsProvenance.ts
|
|
7
|
+
/**
|
|
8
|
+
* The server URL a workflow on github.com runs against.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* GHES runners export `GITHUB_SERVER_URL`; github.com runners do too, but a
|
|
12
|
+
* program running outside a runner — or under a partial test environment —
|
|
13
|
+
* legitimately sees it absent, and absence has a **correct answer** rather
|
|
14
|
+
* than being an error. See {@link ActionsProvenance.capture}.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_SERVER_URL = "https://github.com";
|
|
17
|
+
/**
|
|
18
|
+
* The Actions side of `@effected/sbom`'s SLSA provenance constructor.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* `SlsaProvenance.forGitHubWorkflow` is total and does the real work, but it
|
|
22
|
+
* takes a camelCase input record — and the only input anyone inside a workflow
|
|
23
|
+
* actually holds is the runner's own OIDC claims, spelled in GitHub's
|
|
24
|
+
* snake_case claim names. The rename between the two is eleven all-string
|
|
25
|
+
* fields, so transposing `repository_id` and `repository_owner_id` compiles,
|
|
26
|
+
* passes every type check, and produces a validly signed attestation asserting
|
|
27
|
+
* the **wrong** provenance. This module owns that rename once, on the same
|
|
28
|
+
* `github-actions` ↔ `sbom` seam as {@link ActionsIdentityToken}: `sbom` must
|
|
29
|
+
* not depend on the Actions runtime, so the adapter lives here, beside the
|
|
30
|
+
* services it reads.
|
|
31
|
+
*
|
|
32
|
+
* The construct ends at the predicate. Assembling the in-toto statement,
|
|
33
|
+
* signing it and uploading the bundle stay with the consumer — that pipeline
|
|
34
|
+
* is deliberately consumer glue (see `@effected/sbom`'s `SigstoreSigner` and
|
|
35
|
+
* `@effected/github`'s `Attestation.upload`).
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { ActionsProvenance } from "@effected/github-actions";
|
|
40
|
+
* import { Effect } from "effect";
|
|
41
|
+
*
|
|
42
|
+
* const program = Effect.gen(function* () {
|
|
43
|
+
* const provenance = yield* ActionsProvenance.capture();
|
|
44
|
+
* return provenance.runDetails.builder.id;
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
var ActionsProvenance = class {
|
|
51
|
+
constructor() {}
|
|
52
|
+
/**
|
|
53
|
+
* The current run's SLSA provenance, built from the runner's OIDC claims.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* Every field `GitHubWorkflowProvenance` declares is populated from
|
|
57
|
+
* {@link OidcClaims}, except `serverUrl`, which is read from
|
|
58
|
+
* `GITHUB_SERVER_URL` through {@link ActionEnvironment}'s `getOptional`
|
|
59
|
+
* with `https://github.com` as the default — **not** through the
|
|
60
|
+
* `GitHubContext` projection. The projection fails typed when a `GITHUB_*`
|
|
61
|
+
* variable is missing, and a missing server URL is not a failure: it has a
|
|
62
|
+
* correct default. GHES consumers set the variable, github.com consumers
|
|
63
|
+
* never should have to, and neither should think about it. (Upstream
|
|
64
|
+
* `@actions/attest` reads the same variable with no default and writes the
|
|
65
|
+
* literal string `undefined` into every URL it builds — the hazard
|
|
66
|
+
* `GitHubWorkflowProvenance.serverUrl` being a required field exists to
|
|
67
|
+
* prevent.)
|
|
68
|
+
*
|
|
69
|
+
* The audience is forwarded to {@link OidcTokenIssuerShape.claims}
|
|
70
|
+
* verbatim; the claims a provenance reads do not depend on it, so omitting
|
|
71
|
+
* it is fine and spends no extra token mint.
|
|
72
|
+
*
|
|
73
|
+
* An {@link OidcTokenError} passes through **untouched** — not caught, not
|
|
74
|
+
* defaulted, not wrapped. Whether attestation is mandatory or best-effort
|
|
75
|
+
* is the consumer's policy: an action that must not publish unattested
|
|
76
|
+
* lets the error propagate, and one that publishes anyway catches it at
|
|
77
|
+
* its own boundary. `reason: "unavailable"` almost always means the
|
|
78
|
+
* workflow is missing `permissions: id-token: write`.
|
|
79
|
+
*/
|
|
80
|
+
static capture = Effect.fn("ActionsProvenance.capture")(function* (audience) {
|
|
81
|
+
const issuer = yield* OidcTokenIssuer;
|
|
82
|
+
const env = yield* ActionEnvironment;
|
|
83
|
+
const claims = yield* issuer.claims(audience);
|
|
84
|
+
const serverUrl = Option.getOrElse(yield* env.getOptional("GITHUB_SERVER_URL"), () => DEFAULT_SERVER_URL);
|
|
85
|
+
return SlsaProvenance.forGitHubWorkflow({
|
|
86
|
+
serverUrl,
|
|
87
|
+
repository: claims.repository,
|
|
88
|
+
ref: claims.ref,
|
|
89
|
+
sha: claims.sha,
|
|
90
|
+
eventName: claims.event_name,
|
|
91
|
+
workflowRef: claims.workflow_ref,
|
|
92
|
+
jobWorkflowRef: claims.job_workflow_ref,
|
|
93
|
+
repositoryId: claims.repository_id,
|
|
94
|
+
repositoryOwnerId: claims.repository_owner_id,
|
|
95
|
+
runnerEnvironment: claims.runner_environment,
|
|
96
|
+
runId: claims.run_id,
|
|
97
|
+
runAttempt: claims.run_attempt
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
//#endregion
|
|
103
|
+
export { ActionsProvenance };
|