@effected/github-actions 0.13.0 → 0.13.1
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 +62 -106
- package/ActionEnvironment.js +25 -56
- package/ActionInput.js +8 -7
- package/ActionLogger.js +13 -15
- package/ActionOutputs.js +43 -56
- package/ActionState.js +13 -25
- package/Artifact.js +27 -47
- package/BlobEnvelope.js +3 -9
- package/BlobStore.githubCache.js +33 -54
- package/BlobStore.js +14 -21
- package/CacheKey.js +57 -32
- package/CheckDocument.js +18 -11
- package/DetachedProcess.js +11 -15
- package/DryRun.js +9 -9
- package/GitHubMarkdown.js +3 -3
- package/GitHubToken.js +1 -1
- package/ManagedDocument.js +26 -29
- package/OidcTokenIssuer.js +18 -42
- package/PackageManagerInstaller.js +33 -29
- package/ToolInstaller.js +24 -34
- package/index.d.ts +37 -23
- package/internal/actionsResults.js +10 -20
- package/internal/cacheService.js +70 -0
- package/internal/digest.js +42 -0
- package/internal/fsProbe.js +14 -0
- package/internal/jwt.js +63 -0
- package/internal/runner.js +31 -0
- package/internal/runnerFile.js +45 -0
- package/internal/sigv4.js +3 -3
- package/internal/spawn.js +18 -0
- package/internal/twirp.js +18 -1
- package/internal/unstubbed.js +28 -0
- package/package.json +3 -2
package/index.d.ts
CHANGED
|
@@ -1307,20 +1307,30 @@ export declare class CacheKey extends CacheKey_base {
|
|
|
1307
1307
|
*
|
|
1308
1308
|
* @remarks
|
|
1309
1309
|
* Discovery and matching are **two different jobs**, and separating them is
|
|
1310
|
-
* what makes this testable and correct. The walk is
|
|
1311
|
-
* `
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1310
|
+
* what makes this testable and correct. The walk is `@effected/walker`'s
|
|
1311
|
+
* `descend`, run once per include from that include's own literal prefix
|
|
1312
|
+
* (a literal include is one `stat`, never a walk) with nothing pruned
|
|
1313
|
+
* implicitly; the matching is `@effected/glob`'s full minimatch dialect —
|
|
1314
|
+
* the same dialect `@actions/glob` uses, so a workflow author's
|
|
1315
|
+
* `!**\/node_modules/**` behaves here exactly as it does in every other
|
|
1316
|
+
* cache step in the same workflow. `node:fs.globSync` would have welded the
|
|
1317
|
+
* two together behind one non-stubbable call *and* changed the dialect, and
|
|
1318
|
+
* a dialect divergence surfaces as a silent cache-key difference — the worst
|
|
1319
|
+
* failure mode a cache key has.
|
|
1320
|
+
*
|
|
1321
|
+
* Candidates are matched by their path **relative to the workspace**, and a
|
|
1322
|
+
* literal that climbs above it (`../lockfile`) is dropped, which is what
|
|
1323
|
+
* makes "never hash a file outside the workspace" structural rather than a
|
|
1324
|
+
* rule someone has to remember. Directories are excluded: a directory
|
|
1322
1325
|
* called `notes.txt` matches `**\/*.txt` and is not a file, and hashing it
|
|
1323
|
-
* would fail rather than being ignored.
|
|
1326
|
+
* would fail rather than being ignored. An absent literal is a miss; any
|
|
1327
|
+
* other failure to read one is a typed `CacheKeyReadError`, because a key
|
|
1328
|
+
* derived from an incomplete file set is wrong in a way nothing reports.
|
|
1329
|
+
*
|
|
1330
|
+
* **One knowing divergence from the runner's `hashFiles()`:** `descend`
|
|
1331
|
+
* never enters a symlinked directory (cycle safety), where `@actions/glob`
|
|
1332
|
+
* follows links by default. A file reachable only through a symlinked
|
|
1333
|
+
* directory does not contribute to the key.
|
|
1324
1334
|
*
|
|
1325
1335
|
* The answer is sorted, so a caller cannot make its key depend on the order
|
|
1326
1336
|
* the filesystem happened to report.
|
|
@@ -3428,7 +3438,7 @@ export declare class DetachedProcess {
|
|
|
3428
3438
|
* yet, and silently retrying the first is how a misconfigured probe becomes a
|
|
3429
3439
|
* timeout six seconds later with nothing to show for it.
|
|
3430
3440
|
*/
|
|
3431
|
-
static readonly awaitReady: <E, R>(probe: Effect.Effect<boolean, E, R>, options?: ReadinessOptions | undefined) => Effect.Effect<
|
|
3441
|
+
static readonly awaitReady: <E, R>(probe: Effect.Effect<boolean, E, R>, options?: ReadinessOptions | undefined) => Effect.Effect<undefined, E | DetachedNotReadyError, R>;
|
|
3432
3442
|
/**
|
|
3433
3443
|
* A readiness probe over HTTP: `true` when a `GET` answers 2xx.
|
|
3434
3444
|
*
|
|
@@ -3550,15 +3560,6 @@ declare const DryRun_base: Context.ServiceClass<DryRun, "@effected/github-action
|
|
|
3550
3560
|
* @public
|
|
3551
3561
|
*/
|
|
3552
3562
|
export declare class DryRun extends DryRun_base {
|
|
3553
|
-
/**
|
|
3554
|
-
* Driven by the `dry-run` action input, defaulting to a real run.
|
|
3555
|
-
*
|
|
3556
|
-
* @remarks
|
|
3557
|
-
* Fails with a `ConfigError` when the input is present but is not a YAML 1.2
|
|
3558
|
-
* core-schema boolean — a workflow that writes `dry-run: yes` should stop,
|
|
3559
|
-
* not quietly perform the mutations it meant to rehearse.
|
|
3560
|
-
*/
|
|
3561
|
-
static readonly layer: Layer.Layer<DryRun, Config.ConfigError>;
|
|
3562
3563
|
/**
|
|
3563
3564
|
* Driven by a named input.
|
|
3564
3565
|
*
|
|
@@ -3568,6 +3569,15 @@ export declare class DryRun extends DryRun_base {
|
|
|
3568
3569
|
* composition site.
|
|
3569
3570
|
*/
|
|
3570
3571
|
static readonly layerFromInput: (name: string) => Layer.Layer<DryRun, Config.ConfigError>;
|
|
3572
|
+
/**
|
|
3573
|
+
* Driven by the `dry-run` action input, defaulting to a real run.
|
|
3574
|
+
*
|
|
3575
|
+
* @remarks
|
|
3576
|
+
* Fails with a `ConfigError` when the input is present but is not a YAML 1.2
|
|
3577
|
+
* core-schema boolean — a workflow that writes `dry-run: yes` should stop,
|
|
3578
|
+
* not quietly perform the mutations it meant to rehearse.
|
|
3579
|
+
*/
|
|
3580
|
+
static readonly layer: Layer.Layer<DryRun, Config.ConfigError>;
|
|
3571
3581
|
/** Driven by an explicit decision the caller has already made. */
|
|
3572
3582
|
static readonly layerFrom: (enabled: boolean) => Layer.Layer<DryRun>;
|
|
3573
3583
|
/**
|
|
@@ -4130,6 +4140,7 @@ declare const ManagedDocument_base: Schema.Class<ManagedDocument, Schema.Struct<
|
|
|
4130
4140
|
* @public
|
|
4131
4141
|
*/
|
|
4132
4142
|
export declare class ManagedDocument extends ManagedDocument_base {
|
|
4143
|
+
#private;
|
|
4133
4144
|
/**
|
|
4134
4145
|
* Read a document out of existing text, or begin a fresh one.
|
|
4135
4146
|
*
|
|
@@ -4216,6 +4227,9 @@ export declare class ManagedDocument extends ManagedDocument_base {
|
|
|
4216
4227
|
* synchronous callers can use that variant directly.
|
|
4217
4228
|
*/
|
|
4218
4229
|
withRegions(entries: ReadonlyArray<readonly [key: string, content: string, meta?: Readonly<Record<string, string>>]>): Effect.Effect<ManagedDocument, ManagedDocumentError>;
|
|
4230
|
+
private scan;
|
|
4231
|
+
/** What every wire key of this document's regions begins with. */
|
|
4232
|
+
private get wirePrefix();
|
|
4219
4233
|
/** The marker key a region is written under: `namespace.key.region`. */
|
|
4220
4234
|
private wireKey;
|
|
4221
4235
|
/** The region key back out of a wire key, for error reporting. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { payloadOf } from "./jwt.js";
|
|
1
2
|
import { Effect, Option, Redacted, Result } from "effect";
|
|
2
3
|
|
|
3
4
|
//#region src/internal/actionsResults.ts
|
|
@@ -18,20 +19,13 @@ const RESULTS_URL = "ACTIONS_RESULTS_URL";
|
|
|
18
19
|
/** The variable holding the run-scoped credential. Absent outside a `uses:` step. */
|
|
19
20
|
const RUNTIME_TOKEN = "ACTIONS_RUNTIME_TOKEN";
|
|
20
21
|
/**
|
|
21
|
-
*
|
|
22
|
+
* The `misconfigured` detail every results-backend consumer reports when
|
|
23
|
+
* {@link resultsBackend} fails: names the missing variable, and says why it is
|
|
24
|
+
* missing — the single most common misuse of these services.
|
|
22
25
|
*
|
|
23
|
-
* @
|
|
24
|
-
* No signature verification, deliberately and for the same reason
|
|
25
|
-
* `OidcTokenIssuer` gives: the token was handed to this process by the runner
|
|
26
|
-
* that started it, and the claim read here scopes a request rather than
|
|
27
|
-
* authorizing one.
|
|
26
|
+
* @internal
|
|
28
27
|
*/
|
|
29
|
-
const
|
|
30
|
-
const segments = token.split(".");
|
|
31
|
-
if (segments.length !== 3) throw new Error(`expected a three-segment JWT, got ${segments.length}`);
|
|
32
|
-
const json = Buffer.from(segments[1] ?? "", "base64url").toString("utf8");
|
|
33
|
-
return JSON.parse(json);
|
|
34
|
-
};
|
|
28
|
+
const misconfiguredDetail = (variable, service) => `${variable} is not set — the ${service} is only reachable from a \`uses:\` step, never from \`run:\``;
|
|
35
29
|
/**
|
|
36
30
|
* The run and job ids the artifact protocol needs, from the runtime token's
|
|
37
31
|
* `scp` claim.
|
|
@@ -44,13 +38,9 @@ const payloadOf = (token) => {
|
|
|
44
38
|
* @internal
|
|
45
39
|
*/
|
|
46
40
|
const backendIdsFrom = (token) => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} catch (cause) {
|
|
51
|
-
return Result.fail(`the runtime token is not a readable JWT: ${cause instanceof Error ? cause.message : cause}`);
|
|
52
|
-
}
|
|
53
|
-
const scope = payload.scp;
|
|
41
|
+
const payload = payloadOf(token);
|
|
42
|
+
if (Result.isFailure(payload)) return Result.fail(`the runtime token is not a readable JWT: ${payload.failure.detail}`);
|
|
43
|
+
const scope = payload.success?.scp;
|
|
54
44
|
if (typeof scope !== "string") return Result.fail("the runtime token carries no `scp` claim");
|
|
55
45
|
for (const entry of scope.split(" ")) {
|
|
56
46
|
const parts = entry.split(":");
|
|
@@ -95,4 +85,4 @@ const resultsBackend = (env) => Effect.gen(function* () {
|
|
|
95
85
|
});
|
|
96
86
|
|
|
97
87
|
//#endregion
|
|
98
|
-
export { RESULTS_URL, RUNTIME_TOKEN, backendIdsFrom, resultsBackend };
|
|
88
|
+
export { RESULTS_URL, RUNTIME_TOKEN, backendIdsFrom, misconfiguredDetail, resultsBackend };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { CONFLICT, isOk, stringField } from "./twirp.js";
|
|
2
|
+
import { Effect, Option } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/cacheService.ts
|
|
5
|
+
/**
|
|
6
|
+
* The Actions `CacheService` choreography — reserve, upload, finalize; look up
|
|
7
|
+
* — spelled once for the two services that speak it.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* `ActionCache` files a tar archive under a path-derived version and
|
|
11
|
+
* `BlobStore.githubCache` files an envelope under a constant one, but the
|
|
12
|
+
* three RPCs between them and their conflict semantics are identical. Each
|
|
13
|
+
* caller supplies its own per-key `call` and its own `refused` constructor, so
|
|
14
|
+
* the errors stay the caller's; this module owns only the protocol. It reaches
|
|
15
|
+
* `effect` and the Twirp client and nothing heavier — the Azure transfer of
|
|
16
|
+
* the bytes between the two RPCs stays in the three modules licensed to import
|
|
17
|
+
* it (`__test__/reachability.test.ts`).
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
/** The Twirp service the Actions cache protocol lives under. @internal */
|
|
22
|
+
const CACHE_SERVICE = "github.actions.results.api.v1.CacheService";
|
|
23
|
+
/**
|
|
24
|
+
* Reserve `(key, version)` for an upload. `Option.none()` means another job
|
|
25
|
+
* saved this key first — entries are immutable, so the cache already holds
|
|
26
|
+
* what the caller wanted and there is nothing left to do.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
const reserveUpload = (client, key, version) => Effect.flatMap(client.call("CreateCacheEntry", {
|
|
31
|
+
key,
|
|
32
|
+
version
|
|
33
|
+
}), (created) => {
|
|
34
|
+
if (created === CONFLICT) return Effect.succeedNone;
|
|
35
|
+
const url = stringField(created, "signedUploadUrl");
|
|
36
|
+
return !isOk(created) || url === void 0 ? Effect.fail(client.refused("CreateCacheEntry returned no upload url")) : Effect.succeedSome(url);
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* Confirm an upload. Not folded in with the reservation: an unfinalized upload
|
|
40
|
+
* leaves bytes in Azure that no lookup can reach — a silent no-op that reads
|
|
41
|
+
* as a successful save — so it is a failure even though every byte arrived.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
const finalizeUpload = (client, key, version, sizeBytes) => Effect.flatMap(client.call("FinalizeCacheEntryUpload", {
|
|
46
|
+
key,
|
|
47
|
+
version,
|
|
48
|
+
size_bytes: String(sizeBytes)
|
|
49
|
+
}), (finalized) => finalized === CONFLICT || !isOk(finalized) ? Effect.fail(client.refused("FinalizeCacheEntryUpload did not confirm the upload")) : Effect.void);
|
|
50
|
+
/**
|
|
51
|
+
* The signed download url for a key, walking the restore-key ladder.
|
|
52
|
+
* `Option.none()` is a miss, not a failure — a cold cache is the normal state.
|
|
53
|
+
*
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
const lookupDownload = (client, key, restoreKeys, version) => Effect.map(client.call("GetCacheEntryDownloadURL", {
|
|
57
|
+
key,
|
|
58
|
+
restore_keys: [...restoreKeys],
|
|
59
|
+
version
|
|
60
|
+
}), (found) => {
|
|
61
|
+
if (found === CONFLICT || !isOk(found)) return Option.none();
|
|
62
|
+
const url = stringField(found, "signedDownloadUrl");
|
|
63
|
+
return url === void 0 ? Option.none() : Option.some({
|
|
64
|
+
url,
|
|
65
|
+
matchedKey: stringField(found, "matchedKey")
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
export { CACHE_SERVICE, finalizeUpload, lookupDownload, reserveUpload };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Effect, Encoding, Stream } from "effect";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/digest.ts
|
|
5
|
+
/**
|
|
6
|
+
* The sanctioned `node:crypto` digests, spelled once.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Core `Crypto` is RNG-only — no digest, no HMAC — so this package's licence
|
|
10
|
+
* for a `node:` import covers hashing (`CLAUDE.md`). Keeping every `createHash`
|
|
11
|
+
* behind this module is what makes that licence auditable in one place rather
|
|
12
|
+
* than six, and keeps the streamed file digest — the shape a multi-gigabyte
|
|
13
|
+
* toolchain archive needs — from being re-derived per caller.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
/** The raw SHA-256 of a string or byte array held in memory. @internal */
|
|
18
|
+
const sha256 = (value) => new Uint8Array(createHash("sha256").update(value).digest());
|
|
19
|
+
/** The hex SHA-256 of a string or byte array held in memory. @internal */
|
|
20
|
+
const sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
|
|
21
|
+
/**
|
|
22
|
+
* The raw digest of a file's bytes under `algorithm`, streamed rather than
|
|
23
|
+
* buffered. Raw, because a caller that folds several file digests into one
|
|
24
|
+
* (`CacheKey.hashFiles`, byte-compatible with the runner's `hashFiles()`)
|
|
25
|
+
* hashes the digest BYTES, not their hex spelling.
|
|
26
|
+
*
|
|
27
|
+
* `algorithm` must be one `node:crypto` supports — every caller's grammar
|
|
28
|
+
* admits only `sha1`/`sha256`/`sha384`/`sha512`, so `createHash` cannot throw.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
const digestFile = (fs, file, algorithm) => Effect.suspend(() => {
|
|
33
|
+
const accumulator = createHash(algorithm);
|
|
34
|
+
return Stream.runForEach(fs.stream(file), (chunk) => Effect.sync(() => {
|
|
35
|
+
accumulator.update(chunk);
|
|
36
|
+
})).pipe(Effect.map(() => new Uint8Array(accumulator.digest())));
|
|
37
|
+
});
|
|
38
|
+
/** {@link digestFile}, as hex. @internal */
|
|
39
|
+
const digestFileHex = (fs, file, algorithm) => Effect.map(digestFile(fs, file, algorithm), Encoding.encodeHex);
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
export { digestFile, digestFileHex, sha256, sha256Hex };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/fsProbe.ts
|
|
4
|
+
/**
|
|
5
|
+
* The entry's type, or `undefined` when nothing readable is there. Absence
|
|
6
|
+
* and unreadability collapse together on purpose: every caller's next move
|
|
7
|
+
* (reinstall, write the shim, fail `layoutUnexpected`) is the same for both.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
const typeAt = (fs, path) => Effect.map(Effect.option(fs.stat(path)), (info) => info._tag === "Some" ? info.value.type : void 0);
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
export { typeAt };
|
package/internal/jwt.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Encoding, Result } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/jwt.ts
|
|
4
|
+
/**
|
|
5
|
+
* The JWT segment codec — read a payload, or frame an unsigned token — for the
|
|
6
|
+
* two places this package reads a runner-issued token's claims.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* **No signature verification, deliberately.** Every token read here was
|
|
10
|
+
* handed to this process by the runner that started it, and the claim read
|
|
11
|
+
* scopes a request (`actionsResults`) or is republished for a verifier
|
|
12
|
+
* elsewhere (`OidcTokenIssuer`); neither authorizes anything on the strength
|
|
13
|
+
* of the claim alone. Do not "fix" it here.
|
|
14
|
+
*
|
|
15
|
+
* Core `Encoding` does the base64url work, so no `Buffer` is involved: it is
|
|
16
|
+
* strict about the alphabet where Node's decoder is forgiving, which is why
|
|
17
|
+
* there is a test whose payload actually contains `-` and `_`.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The decoded payload of `token` — the middle segment, base64url JSON —
|
|
23
|
+
* without any check on the signature.
|
|
24
|
+
*
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
const payloadOf = (token) => {
|
|
28
|
+
const segments = token.split(".");
|
|
29
|
+
const payload = segments[1];
|
|
30
|
+
if (segments.length !== 3 || payload === void 0 || payload === "") return Result.fail({
|
|
31
|
+
kind: "segments",
|
|
32
|
+
detail: `expected three segments, got ${segments.length}`
|
|
33
|
+
});
|
|
34
|
+
const json = Encoding.decodeBase64UrlString(payload);
|
|
35
|
+
if (Result.isFailure(json)) return Result.fail({
|
|
36
|
+
kind: "payload",
|
|
37
|
+
detail: "the payload is not base64url JSON",
|
|
38
|
+
cause: json.failure
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
return Result.succeed(JSON.parse(json.success));
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
return Result.fail({
|
|
44
|
+
kind: "payload",
|
|
45
|
+
detail: "the payload is not base64url JSON",
|
|
46
|
+
cause
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* An **unsigned** JWT: `header.payload.unsigned`, each JSON segment
|
|
52
|
+
* base64url-encoded. For building test doubles and nothing else — the
|
|
53
|
+
* signature segment is a placeholder, so this token fails any verifier.
|
|
54
|
+
*
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
const unsignedJwt = (header, payload) => {
|
|
58
|
+
const segment = (value) => Encoding.encodeBase64Url(JSON.stringify(value));
|
|
59
|
+
return `${segment(header)}.${segment(payload)}.unsigned`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
export { payloadOf, unsignedJwt };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/runner.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether `RUNNER_OS` says Windows. `false` when unset — off a runner, the
|
|
6
|
+
* POSIX shape is the honest default.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
const isWindowsRunner = (env) => Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.match(found, {
|
|
11
|
+
onNone: () => false,
|
|
12
|
+
onSome: (os) => os.toLowerCase() === "windows"
|
|
13
|
+
}));
|
|
14
|
+
/**
|
|
15
|
+
* The tool-cache root: `RUNNER_TOOL_CACHE`, or `/tmp/runner-tool-cache` off a
|
|
16
|
+
* runner.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Resolved at layer construction, never at import: the source package read
|
|
20
|
+
* the variable into a module-level constant, which fixed the root before any
|
|
21
|
+
* layer could say otherwise and made it impossible to point a test elsewhere.
|
|
22
|
+
* `PackageManagerInstaller` resolves the same root because the shims it
|
|
23
|
+
* writes into a staged entry must name the FINAL cache path; sharing the
|
|
24
|
+
* resolution is what keeps its post-swap equality check a formality.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
const toolCacheRoot = (env, path) => Effect.map(env.getOptional("RUNNER_TOOL_CACHE"), (found) => Option.getOrElse(found, () => path.join("/tmp", "runner-tool-cache")));
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { isWindowsRunner, toolCacheRoot };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/internal/runnerFile.ts
|
|
2
|
+
/**
|
|
3
|
+
* The runner-file heredoc protocol — `GITHUB_OUTPUT`, `GITHUB_ENV` and
|
|
4
|
+
* `GITHUB_STATE` all take `name<<DELIM\nvalue\nDELIM\n` — spelled once for
|
|
5
|
+
* `ActionOutputs` and `ActionState`.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* **Delimiters are derived, never random.** GitHub's own toolkit uses a
|
|
9
|
+
* random UUID here and accepts the (tiny) chance of collision. Deriving the
|
|
10
|
+
* delimiter instead makes collision **impossible** rather than improbable,
|
|
11
|
+
* needs no randomness — so no `Crypto` in `R` and no unreproducible output —
|
|
12
|
+
* and is trivially testable. A value that contains a delimiter would
|
|
13
|
+
* terminate its block early and corrupt every entry after it, which is a
|
|
14
|
+
* value-controlled injection into the runner's own file.
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
/** The base delimiter for a heredoc block. */
|
|
19
|
+
const BASE_DELIMITER = "EFFECTED_EOF";
|
|
20
|
+
/** A delimiter guaranteed absent from `value`. @internal */
|
|
21
|
+
const delimiterFor = (value) => {
|
|
22
|
+
let delimiter = BASE_DELIMITER;
|
|
23
|
+
while (value.includes(delimiter)) delimiter = `${delimiter}_`;
|
|
24
|
+
return delimiter;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Whether `name` can head a block without breaking the structure it lives
|
|
28
|
+
* in: non-empty, and free of the line breaks that would end it early.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
const isUsableName = (name) => name !== "" && !/[\r\n]/.test(name);
|
|
33
|
+
/**
|
|
34
|
+
* One heredoc block, ready to append. The caller has checked the name with
|
|
35
|
+
* {@link isUsableName}.
|
|
36
|
+
*
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
const heredocBlock = (name, value) => {
|
|
40
|
+
const delimiter = delimiterFor(value);
|
|
41
|
+
return `${name}<<${delimiter}\n${value}\n${delimiter}\n`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
export { delimiterFor, heredocBlock, isUsableName };
|
package/internal/sigv4.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sha256Hex } from "./digest.js";
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
//#region src/internal/sigv4.ts
|
|
4
5
|
/**
|
|
@@ -18,7 +19,6 @@ import { createHash, createHmac } from "node:crypto";
|
|
|
18
19
|
* @internal
|
|
19
20
|
*/
|
|
20
21
|
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
21
|
-
const sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
|
|
22
22
|
const hmac = (key, value) => new Uint8Array(createHmac("sha256", key).update(value).digest());
|
|
23
23
|
/**
|
|
24
24
|
* Percent-encode one path segment the way SigV4 requires.
|
|
@@ -114,4 +114,4 @@ const sign = (request, credentials) => {
|
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
//#endregion
|
|
117
|
-
export { canonicalize, sign, signingKey, uriEncode };
|
|
117
|
+
export { canonicalize, sha256Hex as digestHex, sign, signingKey, uriEncode };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/spawn.ts
|
|
4
|
+
/**
|
|
5
|
+
* Spawn `command` once, drain `stdout`+`stderr`, then read the exit code from
|
|
6
|
+
* the same handle. Never fails on a non-zero exit — that is the caller's
|
|
7
|
+
* policy.
|
|
8
|
+
*/
|
|
9
|
+
const spawnOnce = (spawner, command) => Effect.scoped(Effect.gen(function* () {
|
|
10
|
+
const handle = yield* spawner.spawn(command);
|
|
11
|
+
return {
|
|
12
|
+
output: yield* Stream.mkString(Stream.decodeText(handle.all)),
|
|
13
|
+
code: yield* handle.exitCode
|
|
14
|
+
};
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { spawnOnce };
|
package/internal/twirp.js
CHANGED
|
@@ -14,6 +14,23 @@ import { HttpClientRequest } from "effect/unstable/http";
|
|
|
14
14
|
*/
|
|
15
15
|
const CONFLICT = Symbol.for("@effected/github-actions/twirp/conflict");
|
|
16
16
|
/**
|
|
17
|
+
* The error fields a {@link TwirpFailure} contributes to a caller's own error
|
|
18
|
+
* class — the cache, artifact and blob-store errors all carry `reason`,
|
|
19
|
+
* `status`, `detail` and `cause` under the same names, so each spreads this
|
|
20
|
+
* and adds its own identifier.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
const twirpFailureFields = (failure) => failure.kind === "status" ? {
|
|
25
|
+
reason: "refused",
|
|
26
|
+
detail: failure.method,
|
|
27
|
+
...failure.status === void 0 ? {} : { status: failure.status }
|
|
28
|
+
} : {
|
|
29
|
+
reason: "unreachable",
|
|
30
|
+
detail: `${failure.method} did not answer with a Twirp body`,
|
|
31
|
+
...failure.cause === void 0 ? {} : { cause: failure.cause }
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
17
34
|
* Whether retrying could plausibly help.
|
|
18
35
|
*
|
|
19
36
|
* @remarks
|
|
@@ -94,4 +111,4 @@ const stringField = (body, name) => {
|
|
|
94
111
|
const isOk = (body) => field(body, "ok") === true;
|
|
95
112
|
|
|
96
113
|
//#endregion
|
|
97
|
-
export { CONFLICT, field, isOk, isRetryable, stringField, twirpCall };
|
|
114
|
+
export { CONFLICT, field, isOk, isRetryable, stringField, twirpCall, twirpFailureFields };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/unstubbed.ts
|
|
4
|
+
/**
|
|
5
|
+
* The one way an unstubbed test-double member dies, for every `makeTest` in
|
|
6
|
+
* this package.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* A double's unstubbed member must die rather than silently succeed — a stub
|
|
10
|
+
* that returns nothing teaches a test that nothing happened. The message names
|
|
11
|
+
* the double, the member and the override that fixes it, and it is spelled
|
|
12
|
+
* here once so a wording change is one edit rather than ten.
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The die-on-call members of `<double>.makeTest`: `dies("save")` is an
|
|
18
|
+
* `Effect` that defects naming `save` when run. Each package double binds its
|
|
19
|
+
* own name once, then lists its members.
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
const unstubbed = (double) => (member) => Effect.sync(() => {
|
|
24
|
+
throw new Error(`${double}: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
export { unstubbed };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/github-actions",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The GitHub Actions runtime: env, inputs, outputs, state, logging, cache, artifacts, blob storage, OIDC and the token bridge.",
|
|
6
6
|
"keywords": [
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"@effected/markdown": "^0.10.0",
|
|
45
45
|
"@effected/npm": "^0.14.0",
|
|
46
46
|
"@effected/sbom": "^0.6.1",
|
|
47
|
-
"@effected/templates": "^0.6.0"
|
|
47
|
+
"@effected/templates": "^0.6.0",
|
|
48
|
+
"@effected/walker": "^0.9.0"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
51
|
"@effect/platform-node": "4.0.0-rc.115",
|