@effected/github-actions 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ToolInstaller.js CHANGED
@@ -53,6 +53,8 @@ var ToolInstallerError = class extends Schema.TaggedErrorClass()("ToolInstallerE
53
53
  };
54
54
  /** How many times a retryable download is retried before giving up. */
55
55
  const DOWNLOAD_RETRIES = 2;
56
+ /** The default per-attempt download budget. See {@link ToolDownloadOptions.timeout}. */
57
+ const DOWNLOAD_TIMEOUT = "5 minutes";
56
58
  const make = Effect.gen(function* () {
57
59
  const env = yield* ActionEnvironment;
58
60
  const fs = yield* FileSystem.FileSystem;
@@ -83,29 +85,37 @@ const make = Effect.gen(function* () {
83
85
  })));
84
86
  };
85
87
  /**
86
- * Run an extraction command, keeping its stderr.
88
+ * Run an extraction command ONCE, keeping its stderr.
87
89
  *
88
90
  * @remarks
89
91
  * `tar`'s exit code says only that it failed; its stderr says why, and it is
90
92
  * the difference between "this is not a gzip archive" and "tar is not
91
93
  * installed". Discarding it is what makes an extraction failure unactionable.
94
+ *
95
+ * **One spawn, not two — load-bearing.** The spawner's convenience members
96
+ * each spawn independently: `string` collects output without inspecting the
97
+ * exit code, and `exitCode` runs the command AGAIN (core's
98
+ * `ChildProcessSpawner.make` derives both from `spawn`). Calling them
99
+ * back-to-back double-executed every extraction — harmless for idempotent
100
+ * `tar`/`unzip -o`, but .NET's `ZipFile.ExtractToDirectory` refuses to
101
+ * overwrite, so the second run failed 5/5 on real Windows runners while the
102
+ * captured "complaint" was the FIRST run's silent success. Output and exit
103
+ * code must come from the same `spawn` handle.
92
104
  */
