@effected/github-actions 0.13.0 → 0.13.2

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/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 core
1311
- * `FileSystem.readDirectory(recursive)`; the matching is `@effected/glob`'s
1312
- * full minimatch dialect — the same dialect `@actions/glob` uses, so a workflow
1313
- * author's `!**\/node_modules/**` behaves here exactly as it does in every
1314
- * other cache step in the same workflow. `node:fs.globSync` would have welded
1315
- * the two together behind one non-stubbable call *and* changed the dialect,
1316
- * and a dialect divergence surfaces as a silent cache-key difference — the
1317
- * worst failure mode a cache key has.
1318
- *
1319
- * Candidates are matched by their path **relative to the workspace**, which
1320
- * is what makes "never hash a file outside the workspace" structural rather
1321
- * than a rule someone has to remember. Directories are excluded: a directory
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<void, E | DetachedProcessError, R>;
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. */
@@ -4476,9 +4490,11 @@ declare const PackageManagerInstallerError_base: Schema.Class<PackageManagerInst
4476
4490
  * entry). `integrityMismatch` — the downloaded artifact does not hash to
4477
4491
  * what the pin declares; nothing was cached. `integrityMissing` — the pin
4478
4492
  * carries no integrity and the caller asked for `requireIntegrity`.
4479
- * `unsupportedPlatform` — bun publishes no build for this runner's
4480
- * OS/architecture pair. `layoutUnexpected` — the artifact extracted, but
4481
- * its contents are not shaped like the package manager it claims to be.
4493
+ * `unsupportedPlatform` — no build exists for this runner's
4494
+ * OS/architecture pair: bun publishes none, or pnpm 12 ships no
4495
+ * `@pnpm/exe.*` native binary for it. `layoutUnexpected` — the artifact
4496
+ * extracted, but its contents are not shaped like the package manager it
4497
+ * claims to be.
4482
4498
  */
4483
4499
  readonly reason: Schema.Literals<readonly ["downloadFailed", "extractFailed", "integrityMismatch", "integrityMissing", "unsupportedPlatform", "layoutUnexpected", "cacheFailed"]>;
4484
4500
  /** The package manager being installed. */
