@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/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 +207 -52
- package/ToolInstaller.js +24 -34
- package/index.d.ts +58 -34
- 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/pnpmExe.js +124 -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
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
|
+
import { unstubbed } from "./internal/unstubbed.js";
|
|
3
|
+
import { digestFileHex } from "./internal/digest.js";
|
|
4
|
+
import { isWindowsRunner, toolCacheRoot } from "./internal/runner.js";
|
|
5
|
+
import { typeAt } from "./internal/fsProbe.js";
|
|
6
|
+
import { PNPM_EXE_PREFIX, detectMusl, isNodeScript, pnpmExeTarget, strongestSri } from "./internal/pnpmExe.js";
|
|
2
7
|
import { ToolInstaller } from "./ToolInstaller.js";
|
|
3
|
-
import { Context, Effect, FileSystem, Layer, Option, Path, Schema
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
8
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
5
9
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
6
|
-
import { PackageManagerPinName } from "@effected/npm";
|
|
10
|
+
import { DEFAULT_REGISTRY, PackageManagerPinName } from "@effected/npm";
|
|
7
11
|
|
|
8
12
|
//#region src/PackageManagerInstaller.ts
|
|
9
13
|
/**
|
|
@@ -19,9 +23,11 @@ var PackageManagerInstallerError = class extends Schema.TaggedError()("PackageMa
|
|
|
19
23
|
* entry). `integrityMismatch` — the downloaded artifact does not hash to
|
|
20
24
|
* what the pin declares; nothing was cached. `integrityMissing` — the pin
|
|
21
25
|
* carries no integrity and the caller asked for `requireIntegrity`.
|
|
22
|
-
* `unsupportedPlatform` —
|
|
23
|
-
* OS/architecture pair
|
|
24
|
-
*
|
|
26
|
+
* `unsupportedPlatform` — no build exists for this runner's
|
|
27
|
+
* OS/architecture pair: bun publishes none, or pnpm 12 ships no
|
|
28
|
+
* `@pnpm/exe.*` native binary for it. `layoutUnexpected` — the artifact
|
|
29
|
+
* extracted, but its contents are not shaped like the package manager it
|
|
30
|
+
* claims to be.
|
|
25
31
|
*/
|
|
26
32
|
reason: Schema.Literals([
|
|
27
33
|
"downloadFailed",
|
|
@@ -90,10 +96,14 @@ var AmbientPackageManager = class extends Schema.Class("AmbientPackageManager")(
|
|
|
90
96
|
* on Windows); for bun it is the entry directory itself, because the cached
|
|
91
97
|
* `bun` binary is directly executable and a shim would add nothing but a
|
|
92
98
|
* fork. `bins` still maps each published bin name to the underlying entry —
|
|
93
|
-
* a Node script to run with `node` for npm
|
|
94
|
-
* bun
|
|
95
|
-
*
|
|
96
|
-
*
|
|
99
|
+
* a Node script to run with `node` for npm, yarn and pnpm 11 and earlier; an
|
|
100
|
+
* executable to run directly for bun and for pnpm 12+, whose `pnpm` is the
|
|
101
|
+
* host's native binary (overlaid onto the cached wrapper the way pnpm's own
|
|
102
|
+
* install script does) and whose `pn`/`pnpx`/`pnx` are shell aliases of it.
|
|
103
|
+
* Each shim already applies that distinction. Calling `addPath` remains
|
|
104
|
+
* deliberately the consumer's move, and note that `addPath` targets
|
|
105
|
+
* *subsequent* steps: a same-process probe must use the absolute paths in
|
|
106
|
+
* `bins` or `binDir`.
|
|
97
107
|
*
|
|
98
108
|
* @public
|
|
99
109
|
*/
|
|
@@ -130,16 +140,26 @@ var CachedPackageManager = class extends Schema.Class("CachedPackageManager")({
|
|
|
130
140
|
* @public
|
|
131
141
|
*/
|
|
132
142
|
const InstalledPackageManager = Schema.Union([AmbientPackageManager, CachedPackageManager]);
|
|
133
|
-
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
134
143
|
/** The shim directory name inside a cached npm-registry-manager entry. */
|
|
135
144
|
const SHIM_DIR = ".bin";
|
|
136
145
|
/**
|
|
137
|
-
* The POSIX shim body: an `exec` wrapper so the shim's process *becomes*
|
|
138
|
-
*
|
|
146
|
+
* The POSIX shim body: an `exec` wrapper so the shim's process *becomes* the
|
|
147
|
+
* target, and `"$@"` so arguments survive quoting intact. A Node script
|
|
148
|
+
* (`.js`/`.mjs`/`.cjs`) runs under `node`; anything else — pnpm 12's native
|
|
149
|
+
* binary, its `#!/bin/sh` alias scripts — is exec'd directly, because handing
|
|
150
|
+
* a shell script or a Mach-O to node is exactly the "Invalid or unexpected
|
|
151
|
+
* token" failure this rule exists to prevent.
|
|
139
152
|
*/
|
|
140
|
-
const posixShim = (target) => `#!/bin/sh\nexec node "${target}" "$@"\n`;
|
|
141
|
-
/** The Windows shim body, CRLF-terminated as cmd expects. */
|
|
142
|
-
const cmdShim = (target) => `@echo off\r\nnode "${target}" %*\r\n`;
|
|
153
|
+
const posixShim = (target) => isNodeScript(target) ? `#!/bin/sh\nexec node "${target}" "$@"\n` : `#!/bin/sh\nexec "${target}" "$@"\n`;
|
|
154
|
+
/** The Windows shim body, CRLF-terminated as cmd expects; same node-vs-direct rule. */
|
|
155
|
+
const cmdShim = (target) => isNodeScript(target) ? `@echo off\r\nnode "${target}" %*\r\n` : `@echo off\r\n"${target}" %*\r\n`;
|
|
156
|
+
/** The bin names pnpm's wrapper publishes, every one of which becomes the native binary on Windows. */
|
|
157
|
+
const PNPM_NATIVE_BIN_NAMES = [
|
|
158
|
+
"pnpm",
|
|
159
|
+
"pn",
|
|
160
|
+
"pnpx",
|
|
161
|
+
"pnx"
|
|
162
|
+
];
|
|
143
163
|
/**
|
|
144
164
|
* The bun release asset name for a runner platform, or `None` when bun
|
|
145
165
|
* publishes no build for it.
|
|
@@ -149,9 +169,18 @@ const cmdShim = (target) => `@echo off\r\nnode "${target}" %*\r\n`;
|
|
|
149
169
|
* bun's spelling (`macOS` → `darwin`) and the Node arch spelling to bun's
|
|
150
170
|
* (`arm64` → `aarch64`).
|
|
151
171
|
*/
|
|
172
|
+
const BUN_OS = {
|
|
173
|
+
linux: "linux",
|
|
174
|
+
macos: "darwin",
|
|
175
|
+
windows: "windows"
|
|
176
|
+
};
|
|
177
|
+
const BUN_CPU = {
|
|
178
|
+
x64: "x64",
|
|
179
|
+
arm64: "aarch64"
|
|
180
|
+
};
|
|
152
181
|
const bunTarget = (runnerOs, arch) => {
|
|
153
|
-
const os = runnerOs.toLowerCase()
|
|
154
|
-
const cpu = arch
|
|
182
|
+
const os = BUN_OS[runnerOs.toLowerCase()];
|
|
183
|
+
const cpu = BUN_CPU[arch];
|
|
155
184
|
return os === void 0 || cpu === void 0 ? Option.none() : Option.some(`bun-${os}-${cpu}`);
|
|
156
185
|
};
|
|
157
186
|
/**
|
|
@@ -174,7 +203,10 @@ const normalizeBins = (bin, fallbackName) => {
|
|
|
174
203
|
return Object.keys(entries).length === 0 ? Option.none() : Option.some(entries);
|
|
175
204
|
};
|
|
176
205
|
/** Map a `RUNNER_ARCH` value (`X64`, `ARM64`) onto the Node arch spelling. */
|
|
177
|
-
const archFromRunner = (runnerArch) =>
|
|
206
|
+
const archFromRunner = (runnerArch) => {
|
|
207
|
+
const lowered = runnerArch.toLowerCase();
|
|
208
|
+
return lowered === "x64" || lowered === "arm64" ? lowered : runnerArch;
|
|
209
|
+
};
|
|
178
210
|
const make = Effect.gen(function* () {
|
|
179
211
|
const env = yield* ActionEnvironment;
|
|
180
212
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -186,11 +218,12 @@ const make = Effect.gen(function* () {
|
|
|
186
218
|
onNone: () => process.arch,
|
|
187
219
|
onSome: archFromRunner
|
|
188
220
|
}));
|
|
189
|
-
const windows =
|
|
221
|
+
const windows = yield* isWindowsRunner(env);
|
|
222
|
+
const musl = detectMusl();
|
|
190
223
|
const bunBinaryName = windows ? "bun.exe" : "bun";
|
|
191
224
|
const shimFileName = (name) => windows ? `${name}.cmd` : name;
|
|
192
225
|
const shimBody = windows ? cmdShim : posixShim;
|
|
193
|
-
const cacheRoot = yield*
|
|
226
|
+
const cacheRoot = yield* toolCacheRoot(env, path);
|
|
194
227
|
const finalCachePath = (tool, version) => ToolInstaller.cachePath({
|
|
195
228
|
root: cacheRoot,
|
|
196
229
|
tool,
|
|
@@ -209,16 +242,11 @@ const make = Effect.gen(function* () {
|
|
|
209
242
|
cause
|
|
210
243
|
});
|
|
211
244
|
/** The hex digest of a file's bytes, streamed rather than buffered. */
|
|
212
|
-
const hashFile = (pin, file, algorithm) => Effect.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
reason: "integrityMismatch",
|
|
218
|
-
subject: file,
|
|
219
|
-
cause
|
|
220
|
-
})));
|
|
221
|
-
});
|
|
245
|
+
const hashFile = (pin, file, algorithm) => digestFileHex(fs, file, algorithm).pipe(Effect.mapError((cause) => errorFor(pin)({
|
|
246
|
+
reason: "integrityMismatch",
|
|
247
|
+
subject: file,
|
|
248
|
+
cause
|
|
249
|
+
})));
|
|
222
250
|
/**
|
|
223
251
|
* Verify a downloaded artifact against the pin's integrity, fail-closed.
|
|
224
252
|
*
|
|
@@ -250,20 +278,24 @@ const make = Effect.gen(function* () {
|
|
|
250
278
|
*/
|
|
251
279
|
const ambientNpmVersion = Effect.map(Effect.result(spawner.string(ChildProcess.make("npm", ["--version"]))), (result) => result._tag === "Success" ? Option.some(result.success.trim()) : Option.none());
|
|
252
280
|
const assertFile = (pin, file, subject) => Effect.gen(function* () {
|
|
253
|
-
|
|
254
|
-
if (stat._tag !== "Success" || stat.success.type !== "File") return yield* Effect.fail(errorFor(pin)({
|
|
281
|
+
if ((yield* typeAt(fs, file)) !== "File") return yield* Effect.fail(errorFor(pin)({
|
|
255
282
|
reason: "layoutUnexpected",
|
|
256
283
|
subject
|
|
257
284
|
}));
|
|
258
285
|
});
|
|
259
286
|
/**
|
|
260
|
-
*
|
|
287
|
+
* What a package directory's own manifest says about its entry points: the
|
|
288
|
+
* `bin` map, plus the `@pnpm/exe.*` optional dependencies that mark the
|
|
289
|
+
* pnpm 12 native-binary layout (empty for every other manager and version).
|
|
261
290
|
*
|
|
262
291
|
* Read from the artifact rather than hardcoded per manager, because the
|
|
263
292
|
* layouts genuinely move — corepack's own table has pnpm's entry at
|
|
264
|
-
* `bin/pnpm.cjs` through v10 and `bin/pnpm.mjs` from v11
|
|
293
|
+
* `bin/pnpm.cjs` through v10 and `bin/pnpm.mjs` from v11, and from v12 the
|
|
294
|
+
* `pnpm` bin is a placeholder for a native binary shipped as a separate
|
|
295
|
+
* package. Both maps are attacker-supplied bytes and are normalized to
|
|
296
|
+
* string → string before anything downstream reads them.
|
|
265
297
|
*/
|
|
266
|
-
const
|
|
298
|
+
const readPackageManifest = (pin, packageDir) => Effect.gen(function* () {
|
|
267
299
|
const manifestPath = path.join(packageDir, "package.json");
|
|
268
300
|
const raw = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => errorFor(pin)({
|
|
269
301
|
reason: "layoutUnexpected",
|
|
@@ -292,8 +324,41 @@ const make = Effect.gen(function* () {
|
|
|
292
324
|
}));
|
|
293
325
|
yield* assertFile(pin, target, `bin ${name} (${relative}) is missing`);
|
|
294
326
|
}
|
|
295
|
-
return
|
|
327
|
+
return {
|
|
328
|
+
bins,
|
|
329
|
+
nativePackages: nativePackagesOf(manifest.optionalDependencies)
|
|
330
|
+
};
|
|
296
331
|
});
|
|
332
|
+
/**
|
|
333
|
+
* The `@pnpm/exe.<target>` → version entries of an `optionalDependencies`
|
|
334
|
+
* value, and nothing else: only well-formed string pairs under that prefix
|
|
335
|
+
* survive, so a hostile or malformed map cannot smuggle a package name
|
|
336
|
+
* into a registry url.
|
|
337
|
+
*/
|
|
338
|
+
const nativePackagesOf = (optionalDependencies) => {
|
|
339
|
+
if (typeof optionalDependencies !== "object" || optionalDependencies === null) return {};
|
|
340
|
+
const entries = {};
|
|
341
|
+
for (const [name, version] of Object.entries(optionalDependencies)) if (name.startsWith("@pnpm/exe.") && typeof version === "string") entries[name] = version;
|
|
342
|
+
return entries;
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* pnpm's Windows overlay places the native binary as `<name>.exe` for every
|
|
346
|
+
* wrapper bin and repoints `bin` at those; this is that repoint, applied to
|
|
347
|
+
* the in-memory map (the shims and the record) rather than the manifest.
|
|
348
|
+
*/
|
|
349
|
+
const nativeWindowsBins = (bins) => Object.fromEntries(Object.entries(bins).map(([name, relative]) => PNPM_NATIVE_BIN_NAMES.includes(name) ? [name, `${relative}.exe`] : [name, relative]));
|
|
350
|
+
/**
|
|
351
|
+
* Whether a file is still pnpm's shebang-less placeholder (a `#`-led text
|
|
352
|
+
* file that is not a `#!` script) rather than the native binary meant to
|
|
353
|
+
* overlay it — the mark of an entry written by a version of this module
|
|
354
|
+
* that predates the overlay, or by a foreign writer that ran no lifecycle
|
|
355
|
+
* scripts. Anything else (a shebang, an executable's magic, an unreadable
|
|
356
|
+
* or empty file) is left to the ordinary layout checks.
|
|
357
|
+
*/
|
|
358
|
+
const isPlaceholder = (file) => Effect.scoped(Effect.flatMap(fs.open(file), (handle) => handle.readAlloc(2))).pipe(Effect.map((head) => Option.match(head, {
|
|
359
|
+
onNone: () => false,
|
|
360
|
+
onSome: (bytes) => bytes.length >= 1 && bytes[0] === 35 && bytes[1] !== 33
|
|
361
|
+
})), Effect.orElseSucceed(() => false));
|
|
297
362
|
const rootBins = (bins, directory) => Object.fromEntries(Object.entries(bins).map(([name, relative]) => [name, path.join(directory, relative)]));
|
|
298
363
|
/**
|
|
299
364
|
* Write one executable shim per bin into `<into>/.bin`, each invoking
|
|
@@ -319,35 +384,124 @@ const make = Effect.gen(function* () {
|
|
|
319
384
|
for (const [name, relative] of Object.entries(bins)) {
|
|
320
385
|
const shim = path.join(shimDir, shimFileName(name));
|
|
321
386
|
if (options.skipExisting) {
|
|
322
|
-
|
|
323
|
-
if (present._tag === "Success" && present.success.type === "File") continue;
|
|
387
|
+
if ((yield* typeAt(fs, shim)) === "File") continue;
|
|
324
388
|
}
|
|
325
389
|
yield* fs.writeFileString(shim, shimBody(path.join(finalDirectory, relative))).pipe(Effect.mapError(cacheError(shim)));
|
|
326
390
|
if (!windows) yield* fs.chmod(shim, 493).pipe(Effect.mapError(cacheError(shim)));
|
|
327
391
|
}
|
|
328
392
|
});
|
|
329
|
-
/**
|
|
393
|
+
/**
|
|
394
|
+
* The record for a directory already in the cache, regenerating absent
|
|
395
|
+
* shims — or `None` when the entry is a pnpm 12 wrapper whose `pnpm` bin is
|
|
396
|
+
* still the placeholder, which no shim can make runnable: the caller then
|
|
397
|
+
* reinstalls over it through the ordinary install path (ToolInstaller's
|
|
398
|
+
* swap removes the old entry and renames the complete new one into place).
|
|
399
|
+
*/
|
|
330
400
|
const cachedRecord = (pin, directory) => Effect.gen(function* () {
|
|
331
401
|
if (pin.name === "bun") {
|
|
332
402
|
const binary = path.join(directory, bunBinaryName);
|
|
333
403
|
yield* assertFile(pin, binary, `cached bun binary (${bunBinaryName}) is missing`);
|
|
334
|
-
return CachedPackageManager.make({
|
|
404
|
+
return Option.some(CachedPackageManager.make({
|
|
335
405
|
name: pin.name,
|
|
336
406
|
version: pin.version.toString(),
|
|
337
407
|
directory,
|
|
338
408
|
binDir: directory,
|
|
339
409
|
bins: { bun: binary }
|
|
340
|
-
});
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
const manifest = yield* readPackageManifest(pin, directory);
|
|
413
|
+
let bins = manifest.bins;
|
|
414
|
+
if (Object.keys(manifest.nativePackages).length > 0) {
|
|
415
|
+
const entry = bins[pin.name];
|
|
416
|
+
if (entry !== void 0 && (yield* isPlaceholder(path.join(directory, entry)))) {
|
|
417
|
+
yield* Effect.logInfo(`The cached ${pin.name}@${pin.version.toString()} still holds pnpm's placeholder bin; reinstalling it over.`);
|
|
418
|
+
return Option.none();
|
|
419
|
+
}
|
|
420
|
+
if (windows) bins = nativeWindowsBins(bins);
|
|
341
421
|
}
|
|
342
|
-
const bins = yield* readPackageBins(pin, directory);
|
|
343
422
|
yield* writeShims(pin, directory, directory, bins, { skipExisting: true });
|
|
344
|
-
return CachedPackageManager.make({
|
|
423
|
+
return Option.some(CachedPackageManager.make({
|
|
345
424
|
name: pin.name,
|
|
346
425
|
version: pin.version.toString(),
|
|
347
426
|
directory,
|
|
348
427
|
binDir: path.join(directory, SHIM_DIR),
|
|
349
428
|
bins: rootBins(bins, directory)
|
|
429
|
+
}));
|
|
430
|
+
});
|
|
431
|
+
/**
|
|
432
|
+
* pnpm 12's native overlay, done here because the installer never runs the
|
|
433
|
+
* lifecycle script that does it upstream: pick the host's `@pnpm/exe.*`
|
|
434
|
+
* package from the wrapper's optional dependencies, fetch its tarball from
|
|
435
|
+
* the SAME registry the wrapper came from, verify it against the registry's
|
|
436
|
+
* own `dist.integrity` (fail-closed — this is a second artifact the pin
|
|
437
|
+
* never named, so there is no integrity-less posture to honor), and copy
|
|
438
|
+
* the executable over the placeholder in the STAGED wrapper, where `dist/`
|
|
439
|
+
* sits beside it as the binary expects. Answers the bins as they should be
|
|
440
|
+
* shimmed and recorded — retargeted to `.exe` on Windows, as upstream does.
|
|
441
|
+
*/
|
|
442
|
+
const overlayNativeBinary = (pin, packageDir, bins, nativePackages, registry) => Effect.gen(function* () {
|
|
443
|
+
const target = Option.getOrUndefined(pnpmExeTarget(runnerOs, arch, musl));
|
|
444
|
+
const packageName = target === void 0 ? void 0 : `${PNPM_EXE_PREFIX}${target}`;
|
|
445
|
+
const nativeVersion = packageName === void 0 ? void 0 : nativePackages[packageName];
|
|
446
|
+
if (target === void 0 || packageName === void 0 || nativeVersion === void 0) return yield* Effect.fail(errorFor(pin)({
|
|
447
|
+
reason: "unsupportedPlatform",
|
|
448
|
+
subject: `${runnerOs || "unknown"}/${arch}`
|
|
449
|
+
}));
|
|
450
|
+
if (nativeVersion !== pin.version.toString()) return yield* Effect.fail(errorFor(pin)({
|
|
451
|
+
reason: "layoutUnexpected",
|
|
452
|
+
subject: `${packageName} is pinned at ${nativeVersion}, not the wrapper's ${pin.version.toString()}`
|
|
453
|
+
}));
|
|
454
|
+
const placeholder = bins[pin.name];
|
|
455
|
+
if (placeholder === void 0) return yield* Effect.fail(errorFor(pin)({
|
|
456
|
+
reason: "layoutUnexpected",
|
|
457
|
+
subject: `package.json names no ${pin.name} bin to overlay`
|
|
458
|
+
}));
|
|
459
|
+
const packumentUrl = `${registry}/${packageName}/${nativeVersion}`;
|
|
460
|
+
const packumentFile = yield* installer.download(packumentUrl).pipe(Effect.mapError(fromInstaller(pin)));
|
|
461
|
+
const unverifiable = (cause) => errorFor(pin)({
|
|
462
|
+
reason: "integrityMismatch",
|
|
463
|
+
subject: packumentUrl,
|
|
464
|
+
...cause === void 0 ? {} : { cause }
|
|
350
465
|
});
|
|
466
|
+
const declared = (yield* fs.readFileString(packumentFile).pipe(Effect.mapError((cause) => unverifiable(cause)), Effect.flatMap((raw) => Effect.try({
|
|
467
|
+
try: () => JSON.parse(raw),
|
|
468
|
+
catch: (cause) => unverifiable(cause)
|
|
469
|
+
})))).dist?.integrity;
|
|
470
|
+
const expected = typeof declared === "string" ? Option.getOrUndefined(strongestSri(declared)) : void 0;
|
|
471
|
+
if (expected === void 0) return yield* Effect.fail(unverifiable());
|
|
472
|
+
const tarballUrl = `${registry}/${packageName}/-/exe.${target}-${nativeVersion}.tgz`;
|
|
473
|
+
const archive = yield* installer.download(tarballUrl).pipe(Effect.mapError(fromInstaller(pin)));
|
|
474
|
+
const actualHex = yield* hashFile(pin, archive, expected.algorithm);
|
|
475
|
+
if (actualHex !== expected.hex) return yield* Effect.fail(errorFor(pin)({
|
|
476
|
+
reason: "integrityMismatch",
|
|
477
|
+
subject: tarballUrl,
|
|
478
|
+
expected: `${expected.algorithm}.${expected.hex}`,
|
|
479
|
+
actual: `${expected.algorithm}.${actualHex}`
|
|
480
|
+
}));
|
|
481
|
+
const extracted = yield* installer.extractTar(archive).pipe(Effect.mapError(fromInstaller(pin)));
|
|
482
|
+
const nativeName = windows ? "pnpm.exe" : "pnpm";
|
|
483
|
+
const native = path.join(extracted, "package", nativeName);
|
|
484
|
+
yield* assertFile(pin, native, `package/${nativeName} is missing from the ${packageName} tarball`);
|
|
485
|
+
const cacheError = (subject) => (cause) => errorFor(pin)({
|
|
486
|
+
reason: "cacheFailed",
|
|
487
|
+
subject,
|
|
488
|
+
cause
|
|
489
|
+
});
|
|
490
|
+
if (windows) {
|
|
491
|
+
for (const name of PNPM_NATIVE_BIN_NAMES) {
|
|
492
|
+
const relative = bins[name];
|
|
493
|
+
if (relative === void 0) continue;
|
|
494
|
+
for (const destination of [path.join(packageDir, relative), path.join(packageDir, `${relative}.exe`)]) yield* fs.copyFile(native, destination).pipe(Effect.mapError(cacheError(destination)));
|
|
495
|
+
}
|
|
496
|
+
return nativeWindowsBins(bins);
|
|
497
|
+
}
|
|
498
|
+
const destination = path.join(packageDir, placeholder);
|
|
499
|
+
yield* fs.copyFile(native, destination).pipe(Effect.mapError(cacheError(destination)));
|
|
500
|
+
for (const relative of Object.values(bins)) if (!isNodeScript(relative)) {
|
|
501
|
+
const file = path.join(packageDir, relative);
|
|
502
|
+
yield* fs.chmod(file, 493).pipe(Effect.mapError(cacheError(file)));
|
|
503
|
+
}
|
|
504
|
+
return bins;
|
|
351
505
|
});
|
|
352
506
|
/** Download bun's per-platform zip from GitHub releases and cache the binary. */
|
|
353
507
|
const installBun = (pin) => Effect.gen(function* () {
|
|
@@ -386,12 +540,12 @@ const make = Effect.gen(function* () {
|
|
|
386
540
|
if (!berry) yield* verifyIntegrity(pin, archive);
|
|
387
541
|
const extracted = yield* installer.extractTar(archive).pipe(Effect.mapError(fromInstaller(pin)));
|
|
388
542
|
const packageDir = path.join(extracted, "package");
|
|
389
|
-
|
|
390
|
-
if (stat._tag !== "Success" || stat.success.type !== "Directory") return yield* Effect.fail(errorFor(pin)({
|
|
543
|
+
if ((yield* typeAt(fs, packageDir)) !== "Directory") return yield* Effect.fail(errorFor(pin)({
|
|
391
544
|
reason: "layoutUnexpected",
|
|
392
545
|
subject: "tarball has no package/ root"
|
|
393
546
|
}));
|
|
394
|
-
const
|
|
547
|
+
const manifest = yield* readPackageManifest(pin, packageDir);
|
|
548
|
+
const bins = Object.keys(manifest.nativePackages).length > 0 ? yield* overlayNativeBinary(pin, packageDir, manifest.bins, manifest.nativePackages, registry) : manifest.bins;
|
|
395
549
|
if (berry) {
|
|
396
550
|
const cli = path.join(packageDir, "bin/yarn.js");
|
|
397
551
|
yield* assertFile(pin, cli, "bin/yarn.js is missing from the cli-dist tarball");
|
|
@@ -420,7 +574,10 @@ const make = Effect.gen(function* () {
|
|
|
420
574
|
});
|
|
421
575
|
if (options?.requireIntegrity === true && pin.integrity === void 0) return yield* Effect.fail(errorFor(pin)({ reason: "integrityMissing" }));
|
|
422
576
|
const cached = yield* installer.find(pin.name, version);
|
|
423
|
-
if (Option.isSome(cached))
|
|
577
|
+
if (Option.isSome(cached)) {
|
|
578
|
+
const record = yield* cachedRecord(pin, cached.value);
|
|
579
|
+
if (Option.isSome(record)) return record.value;
|
|
580
|
+
}
|
|
424
581
|
if (pin.name === "npm" && options?.allowAmbient !== false) {
|
|
425
582
|
const probed = yield* ambientNpmVersion;
|
|
426
583
|
if (Option.isSome(probed) && probed.value === version) return AmbientPackageManager.make({
|
|
@@ -437,9 +594,7 @@ const make = Effect.gen(function* () {
|
|
|
437
594
|
return yield* installFromRegistry(pin, pin.name, registry);
|
|
438
595
|
}) };
|
|
439
596
|
});
|
|
440
|
-
const
|
|
441
|
-
throw new Error(`PackageManagerInstaller.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
442
|
-
};
|
|
597
|
+
const dies = unstubbed("PackageManagerInstaller.makeTest");
|
|
443
598
|
/**
|
|
444
599
|
* First-class exact-version package-manager provisioning on a GitHub runner.
|
|
445
600
|
*
|
|
@@ -483,7 +638,7 @@ var PackageManagerInstaller = class PackageManagerInstaller extends Context.Serv
|
|
|
483
638
|
static layer = Layer.effect(this, make);
|
|
484
639
|
/** A test double. The unstubbed member dies rather than reporting an install that did not happen. */
|
|
485
640
|
static makeTest = (overrides = {}) => ({
|
|
486
|
-
install: () =>
|
|
641
|
+
install: () => dies("install"),
|
|
487
642
|
...overrides
|
|
488
643
|
});
|
|
489
644
|
/** {@link PackageManagerInstaller.makeTest} behind `Layer.succeed`. */
|
package/ToolInstaller.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
|
+
import { unstubbed } from "./internal/unstubbed.js";
|
|
3
|
+
import { isWindowsRunner, toolCacheRoot } from "./internal/runner.js";
|
|
4
|
+
import { spawnOnce } from "./internal/spawn.js";
|
|
5
|
+
import { typeAt } from "./internal/fsProbe.js";
|
|
2
6
|
import { Context, Effect, FileSystem, Layer, Option, Path, Schedule, Schema, Stream } from "effect";
|
|
3
7
|
import { HttpClient } from "effect/unstable/http";
|
|
4
8
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
@@ -61,11 +65,8 @@ const make = Effect.gen(function* () {
|
|
|
61
65
|
const path = yield* Path.Path;
|
|
62
66
|
const http = yield* HttpClient.HttpClient;
|
|
63
67
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
64
|
-
const root = yield*
|
|
65
|
-
const windows = yield*
|
|
66
|
-
onNone: () => false,
|
|
67
|
-
onSome: (os) => os.toLowerCase() === "windows"
|
|
68
|
-
}));
|
|
68
|
+
const root = yield* toolCacheRoot(env, path);
|
|
69
|
+
const windows = yield* isWindowsRunner(env);
|
|
69
70
|
const cachePath = (tool, version) => ToolInstaller.cachePath({
|
|
70
71
|
root,
|
|
71
72
|
tool,
|
|
@@ -93,30 +94,22 @@ const make = Effect.gen(function* () {
|
|
|
93
94
|
* the difference between "this is not a gzip archive" and "tar is not
|
|
94
95
|
* installed". Discarding it is what makes an extraction failure unactionable.
|
|
95
96
|
*
|
|
96
|
-
* **One spawn, not two — load-bearing
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* `ChildProcessSpawner.make` derives both from `spawn`). Calling them
|
|
100
|
-
* back-to-back double-executed every extraction — harmless for idempotent
|
|
101
|
-
* `tar`/`unzip -o`, but .NET's `ZipFile.ExtractToDirectory` refuses to
|
|
102
|
-
* overwrite, so the second run failed 5/5 on real Windows runners while the
|
|
103
|
-
* captured "complaint" was the FIRST run's silent success. Output and exit
|
|
104
|
-
* code must come from the same `spawn` handle.
|
|
97
|
+
* **One spawn, not two — load-bearing**, and why is spelled once in
|
|
98
|
+
* `internal/spawn.ts`: the double run failed 5/5 on real Windows runners
|
|
99
|
+
* because .NET's `ZipFile.ExtractToDirectory` refuses to overwrite.
|
|
105
100
|
*/
|
|
106
|
-
const extractWith = (command, archive) => Effect.
|
|
107
|
-
const
|
|
101
|
+
const extractWith = (command, archive) => Effect.gen(function* () {
|
|
102
|
+
const { output, code } = yield* spawnOnce(spawner, command).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
108
103
|
reason: "extractFailed",
|
|
109
104
|
subject: archive,
|
|
110
105
|
cause
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const output = yield* Stream.mkString(Stream.decodeText(handle.all)).pipe(Effect.mapError(extractError));
|
|
114
|
-
if ((yield* handle.exitCode.pipe(Effect.mapError(extractError))) !== 0) return yield* Effect.fail(new ToolInstallerError({
|
|
106
|
+
})));
|
|
107
|
+
if (code !== 0) return yield* Effect.fail(new ToolInstallerError({
|
|
115
108
|
reason: "extractFailed",
|
|
116
109
|
subject: archive,
|
|
117
110
|
stderr: output.trim()
|
|
118
111
|
}));
|
|
119
|
-
})
|
|
112
|
+
});
|
|
120
113
|
/**
|
|
121
114
|
* Install a staged directory into the tool cache by renaming it into place.
|
|
122
115
|
*
|
|
@@ -191,7 +184,7 @@ const make = Effect.gen(function* () {
|
|
|
191
184
|
while: (error) => error.retryable
|
|
192
185
|
}));
|
|
193
186
|
});
|
|
194
|
-
const find = (tool, version) => Effect.map(
|
|
187
|
+
const find = (tool, version) => Effect.map(typeAt(fs, cachePath(tool, version)), (type) => type === "Directory" ? Option.some(cachePath(tool, version)) : Option.none());
|
|
195
188
|
const cacheFile = Effect.fn("ToolInstaller.cacheFile")(function* (source, name, tool, version) {
|
|
196
189
|
yield* Effect.annotateCurrentSpan({
|
|
197
190
|
tool,
|
|
@@ -252,8 +245,7 @@ const make = Effect.gen(function* () {
|
|
|
252
245
|
});
|
|
253
246
|
const cached = yield* find(options.tool, options.version);
|
|
254
247
|
if (Option.isSome(cached)) {
|
|
255
|
-
|
|
256
|
-
if (present._tag === "Success" && present.success.type === "File") return {
|
|
248
|
+
if ((yield* typeAt(fs, path.join(cached.value, options.binary))) === "File") return {
|
|
257
249
|
directory: cached.value,
|
|
258
250
|
binDir: cached.value
|
|
259
251
|
};
|
|
@@ -272,9 +264,7 @@ const make = Effect.gen(function* () {
|
|
|
272
264
|
})
|
|
273
265
|
};
|
|
274
266
|
});
|
|
275
|
-
const
|
|
276
|
-
throw new Error(`ToolInstaller.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
277
|
-
};
|
|
267
|
+
const dies = unstubbed("ToolInstaller.makeTest");
|
|
278
268
|
/**
|
|
279
269
|
* Download, extract and cache a toolchain in the runner's tool cache.
|
|
280
270
|
*
|
|
@@ -323,13 +313,13 @@ var ToolInstaller = class ToolInstaller extends Context.Service()("@effected/git
|
|
|
323
313
|
static cachePath = (options) => `${options.root}/${options.tool}/${options.version}/${options.arch}`;
|
|
324
314
|
/** A test double. Unstubbed members die rather than reporting a tool that is not there. */
|
|
325
315
|
static makeTest = (overrides = {}) => ({
|
|
326
|
-
find: () =>
|
|
327
|
-
download: () =>
|
|
328
|
-
extractTar: () =>
|
|
329
|
-
extractZip: () =>
|
|
330
|
-
cacheDir: () =>
|
|
331
|
-
cacheFile: () =>
|
|
332
|
-
provisionFile: () =>
|
|
316
|
+
find: () => dies("find"),
|
|
317
|
+
download: () => dies("download"),
|
|
318
|
+
extractTar: () => dies("extractTar"),
|
|
319
|
+
extractZip: () => dies("extractZip"),
|
|
320
|
+
cacheDir: () => dies("cacheDir"),
|
|
321
|
+
cacheFile: () => dies("cacheFile"),
|
|
322
|
+
provisionFile: () => dies("provisionFile"),
|
|
333
323
|
...overrides
|
|
334
324
|
});
|
|
335
325
|
/** {@link ToolInstaller.makeTest} behind `Layer.succeed`. */
|