93
- const extractWith = (command, archive) => Effect.gen(function* () {
94
- const output = yield* spawner.string(command, { includeStderr: true }).pipe(Effect.mapError((cause) => new ToolInstallerError({
95
- reason: "extractFailed",
96
- subject: archive,
97
- cause
98
- })));
99
- if ((yield* spawner.exitCode(command).pipe(Effect.mapError((cause) => new ToolInstallerError({
105
+ const extractWith = (command, archive) => Effect.scoped(Effect.gen(function* () {
106
+ const extractError = (cause) => new ToolInstallerError({
100
107
  reason: "extractFailed",
101
108
  subject: archive,
102
109
  cause
103
- })))) !== 0) return yield* Effect.fail(new ToolInstallerError({
110
+ });
111
+ const handle = yield* spawner.spawn(command).pipe(Effect.mapError(extractError));
112
+ const output = yield* Stream.mkString(Stream.decodeText(handle.all)).pipe(Effect.mapError(extractError));
113
+ if ((yield* handle.exitCode.pipe(Effect.mapError(extractError))) !== 0) return yield* Effect.fail(new ToolInstallerError({
104
114
  reason: "extractFailed",
105
115
  subject: archive,
106
116
  stderr: output.trim()
107
117
  }));
108
- });
118
+ }));
109
119
  /**
110
120
  * Install a staged directory into the tool cache by renaming it into place.
111
121
  *
@@ -146,38 +156,55 @@ const make = Effect.gen(function* () {
146
156
  recursive: true,
147
157
  force: true
148
158
  }))))));
149
- return {
150
- find: (tool, version) => Effect.map(Effect.result(fs.stat(cachePath(tool, version))), (result) => result._tag === "Success" && result.success.type === "Directory" ? Option.some(cachePath(tool, version)) : Option.none()),
151
- download: Effect.fn("ToolInstaller.download")(function* (url) {
152
- return yield* Effect.gen(function* () {
153
- const response = yield* http.get(url).pipe(Effect.mapError((cause) => new ToolInstallerError({
154
- reason: "downloadFailed",
155
- subject: url,
156
- cause
157
- })));
158
- if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new ToolInstallerError({
159
- reason: "downloadFailed",
160
- subject: url,
161
- status: response.status
162
- }));
163
- const directory = yield* fs.makeTempDirectory({ prefix: "effected-download-" }).pipe(Effect.mapError((cause) => new ToolInstallerError({
164
- reason: "downloadFailed",
165
- subject: url,
166
- cause
167
- })));
168
- const file = path.join(directory, "download");
169
- yield* Stream.run(response.stream, fs.sink(file)).pipe(Effect.mapError((cause) => new ToolInstallerError({
170
- reason: "downloadFailed",
171
- subject: url,
172
- cause
173
- })));
174
- return file;
175
- }).pipe(Effect.retry({
176
- schedule: Schedule.exponential("1 second"),
177
- times: DOWNLOAD_RETRIES,
178
- while: (error) => error.retryable
159
+ const download = Effect.fn("ToolInstaller.download")(function* (url, options) {
160
+ return yield* Effect.gen(function* () {
161
+ const response = yield* http.get(url).pipe(Effect.mapError((cause) => new ToolInstallerError({
162
+ reason: "downloadFailed",
163
+ subject: url,
164
+ cause
165
+ })));
166
+ if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new ToolInstallerError({
167
+ reason: "downloadFailed",
168
+ subject: url,
169
+ status: response.status
179
170
  }));
180
- }),
171
+ const directory = yield* fs.makeTempDirectory({ prefix: "effected-download-" }).pipe(Effect.mapError((cause) => new ToolInstallerError({
172
+ reason: "downloadFailed",
173
+ subject: url,
174
+ cause
175
+ })));
176
+ const file = path.join(directory, "download");
177
+ yield* Stream.run(response.stream, fs.sink(file)).pipe(Effect.mapError((cause) => new ToolInstallerError({
178
+ reason: "downloadFailed",
179
+ subject: url,
180
+ cause
181
+ })));
182
+ return file;
183
+ }).pipe(Effect.timeout(options?.timeout ?? DOWNLOAD_TIMEOUT), Effect.catchTag("TimeoutError", (cause) => Effect.fail(new ToolInstallerError({
184
+ reason: "downloadFailed",
185
+ subject: url,
186
+ cause
187
+ }))), Effect.retry({
188
+ schedule: Schedule.exponential("1 second"),
189
+ times: DOWNLOAD_RETRIES,
190
+ while: (error) => error.retryable
191
+ }));
192
+ });
193
+ const find = (tool, version) => Effect.map(Effect.result(fs.stat(cachePath(tool, version))), (result) => result._tag === "Success" && result.success.type === "Directory" ? Option.some(cachePath(tool, version)) : Option.none());
194
+ const cacheFile = Effect.fn("ToolInstaller.cacheFile")(function* (source, name, tool, version) {
195
+ yield* Effect.annotateCurrentSpan({
196
+ tool,
197
+ version
198
+ });
199
+ return yield* staged(tool, (staging) => fs.copyFile(source, path.join(staging, name)).pipe(Effect.mapError((cause) => new ToolInstallerError({
200
+ reason: "cacheFailed",
201
+ subject: tool,
202
+ cause
203
+ })), Effect.flatMap(() => swapIntoCache(staging, tool, version))));
204
+ });
205
+ return {
206
+ find,
207
+ download,
181
208
  extractTar: Effect.fn("ToolInstaller.extractTar")(function* (archive, options) {
182
209
  const destination = yield* destinationFor(options);
183
210
  const flags = options?.flags === void 0 || options.flags.length === 0 ? ["xzf"] : [...options.flags];
@@ -195,7 +222,7 @@ const make = Effect.gen(function* () {
195
222
  "-NoProfile",
196
223
  "-NonInteractive",
197
224
  "-Command",
198
- `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archive.replaceAll("'", "''")}', '${destination.replaceAll("'", "''")}')`
225
+ `$ErrorActionPreference = 'Stop'; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archive.replaceAll("'", "''")}', '${destination.replaceAll("'", "''")}', $true) } catch { [Console]::Error.WriteLine($_.Exception.ToString()); exit 1 }`
199
226
  ]) : ChildProcess.make("unzip", [
200
227
  "-oq",
201
228
  archive,
@@ -216,16 +243,31 @@ const make = Effect.gen(function* () {
216
243
  cause
217
244
  })), Effect.flatMap(() => swapIntoCache(staging, tool, version))));
218
245
  }),
219
- cacheFile: Effect.fn("ToolInstaller.cacheFile")(function* (source, name, tool, version) {
246
+ cacheFile,
247
+ provisionFile: Effect.fn("ToolInstaller.provisionFile")(function* (options) {
220
248
  yield* Effect.annotateCurrentSpan({
221
- tool,
222
- version
249
+ tool: options.tool,
250
+ version: options.version
223
251
  });
224
- return yield* staged(tool, (staging) => fs.copyFile(source, path.join(staging, name)).pipe(Effect.mapError((cause) => new ToolInstallerError({
252
+ const cached = yield* find(options.tool, options.version);
253
+ if (Option.isSome(cached)) {
254
+ const present = yield* Effect.result(fs.stat(path.join(cached.value, options.binary)));
255
+ if (present._tag === "Success" && present.success.type === "File") return {
256
+ directory: cached.value,
257
+ binDir: cached.value
258
+ };
259
+ }
260
+ const file = yield* download(options.url);
261
+ if (!windows) yield* fs.chmod(file, 493).pipe(Effect.mapError((cause) => new ToolInstallerError({
225
262
  reason: "cacheFailed",
226
- subject: tool,
263
+ subject: file,
227
264
  cause
228
- })), Effect.flatMap(() => swapIntoCache(staging, tool, version))));
265
+ })));
266
+ const directory = yield* cacheFile(file, options.binary, options.tool, options.version);
267
+ return {
268
+ directory,
269
+ binDir: directory
270
+ };
229
271
  })
230
272
  };
231
273
  });
@@ -236,10 +278,13 @@ const unimplemented = (member) => {
236
278
  * Download, extract and cache a toolchain in the runner's tool cache.
237
279
  *
238
280
  * @remarks
239
- * The primitives, not a workflow: a consumer composes `find` → `download` →
240
- * `extractTar` → `cacheDir` in whatever order its tool needs, because the
241
- * orders genuinely differ (a single binary skips extraction, a tarball with a
242
- * nested root directory needs a path fixup between extract and cache).
281
+ * The primitives, plus exactly one packaged workflow: a consumer composes
282
+ * `find` → `download` → `extractTar` → `cacheDir` in whatever order its tool
283
+ * needs, because the orders genuinely differ (a tarball with a nested root
284
+ * directory needs a path fixup between extract and cache). The one shape with
285
+ * no per-tool variation — a single bare binary, which skips extraction and
286
+ * needs only the executable bit — ships whole as
287
+ * {@link ToolInstallerShape.provisionFile}.
243
288
  *
244
289
  * No `@actions/tool-cache` dependency — the cache is a directory layout, and
245
290
  * this reproduces the layout rather than importing a package to compute it.
@@ -283,6 +328,7 @@ var ToolInstaller = class ToolInstaller extends Context.Service()("@effected/git
283
328
  extractZip: () => Effect.sync(() => unimplemented("extractZip")),
284
329
  cacheDir: () => Effect.sync(() => unimplemented("cacheDir")),
285
330
  cacheFile: () => Effect.sync(() => unimplemented("cacheFile")),
331
+ provisionFile: () => Effect.sync(() => unimplemented("provisionFile")),
286
332
  ...overrides
287
333
  });
288
334
  /** {@link ToolInstaller.makeTest} behind `Layer.succeed`. */