@effected/github-actions 0.2.0 → 0.4.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/ActionEnvironment.js +34 -1
- package/ActionInput.js +61 -8
- package/ActionOutputs.js +71 -2
- package/ActionState.js +33 -6
- package/Artifact.js +2 -2
- package/BlobStore.githubCache.js +2 -1
- package/CacheKey.js +147 -8
- package/ChildEnv.js +124 -0
- package/PackageManagerInstaller.js +494 -0
- package/Secret.js +21 -0
- package/ToolInstaller.js +98 -52
- package/index.d.ts +878 -125
- package/index.js +3 -1
- package/package.json +5 -4
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { ActionEnvironment } from "./ActionEnvironment.js";
|
|
2
|
+
import { ToolInstaller } from "./ToolInstaller.js";
|
|
3
|
+
import { Context, Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
6
|
+
import { PackageManagerPinName } from "@effected/npm";
|
|
7
|
+
|
|
8
|
+
//#region src/PackageManagerInstaller.ts
|
|
9
|
+
/**
|
|
10
|
+
* Raised when a package manager cannot be provisioned on the runner.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var PackageManagerInstallerError = class extends Schema.TaggedErrorClass()("PackageManagerInstallerError", {
|
|
15
|
+
/**
|
|
16
|
+
* `downloadFailed`, `extractFailed` and `cacheFailed` mirror the
|
|
17
|
+
* {@link ToolInstallerError} that caused them, preserved on `cause`
|
|
18
|
+
* (`cacheFailed` also covers a shim that could not be written into the
|
|
19
|
+
* entry). `integrityMismatch` — the downloaded artifact does not hash to
|
|
20
|
+
* what the pin declares; nothing was cached. `integrityMissing` — the pin
|
|
21
|
+
* carries no integrity and the caller asked for `requireIntegrity`.
|
|
22
|
+
* `unsupportedPlatform` — bun publishes no build for this runner's
|
|
23
|
+
* OS/architecture pair. `layoutUnexpected` — the artifact extracted, but
|
|
24
|
+
* its contents are not shaped like the package manager it claims to be.
|
|
25
|
+
*/
|
|
26
|
+
reason: Schema.Literals([
|
|
27
|
+
"downloadFailed",
|
|
28
|
+
"extractFailed",
|
|
29
|
+
"integrityMismatch",
|
|
30
|
+
"integrityMissing",
|
|
31
|
+
"unsupportedPlatform",
|
|
32
|
+
"layoutUnexpected",
|
|
33
|
+
"cacheFailed"
|
|
34
|
+
]),
|
|
35
|
+
/** The package manager being installed. */
|
|
36
|
+
name: PackageManagerPinName,
|
|
37
|
+
/** The exact version being installed, in string form. */
|
|
38
|
+
version: Schema.String,
|
|
39
|
+
/** What was being worked on — a url, a path, or an OS/arch pair. */
|
|
40
|
+
subject: Schema.optionalKey(Schema.String),
|
|
41
|
+
/** The integrity the pin declares (`<algo>.<hex>`), on a mismatch. */
|
|
42
|
+
expected: Schema.optionalKey(Schema.String),
|
|
43
|
+
/** The integrity the artifact actually hashed to, on a mismatch. */
|
|
44
|
+
actual: Schema.optionalKey(Schema.String),
|
|
45
|
+
/** The underlying failure, preserved structurally. */
|
|
46
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
47
|
+
}) {
|
|
48
|
+
get message() {
|
|
49
|
+
const pin = `${this.name}@${this.version}`;
|
|
50
|
+
switch (this.reason) {
|
|
51
|
+
case "downloadFailed": return `Could not download ${pin}${this.subject === void 0 ? "" : ` from ${this.subject}`}`;
|
|
52
|
+
case "extractFailed": return `Could not extract ${pin}${this.subject === void 0 ? "" : ` (${this.subject})`}`;
|
|
53
|
+
case "integrityMismatch": return `Integrity mismatch for ${pin}: expected ${this.expected}, got ${this.actual}`;
|
|
54
|
+
case "integrityMissing": return `The pin for ${pin} carries no integrity hash and requireIntegrity is set`;
|
|
55
|
+
case "unsupportedPlatform": return `No ${pin} build is published for ${this.subject}`;
|
|
56
|
+
case "layoutUnexpected": return `The ${pin} artifact does not have the expected layout${this.subject === void 0 ? "" : `: ${this.subject}`}`;
|
|
57
|
+
default: return `Could not cache ${pin} into the tool cache${this.subject === void 0 ? "" : `: ${this.subject}`}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* A package manager the runner's own toolchain already had: nothing was
|
|
63
|
+
* downloaded and nothing was cached, so there is no directory to publish.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* `bins` values are bare command names (`npm`, `npx`) resolved through the
|
|
67
|
+
* ambient `PATH` — the toolchain already put them there.
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
var AmbientPackageManager = class extends Schema.Class("AmbientPackageManager")({
|
|
72
|
+
/** The discriminant: the runner's own toolchain answered. */
|
|
73
|
+
source: Schema.tag("ambient"),
|
|
74
|
+
/** The package-manager name (`npm`, `pnpm`, `yarn` or `bun`). */
|
|
75
|
+
name: PackageManagerPinName,
|
|
76
|
+
/** The exact installed version, in string form. */
|
|
77
|
+
version: Schema.String,
|
|
78
|
+
/** Bin name → the ambient command name that invokes it. */
|
|
79
|
+
bins: Schema.Record(Schema.String, Schema.String)
|
|
80
|
+
}) {};
|
|
81
|
+
/**
|
|
82
|
+
* A package manager living in the runner's tool cache — found there, or
|
|
83
|
+
* installed into it by this call.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* `binDir` is the directory a consumer hands to `ActionOutputs.addPath` to
|
|
87
|
+
* make the manager invokable by name in subsequent workflow steps. For the
|
|
88
|
+
* npm-registry managers it is the entry's `.bin` directory of executable
|
|
89
|
+
* shims this installer writes (`#!/bin/sh` exec wrappers, or `.cmd` wrappers
|
|
90
|
+
* on Windows); for bun it is the entry directory itself, because the cached
|
|
91
|
+
* `bun` binary is directly executable and a shim would add nothing but a
|
|
92
|
+
* fork. `bins` still maps each published bin name to the underlying entry —
|
|
93
|
+
* a Node script to run with `node` for npm/pnpm/yarn, the native binary for
|
|
94
|
+
* bun. Calling `addPath` remains deliberately the consumer's move, and note
|
|
95
|
+
* that `addPath` targets *subsequent* steps: a same-process probe must use
|
|
96
|
+
* the absolute paths in `bins` or `binDir`.
|
|
97
|
+
*
|
|
98
|
+
* @public
|
|
99
|
+
*/
|
|
100
|
+
var CachedPackageManager = class extends Schema.Class("CachedPackageManager")({
|
|
101
|
+
/** The discriminant: the manager lives in the tool cache. */
|
|
102
|
+
source: Schema.tag("tool-cache"),
|
|
103
|
+
/** The package-manager name (`npm`, `pnpm`, `yarn` or `bun`). */
|
|
104
|
+
name: PackageManagerPinName,
|
|
105
|
+
/** The exact installed version, in string form. */
|
|
106
|
+
version: Schema.String,
|
|
107
|
+
/** The cached entry: `<tool-cache>/<name>/<version>/<arch>`. */
|
|
108
|
+
directory: Schema.String,
|
|
109
|
+
/** The directory to `addPath` — executable shims, or bun's own directory. */
|
|
110
|
+
binDir: Schema.String,
|
|
111
|
+
/** Bin name → the absolute path of the underlying entry point. */
|
|
112
|
+
bins: Schema.Record(Schema.String, Schema.String)
|
|
113
|
+
}) {};
|
|
114
|
+
/**
|
|
115
|
+
* An installed package manager, discriminated by `source`: `ambient` carries
|
|
116
|
+
* no directory, `tool-cache` carries the cached `directory` and the
|
|
117
|
+
* `addPath`-able `binDir`. The union is a `Schema`, so the record round-trips
|
|
118
|
+
* through `ActionState` for a later phase to read back.
|
|
119
|
+
*
|
|
120
|
+
* @remarks
|
|
121
|
+
* **Construct the variant classes, never the union.** The union's inherited
|
|
122
|
+
* `make` typechecks against *both* variants at once, so a wrong field yields
|
|
123
|
+
* a confusing two-branch error instead of "this variant wants X" — reach for
|
|
124
|
+
* {@link AmbientPackageManager}`.make` / {@link CachedPackageManager}`.make`,
|
|
125
|
+
* whose `Schema.tag` fills `source` for you. The same tag is why an
|
|
126
|
+
* overrides-style test helper should be typed
|
|
127
|
+
* `Partial<Omit<typeof CachedPackageManager.Type, "source">>` — a plain
|
|
128
|
+
* `Partial` offers `source`, which `make` auto-fills and rejects as input.
|
|
129
|
+
*
|
|
130
|
+
* @public
|
|
131
|
+
*/
|
|
132
|
+
const InstalledPackageManager = Schema.Union([AmbientPackageManager, CachedPackageManager]);
|
|
133
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
134
|
+
/** The shim directory name inside a cached npm-registry-manager entry. */
|
|
135
|
+
const SHIM_DIR = ".bin";
|
|
136
|
+
/**
|
|
137
|
+
* The POSIX shim body: an `exec` wrapper so the shim's process *becomes*
|
|
138
|
+
* node, and `"$@"` so arguments survive quoting intact.
|
|
139
|
+
*/
|
|
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`;
|
|
143
|
+
/**
|
|
144
|
+
* The bun release asset name for a runner platform, or `None` when bun
|
|
145
|
+
* publishes no build for it.
|
|
146
|
+
*
|
|
147
|
+
* Asset names verified against the `bun-v1.3.14` release: `bun-{linux,darwin,
|
|
148
|
+
* windows}-{x64,aarch64}.zip`, with the runner's `RUNNER_OS` values mapped to
|
|
149
|
+
* bun's spelling (`macOS` → `darwin`) and the Node arch spelling to bun's
|
|
150
|
+
* (`arm64` → `aarch64`).
|
|
151
|
+
*/
|
|
152
|
+
const bunTarget = (runnerOs, arch) => {
|
|
153
|
+
const os = runnerOs.toLowerCase() === "linux" ? "linux" : runnerOs.toLowerCase() === "macos" ? "darwin" : runnerOs.toLowerCase() === "windows" ? "windows" : void 0;
|
|
154
|
+
const cpu = arch === "x64" ? "x64" : arch === "arm64" ? "aarch64" : void 0;
|
|
155
|
+
return os === void 0 || cpu === void 0 ? Option.none() : Option.some(`bun-${os}-${cpu}`);
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* The registry tarball url for an npm-registry-distributed manager.
|
|
159
|
+
*
|
|
160
|
+
* The yarn split is corepack's own: `<2.0.0` is the `yarn` package, `>=2.0.0`
|
|
161
|
+
* (Berry) ships as `@yarnpkg/cli-dist` — a scoped package, whose tarball
|
|
162
|
+
* basename drops the scope (`/@yarnpkg/cli-dist/-/cli-dist-<v>.tgz`).
|
|
163
|
+
*/
|
|
164
|
+
const registryTarballUrl = (name, version, major, registry) => name === "yarn" && major >= 2 ? `${registry}/@yarnpkg/cli-dist/-/cli-dist-${version}.tgz` : `${registry}/${name}/-/${name}-${version}.tgz`;
|
|
165
|
+
/** Normalize a package.json `bin` value into name → relative path entries. */
|
|
166
|
+
const normalizeBins = (bin, fallbackName) => {
|
|
167
|
+
if (typeof bin === "string") return Option.some({ [fallbackName]: bin.replace(/^\.\//, "") });
|
|
168
|
+
if (typeof bin !== "object" || bin === null) return Option.none();
|
|
169
|
+
const entries = {};
|
|
170
|
+
for (const [name, value] of Object.entries(bin)) {
|
|
171
|
+
if (typeof value !== "string") return Option.none();
|
|
172
|
+
entries[name] = value.replace(/^\.\//, "");
|
|
173
|
+
}
|
|
174
|
+
return Object.keys(entries).length === 0 ? Option.none() : Option.some(entries);
|
|
175
|
+
};
|
|
176
|
+
/** Map a `RUNNER_ARCH` value (`X64`, `ARM64`) onto the Node arch spelling. */
|
|
177
|
+
const archFromRunner = (runnerArch) => runnerArch.toLowerCase() === "x64" ? "x64" : runnerArch.toLowerCase() === "arm64" ? "arm64" : runnerArch;
|
|
178
|
+
const make = Effect.gen(function* () {
|
|
179
|
+
const env = yield* ActionEnvironment;
|
|
180
|
+
const fs = yield* FileSystem.FileSystem;
|
|
181
|
+
const path = yield* Path.Path;
|
|
182
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
183
|
+
const installer = yield* ToolInstaller;
|
|
184
|
+
const runnerOs = yield* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.getOrElse(found, () => ""));
|
|
185
|
+
const arch = yield* Effect.map(env.getOptional("RUNNER_ARCH"), (found) => Option.match(found, {
|
|
186
|
+
onNone: () => process.arch,
|
|
187
|
+
onSome: archFromRunner
|
|
188
|
+
}));
|
|
189
|
+
const windows = runnerOs.toLowerCase() === "windows";
|
|
190
|
+
const bunBinaryName = windows ? "bun.exe" : "bun";
|
|
191
|
+
const shimFileName = (name) => windows ? `${name}.cmd` : name;
|
|
192
|
+
const shimBody = windows ? cmdShim : posixShim;
|
|
193
|
+
const cacheRoot = yield* Effect.map(env.getOptional("RUNNER_TOOL_CACHE"), (found) => Option.getOrElse(found, () => path.join("/tmp", "runner-tool-cache")));
|
|
194
|
+
const finalCachePath = (tool, version) => ToolInstaller.cachePath({
|
|
195
|
+
root: cacheRoot,
|
|
196
|
+
tool,
|
|
197
|
+
version,
|
|
198
|
+
arch: process.arch
|
|
199
|
+
});
|
|
200
|
+
const errorFor = (pin) => (fields) => new PackageManagerInstallerError({
|
|
201
|
+
name: pin.name,
|
|
202
|
+
version: pin.version.toString(),
|
|
203
|
+
...fields
|
|
204
|
+
});
|
|
205
|
+
/** Map a ToolInstaller failure onto this surface; the reasons line up 1:1. */
|
|
206
|
+
const fromInstaller = (pin) => (cause) => errorFor(pin)({
|
|
207
|
+
reason: cause.reason,
|
|
208
|
+
...cause.subject === void 0 ? {} : { subject: cause.subject },
|
|
209
|
+
cause
|
|
210
|
+
});
|
|
211
|
+
/** The hex digest of a file's bytes, streamed rather than buffered. */
|
|
212
|
+
const hashFile = (pin, file, algorithm) => Effect.suspend(() => {
|
|
213
|
+
const digest = createHash(algorithm);
|
|
214
|
+
return fs.stream(file).pipe(Stream.runForEach((chunk) => Effect.sync(() => {
|
|
215
|
+
digest.update(chunk);
|
|
216
|
+
})), Effect.map(() => digest.digest("hex")), Effect.mapError((cause) => errorFor(pin)({
|
|
217
|
+
reason: "integrityMismatch",
|
|
218
|
+
subject: file,
|
|
219
|
+
cause
|
|
220
|
+
})));
|
|
221
|
+
});
|
|
222
|
+
/**
|
|
223
|
+
* Verify a downloaded artifact against the pin's integrity, fail-closed.
|
|
224
|
+
*
|
|
225
|
+
* A pin with no integrity proceeds with a logged warning — in-the-wild
|
|
226
|
+
* `devEngines` pins routinely carry none, and the opt-in strictness lives in
|
|
227
|
+
* `requireIntegrity`, checked before anything is downloaded.
|
|
228
|
+
*/
|
|
229
|
+
const verifyIntegrity = (pin, file) => Effect.gen(function* () {
|
|
230
|
+
if (pin.integrity === void 0) {
|
|
231
|
+
yield* Effect.logWarning(`The pin ${pin.toString()} carries no integrity hash; the downloaded artifact was not verified.`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const dot = pin.integrity.indexOf(".");
|
|
235
|
+
const algorithm = pin.integrity.slice(0, dot);
|
|
236
|
+
const expectedHex = pin.integrity.slice(dot + 1);
|
|
237
|
+
const actualHex = yield* hashFile(pin, file, algorithm);
|
|
238
|
+
if (actualHex !== expectedHex) return yield* Effect.fail(errorFor(pin)({
|
|
239
|
+
reason: "integrityMismatch",
|
|
240
|
+
subject: file,
|
|
241
|
+
expected: pin.integrity,
|
|
242
|
+
actual: `${algorithm}.${actualHex}`
|
|
243
|
+
}));
|
|
244
|
+
});
|
|
245
|
+
/**
|
|
246
|
+
* The ambient `npm --version`, or `None` when the probe cannot answer.
|
|
247
|
+
*
|
|
248
|
+
* A failed probe is not an error — there is nothing a caller could do about
|
|
249
|
+
* it that the dist path does not already do.
|
|
250
|
+
*/
|
|
251
|
+
const ambientNpmVersion = Effect.map(Effect.result(spawner.string(ChildProcess.make("npm", ["--version"]))), (result) => result._tag === "Success" ? Option.some(result.success.trim()) : Option.none());
|
|
252
|
+
const assertFile = (pin, file, subject) => Effect.gen(function* () {
|
|
253
|
+
const stat = yield* Effect.result(fs.stat(file));
|
|
254
|
+
if (stat._tag !== "Success" || stat.success.type !== "File") return yield* Effect.fail(errorFor(pin)({
|
|
255
|
+
reason: "layoutUnexpected",
|
|
256
|
+
subject
|
|
257
|
+
}));
|
|
258
|
+
});
|
|
259
|
+
/**
|
|
260
|
+
* The bin entries a package directory publishes, from its own manifest.
|
|
261
|
+
*
|
|
262
|
+
* Read from the artifact rather than hardcoded per manager, because the
|
|
263
|
+
* layouts genuinely move — corepack's own table has pnpm's entry at
|
|
264
|
+
* `bin/pnpm.cjs` through v10 and `bin/pnpm.mjs` from v11.
|
|
265
|
+
*/
|
|
266
|
+
const readPackageBins = (pin, packageDir) => Effect.gen(function* () {
|
|
267
|
+
const manifestPath = path.join(packageDir, "package.json");
|
|
268
|
+
const raw = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => errorFor(pin)({
|
|
269
|
+
reason: "layoutUnexpected",
|
|
270
|
+
subject: "no package.json",
|
|
271
|
+
cause
|
|
272
|
+
})));
|
|
273
|
+
const manifest = yield* Effect.try({
|
|
274
|
+
try: () => JSON.parse(raw),
|
|
275
|
+
catch: (cause) => errorFor(pin)({
|
|
276
|
+
reason: "layoutUnexpected",
|
|
277
|
+
subject: "unparseable package.json",
|
|
278
|
+
cause
|
|
279
|
+
})
|
|
280
|
+
});
|
|
281
|
+
const bins = Option.getOrUndefined(normalizeBins(manifest.bin, pin.name));
|
|
282
|
+
if (bins === void 0) return yield* Effect.fail(errorFor(pin)({
|
|
283
|
+
reason: "layoutUnexpected",
|
|
284
|
+
subject: "package.json names no bin"
|
|
285
|
+
}));
|
|
286
|
+
for (const [name, relative] of Object.entries(bins)) {
|
|
287
|
+
const target = path.join(packageDir, relative);
|
|
288
|
+
const containment = path.relative(packageDir, target);
|
|
289
|
+
if (containment === ".." || containment.startsWith(`..${path.sep}`) || path.isAbsolute(containment)) return yield* Effect.fail(errorFor(pin)({
|
|
290
|
+
reason: "layoutUnexpected",
|
|
291
|
+
subject: `bin ${name} (${relative}) escapes the package directory`
|
|
292
|
+
}));
|
|
293
|
+
yield* assertFile(pin, target, `bin ${name} (${relative}) is missing`);
|
|
294
|
+
}
|
|
295
|
+
return bins;
|
|
296
|
+
});
|
|
297
|
+
const rootBins = (bins, directory) => Object.fromEntries(Object.entries(bins).map(([name, relative]) => [name, path.join(directory, relative)]));
|
|
298
|
+
/**
|
|
299
|
+
* Write one executable shim per bin into `<into>/.bin`, each invoking
|
|
300
|
+
* `node <finalDirectory>/<relative>`.
|
|
301
|
+
*
|
|
302
|
+
* @remarks
|
|
303
|
+
* `into` and `finalDirectory` are DIFFERENT on the install path on purpose:
|
|
304
|
+
* the shims are written into the *staged* tree so they are part of the entry
|
|
305
|
+
* ToolInstaller renames into place (the cache only ever contains complete
|
|
306
|
+
* entries), while their contents must name the *final* cache path the entry
|
|
307
|
+
* is about to land at. On the cache-hit regeneration path the two coincide.
|
|
308
|
+
* When `skipExisting` is set, a shim already present is left untouched — the
|
|
309
|
+
* regeneration path must not rewrite a shared cache entry another writer owns.
|
|
310
|
+
*/
|
|
311
|
+
const writeShims = (pin, into, finalDirectory, bins, options) => Effect.gen(function* () {
|
|
312
|
+
const shimDir = path.join(into, SHIM_DIR);
|
|
313
|
+
const cacheError = (subject) => (cause) => errorFor(pin)({
|
|
314
|
+
reason: "cacheFailed",
|
|
315
|
+
subject,
|
|
316
|
+
cause
|
|
317
|
+
});
|
|
318
|
+
yield* fs.makeDirectory(shimDir, { recursive: true }).pipe(Effect.mapError(cacheError(shimDir)));
|
|
319
|
+
for (const [name, relative] of Object.entries(bins)) {
|
|
320
|
+
const shim = path.join(shimDir, shimFileName(name));
|
|
321
|
+
if (options.skipExisting) {
|
|
322
|
+
const present = yield* Effect.result(fs.stat(shim));
|
|
323
|
+
if (present._tag === "Success" && present.success.type === "File") continue;
|
|
324
|
+
}
|
|
325
|
+
yield* fs.writeFileString(shim, shimBody(path.join(finalDirectory, relative))).pipe(Effect.mapError(cacheError(shim)));
|
|
326
|
+
if (!windows) yield* fs.chmod(shim, 493).pipe(Effect.mapError(cacheError(shim)));
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
/** The record for a directory already in the cache, regenerating absent shims. */
|
|
330
|
+
const cachedRecord = (pin, directory) => Effect.gen(function* () {
|
|
331
|
+
if (pin.name === "bun") {
|
|
332
|
+
const binary = path.join(directory, bunBinaryName);
|
|
333
|
+
yield* assertFile(pin, binary, `cached bun binary (${bunBinaryName}) is missing`);
|
|
334
|
+
return CachedPackageManager.make({
|
|
335
|
+
name: pin.name,
|
|
336
|
+
version: pin.version.toString(),
|
|
337
|
+
directory,
|
|
338
|
+
binDir: directory,
|
|
339
|
+
bins: { bun: binary }
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const bins = yield* readPackageBins(pin, directory);
|
|
343
|
+
yield* writeShims(pin, directory, directory, bins, { skipExisting: true });
|
|
344
|
+
return CachedPackageManager.make({
|
|
345
|
+
name: pin.name,
|
|
346
|
+
version: pin.version.toString(),
|
|
347
|
+
directory,
|
|
348
|
+
binDir: path.join(directory, SHIM_DIR),
|
|
349
|
+
bins: rootBins(bins, directory)
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
/** Download bun's per-platform zip from GitHub releases and cache the binary. */
|
|
353
|
+
const installBun = (pin) => Effect.gen(function* () {
|
|
354
|
+
const version = pin.version.toString();
|
|
355
|
+
const target = Option.getOrUndefined(bunTarget(runnerOs, arch));
|
|
356
|
+
if (target === void 0) return yield* Effect.fail(errorFor(pin)({
|
|
357
|
+
reason: "unsupportedPlatform",
|
|
358
|
+
subject: `${runnerOs || "unknown"}/${arch}`
|
|
359
|
+
}));
|
|
360
|
+
const url = `https://github.com/oven-sh/bun/releases/download/bun-v${version}/${target}.zip`;
|
|
361
|
+
const archive = yield* installer.download(url).pipe(Effect.mapError(fromInstaller(pin)));
|
|
362
|
+
yield* verifyIntegrity(pin, archive);
|
|
363
|
+
const extracted = yield* installer.extractZip(archive).pipe(Effect.mapError(fromInstaller(pin)));
|
|
364
|
+
const binary = path.join(extracted, target, bunBinaryName);
|
|
365
|
+
yield* assertFile(pin, binary, `${target}/${bunBinaryName} is missing from the zip`);
|
|
366
|
+
if (!windows) yield* fs.chmod(binary, 493).pipe(Effect.mapError((cause) => errorFor(pin)({
|
|
367
|
+
reason: "cacheFailed",
|
|
368
|
+
subject: binary,
|
|
369
|
+
cause
|
|
370
|
+
})));
|
|
371
|
+
const directory = yield* installer.cacheFile(binary, bunBinaryName, pin.name, version).pipe(Effect.mapError(fromInstaller(pin)));
|
|
372
|
+
return CachedPackageManager.make({
|
|
373
|
+
name: pin.name,
|
|
374
|
+
version,
|
|
375
|
+
directory,
|
|
376
|
+
binDir: directory,
|
|
377
|
+
bins: { bun: path.join(directory, bunBinaryName) }
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
/** Download a registry tarball, verify, extract, shim, and cache the package dir. */
|
|
381
|
+
const installFromRegistry = (pin, name, registry) => Effect.gen(function* () {
|
|
382
|
+
const version = pin.version.toString();
|
|
383
|
+
const url = registryTarballUrl(name, version, pin.version.major, registry);
|
|
384
|
+
const archive = yield* installer.download(url).pipe(Effect.mapError(fromInstaller(pin)));
|
|
385
|
+
const berry = name === "yarn" && pin.version.major >= 2;
|
|
386
|
+
if (!berry) yield* verifyIntegrity(pin, archive);
|
|
387
|
+
const extracted = yield* installer.extractTar(archive).pipe(Effect.mapError(fromInstaller(pin)));
|
|
388
|
+
const packageDir = path.join(extracted, "package");
|
|
389
|
+
const stat = yield* Effect.result(fs.stat(packageDir));
|
|
390
|
+
if (stat._tag !== "Success" || stat.success.type !== "Directory") return yield* Effect.fail(errorFor(pin)({
|
|
391
|
+
reason: "layoutUnexpected",
|
|
392
|
+
subject: "tarball has no package/ root"
|
|
393
|
+
}));
|
|
394
|
+
const bins = yield* readPackageBins(pin, packageDir);
|
|
395
|
+
if (berry) {
|
|
396
|
+
const cli = path.join(packageDir, "bin/yarn.js");
|
|
397
|
+
yield* assertFile(pin, cli, "bin/yarn.js is missing from the cli-dist tarball");
|
|
398
|
+
yield* verifyIntegrity(pin, cli);
|
|
399
|
+
}
|
|
400
|
+
const destination = finalCachePath(name, version);
|
|
401
|
+
yield* writeShims(pin, packageDir, destination, bins, { skipExisting: false });
|
|
402
|
+
const directory = yield* installer.cacheDir(packageDir, name, version).pipe(Effect.mapError(fromInstaller(pin)));
|
|
403
|
+
if (directory !== destination) return yield* Effect.fail(errorFor(pin)({
|
|
404
|
+
reason: "cacheFailed",
|
|
405
|
+
subject: `cache destination diverged: shims name ${destination}, entry landed at ${directory}`
|
|
406
|
+
}));
|
|
407
|
+
return CachedPackageManager.make({
|
|
408
|
+
name,
|
|
409
|
+
version,
|
|
410
|
+
directory,
|
|
411
|
+
binDir: path.join(directory, SHIM_DIR),
|
|
412
|
+
bins: rootBins(bins, directory)
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
return { install: Effect.fn("PackageManagerInstaller.install")(function* (pin, options) {
|
|
416
|
+
const version = pin.version.toString();
|
|
417
|
+
yield* Effect.annotateCurrentSpan({
|
|
418
|
+
name: pin.name,
|
|
419
|
+
version
|
|
420
|
+
});
|
|
421
|
+
if (options?.requireIntegrity === true && pin.integrity === void 0) return yield* Effect.fail(errorFor(pin)({ reason: "integrityMissing" }));
|
|
422
|
+
const cached = yield* installer.find(pin.name, version);
|
|
423
|
+
if (Option.isSome(cached)) return yield* cachedRecord(pin, cached.value);
|
|
424
|
+
if (pin.name === "npm" && options?.allowAmbient !== false) {
|
|
425
|
+
const probed = yield* ambientNpmVersion;
|
|
426
|
+
if (Option.isSome(probed) && probed.value === version) return AmbientPackageManager.make({
|
|
427
|
+
name: pin.name,
|
|
428
|
+
version,
|
|
429
|
+
bins: {
|
|
430
|
+
npm: "npm",
|
|
431
|
+
npx: "npx"
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (pin.name === "bun") return yield* installBun(pin);
|
|
436
|
+
const registry = (options?.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
|
|
437
|
+
return yield* installFromRegistry(pin, pin.name, registry);
|
|
438
|
+
}) };
|
|
439
|
+
});
|
|
440
|
+
const unimplemented = (member) => {
|
|
441
|
+
throw new Error(`PackageManagerInstaller.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
442
|
+
};
|
|
443
|
+
/**
|
|
444
|
+
* First-class exact-version package-manager provisioning on a GitHub runner.
|
|
445
|
+
*
|
|
446
|
+
* @remarks
|
|
447
|
+
* Takes a corepack pin (`@effected/npm`'s `PackageManagerPin`) and answers
|
|
448
|
+
* with an installed manager: tool-cache `find` first, then — for npm — an
|
|
449
|
+
* ambient `npm --version` probe (every Node toolchain ships npm), and only
|
|
450
|
+
* then the manager's own dist: npm, pnpm and yarn 1.x from their registry
|
|
451
|
+
* tarballs, yarn 2+ (Berry) from `@yarnpkg/cli-dist`, bun from its
|
|
452
|
+
* per-platform GitHub-release zip. A pin that carries integrity is verified
|
|
453
|
+
* fail-closed against what corepack itself hashes; one that does not proceeds
|
|
454
|
+
* with a logged warning unless `requireIntegrity` says otherwise.
|
|
455
|
+
*
|
|
456
|
+
* Every tool-cache answer carries a `binDir` a consumer can hand straight to
|
|
457
|
+
* `ActionOutputs.addPath`: the npm-registry managers get a `.bin` directory
|
|
458
|
+
* of executable shims written as part of the cached entry, bun's entry is its
|
|
459
|
+
* own `binDir`. Caching goes through {@link ToolInstaller}, so the
|
|
460
|
+
* stage-then-swap invariant holds here for free — shims included, the tool
|
|
461
|
+
* cache only ever contains complete package managers.
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* ```ts
|
|
465
|
+
* import { ActionOutputs, PackageManagerInstaller } from "@effected/github-actions";
|
|
466
|
+
* import { PackageManagerPin } from "@effected/npm";
|
|
467
|
+
* import { Effect } from "effect";
|
|
468
|
+
*
|
|
469
|
+
* const provision = Effect.gen(function* () {
|
|
470
|
+
* const installer = yield* PackageManagerInstaller;
|
|
471
|
+
* const outputs = yield* ActionOutputs;
|
|
472
|
+
* const pin = yield* PackageManagerPin.parse("pnpm@10.13.1");
|
|
473
|
+
* const installed = yield* installer.install(pin);
|
|
474
|
+
* if (installed.source === "tool-cache") {
|
|
475
|
+
* yield* outputs.addPath(installed.binDir);
|
|
476
|
+
* }
|
|
477
|
+
* });
|
|
478
|
+
* ```
|
|
479
|
+
*
|
|
480
|
+
* @public
|
|
481
|
+
*/
|
|
482
|
+
var PackageManagerInstaller = class PackageManagerInstaller extends Context.Service()("@effected/github-actions/PackageManagerInstaller") {
|
|
483
|
+
static layer = Layer.effect(this, make);
|
|
484
|
+
/** A test double. The unstubbed member dies rather than reporting an install that did not happen. */
|
|
485
|
+
static makeTest = (overrides = {}) => ({
|
|
486
|
+
install: () => Effect.sync(() => unimplemented("install")),
|
|
487
|
+
...overrides
|
|
488
|
+
});
|
|
489
|
+
/** {@link PackageManagerInstaller.makeTest} behind `Layer.succeed`. */
|
|
490
|
+
static layerTest = (overrides = {}) => Layer.succeed(PackageManagerInstaller, PackageManagerInstaller.makeTest(overrides));
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
//#endregion
|
|
494
|
+
export { AmbientPackageManager, CachedPackageManager, InstalledPackageManager, PackageManagerInstaller, PackageManagerInstallerError };
|
package/Secret.js
CHANGED
|
@@ -49,6 +49,16 @@ var Secret = class Secret {
|
|
|
49
49
|
* @remarks
|
|
50
50
|
* Every value is masked before any plaintext is returned — including when
|
|
51
51
|
* one of them fails, because masking happens for the whole set first.
|
|
52
|
+
*
|
|
53
|
+
* **Detached workers invert the masking model.** Masking works because the
|
|
54
|
+
* runner parses this process's stdout; a *detached* child's stdout is a log
|
|
55
|
+
* file no runner parses, so a mask emitted **inside** the worker is inert
|
|
56
|
+
* and writes the plaintext verbatim into that log — a shipped incident, not
|
|
57
|
+
* a hypothetical. The rule: **the parent masks, before the spawn** — call
|
|
58
|
+
* this member in the parent, where the runner is listening, and hand the
|
|
59
|
+
* already-masked plaintext to the worker's environment. Inside the worker,
|
|
60
|
+
* compose `ActionOutputs.layerDetached`, whose `setSecret` is a documented
|
|
61
|
+
* no-op, so the same code cannot re-leak what the parent already masked.
|
|
52
62
|
*/
|
|
53
63
|
static forChildEnv = (entries) => Effect.gen(function* () {
|
|
54
64
|
const outputs = yield* ActionOutputs;
|
|
@@ -102,6 +112,17 @@ var Secret = class Secret {
|
|
|
102
112
|
*
|
|
103
113
|
* Call it **once, at layer construction**, not per operation: masking is
|
|
104
114
|
* idempotent but a workflow command per request is noise in the log.
|
|
115
|
+
*
|
|
116
|
+
* **In a detached worker, the mask this member emits is a leak.** The
|
|
117
|
+
* `::add-mask::` command only masks when the runner parses this process's
|
|
118
|
+
* stdout; a detached worker's stdout is a log file no runner parses, so
|
|
119
|
+
* there the command masks nothing *and* spells the plaintext into the log
|
|
120
|
+
* — a signing key shipped exactly that way for one round. A worker that
|
|
121
|
+
* signs (an S3-style `BlobStore` is the canonical case) must compose
|
|
122
|
+
* `ActionOutputs.layerDetached`, under which this member still returns the
|
|
123
|
+
* raw key for the HMAC but the mask is a documented no-op; the masking
|
|
124
|
+
* itself is the **parent's** job, done before the spawn via
|
|
125
|
+
* {@link Secret.forChildEnv} under the real layer.
|
|
105
126
|
*/
|
|
106
127
|
static forSigning = (secret) => Secret.forRunnerFile(secret);
|
|
107
128
|
/**
|