@@ -4516,10 +4532,14 @@ interface PackageManagerInstallOptions {
4516
4532
  readonly requireIntegrity?: boolean | undefined;
4517
4533
  /**
4518
4534
  * The npm registry host the npm, pnpm and yarn tarballs download from.
4519
- * Defaults to `https://registry.npmjs.org`; a corporate mirror that serves
4520
- * the standard `/<name>/-/<basename>-<version>.tgz` tarball routes works
4521
- * unchanged. bun does not ship through a registry — its per-platform zip
4522
- * always comes from GitHub releases.
4535
+ * Defaults to `https://registry.npmjs.org`. A corporate mirror qualifies
4536
+ * when it serves the standard `/<name>/-/<basename>-<version>.tgz` tarball
4537
+ * route and — for pnpm 12 and later, whose native binary is a second
4538
+ * artifact verified against the registry's own integrity — the
4539
+ * `/<name>/<version>` packument route as JSON carrying `dist.integrity`.
4540
+ * A tarball-only proxy fails a pnpm 12 pin with `downloadFailed` naming
4541
+ * that packument url. bun does not ship through a registry — its
4542
+ * per-platform zip always comes from GitHub releases.
4523
4543
  */
4524
4544
  readonly registry?: string | undefined;
4525
4545
  /**
@@ -4586,10 +4606,14 @@ declare const CachedPackageManager_base: Schema.Class<CachedPackageManager, Sche
4586
4606
  * on Windows); for bun it is the entry directory itself, because the cached
4587
4607
  * `bun` binary is directly executable and a shim would add nothing but a
4588
4608
  * fork. `bins` still maps each published bin name to the underlying entry —
4589
- * a Node script to run with `node` for npm/pnpm/yarn, the native binary for
4590
- * bun. Calling `addPath` remains deliberately the consumer's move, and note
4591
- * that `addPath` targets *subsequent* steps: a same-process probe must use
4592
- * the absolute paths in `bins` or `binDir`.
4609
+ * a Node script to run with `node` for npm, yarn and pnpm 11 and earlier; an
4610
+ * executable to run directly for bun and for pnpm 12+, whose `pnpm` is the
4611
+ * host's native binary (overlaid onto the cached wrapper the way pnpm's own
4612
+ * install script does) and whose `pn`/`pnpx`/`pnx` are shell aliases of it.
4613
+ * Each shim already applies that distinction. Calling `addPath` remains
4614
+ * deliberately the consumer's move, and note that `addPath` targets
4615
+ * *subsequent* steps: a same-process probe must use the absolute paths in
4616
+ * `bins` or `binDir`.
4593
4617
  *
4594
4618
  * @public
4595
4619
  */
@@ -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
- * Decode a JWT payload segment.
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
- * @remarks
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 payloadOf = (token) => {
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
- let payload;
48
- try {
49
- payload = payloadOf(token);
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 };
@@ -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,124 @@
1
+ import { Encoding, Option, Result } from "effect";
2
+
3
+ //#region src/internal/pnpmExe.ts
4
+ /**
5
+ * The pnpm native-binary layout: from pnpm 12 the `pnpm` registry package is a
6
+ * thin wrapper whose `pnpm` bin is a shebang-less placeholder that the
7
+ * package's own install script overwrites with the host's native executable,
8
+ * fetched as an optional dependency named `@pnpm/exe.<os>-<arch>[-musl]`.
9
+ * The installer never runs lifecycle scripts, so it performs that overlay
10
+ * itself; the pure pieces of that live here, where they are testable without
11
+ * a tarball.
12
+ *
13
+ * Detection is by LAYOUT (the manifest's `optionalDependencies` naming an
14
+ * `@pnpm/exe.*` package), never by major version — pnpm 11 and earlier ship
15
+ * no such dependency and keep their Node entry points.
16
+ *
17
+ * @internal
18
+ */
19
+ /** The prefix every native-binary package name carries. @internal */
20
+ const PNPM_EXE_PREFIX = "@pnpm/exe.";
21
+ /**
22
+ * The targets pnpm publishes a native binary for — the `PLATFORMS` table of
23
+ * the wrapper's own `native-binary.mjs`, flattened to the spellings that
24
+ * follow the prefix. Only linux x64/arm64 have a musl twin.
25
+ */
26
+ const PNPM_EXE_TARGETS = /* @__PURE__ */ new Set([
27
+ "linux-x64",
28
+ "linux-arm64",
29
+ "linux-x64-musl",
30
+ "linux-arm64-musl",
31
+ "linux-riscv64",
32
+ "linux-ppc64",
33
+ "linux-s390x",
34
+ "darwin-x64",
35
+ "darwin-arm64",
36
+ "win32-x64",
37
+ "win32-arm64",
38
+ "freebsd-x64",
39
+ "android-x64",
40
+ "android-arm64"
41
+ ]);
42
+ /** `RUNNER_OS` spellings (lower-cased) onto Node's `process.platform` spellings, which the targets use. */
43
+ const RUNNER_OS_TO_PLATFORM = {
44
+ linux: "linux",
45
+ macos: "darwin",
46
+ windows: "win32"
47
+ };
48
+ /**
49
+ * The `@pnpm/exe.*` target for a runner platform, or `None` when pnpm
50
+ * publishes no native binary for it.
51
+ *
52
+ * `musl` only distinguishes on linux; an arch released for glibc alone
53
+ * (riscv64, ppc64, s390x) answers `None` on a musl host, as upstream does —
54
+ * the glibc binary cannot run there.
55
+ *
56
+ * @internal
57
+ */
58
+ const pnpmExeTarget = (runnerOs, arch, musl) => {
59
+ const platform = RUNNER_OS_TO_PLATFORM[runnerOs.toLowerCase()];
60
+ if (platform === void 0) return Option.none();
61
+ const target = `${platform}-${arch}${platform === "linux" && musl ? "-musl" : ""}`;
62
+ return PNPM_EXE_TARGETS.has(target) ? Option.some(target) : Option.none();
63
+ };
64
+ /**
65
+ * Whether the HOST libc is musl, the way the wrapper's `native-binary.mjs`
66
+ * decides it: a glibc build of node reports `glibcVersionRuntime` in its
67
+ * diagnostic report header, a musl build does not. Only meaningful on a
68
+ * linux host; anything unprobeable counts as glibc, which is what every
69
+ * hosted runner is.
70
+ *
71
+ * `process.report` is a global, not a `node:` import, so this stays inside
72
+ * the package's closed `node:` list.
73
+ *
74
+ * @internal
75
+ */
76
+ const detectMusl = () => {
77
+ if (process.platform !== "linux") return false;
78
+ try {
79
+ const report = process.report?.getReport();
80
+ if (report === void 0 || report.header === void 0) return false;
81
+ return report.header.glibcVersionRuntime === void 0 || report.header.glibcVersionRuntime === "";
82
+ } catch {
83
+ return false;
84
+ }
85
+ };
86
+ /** The SRI algorithms a registry may list, strongest first. */
87
+ const SRI_ALGORITHMS = [
88
+ "sha512",
89
+ "sha384",
90
+ "sha256",
91
+ "sha1"
92
+ ];
93
+ /**
94
+ * The strongest entry of a Subresource Integrity string (`<algo>-<base64>`,
95
+ * several separated by whitespace), as `{ algorithm, hex }` — the spelling a
96
+ * corepack pin uses, so a mismatch reports both sides the same way. `None`
97
+ * when nothing parseable is there.
98
+ *
99
+ * @internal
100
+ */
101
+ const strongestSri = (integrity) => {
102
+ const entries = integrity.split(/\s+/).filter((entry) => entry.length > 0).flatMap((entry) => {
103
+ const dash = entry.indexOf("-");
104
+ if (dash <= 0) return [];
105
+ const algorithm = entry.slice(0, dash).toLowerCase();
106
+ if (!SRI_ALGORITHMS.includes(algorithm)) return [];
107
+ const digest = entry.slice(dash + 1).split("?")[0] ?? "";
108
+ const decoded = Encoding.decodeBase64(digest);
109
+ return Result.isSuccess(decoded) ? [{
110
+ algorithm,
111
+ hex: Encoding.encodeHex(decoded.success)
112
+ }] : [];
113
+ });
114
+ for (const algorithm of SRI_ALGORITHMS) {
115
+ const found = entries.find((entry) => entry.algorithm === algorithm);
116
+ if (found !== void 0) return Option.some(found);
117
+ }
118
+ return Option.none();
119
+ };
120
+ /** Whether a bin target is a Node script (run with `node`) rather than an executable run directly. @internal */
121
+ const isNodeScript = (target) => /\.[cm]?js$/i.test(target);
122
+
123
+ //#endregion
124
+ export { PNPM_EXE_PREFIX, detectMusl, isNodeScript, pnpmExeTarget, strongestSri };
@@ -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 { createHash, createHmac } from "node:crypto";
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 };