@effected/github-actions 0.1.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.
@@ -0,0 +1,246 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { Context, Effect, Layer, Redacted, Schema } from "effect";
3
+ import { HttpClient } from "effect/unstable/http";
4
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
5
+
6
+ //#region src/OidcTokenIssuer.ts
7
+ /**
8
+ * Raised when an OIDC token cannot be issued or read.
9
+ *
10
+ * @public
11
+ */
12
+ var OidcTokenError = class extends Schema.TaggedErrorClass()("OidcTokenError", {
13
+ /**
14
+ * `unavailable` — the runner did not publish the token-service variables,
15
+ * which almost always means the workflow is missing `permissions: id-token:
16
+ * write`. `requestFailed` — the token service could not be reached or refused
17
+ * the request. `malformedResponse` — it answered with something that is not a
18
+ * token envelope. `malformedToken` — the token is not a decodable JWT.
19
+ * `missingClaims` — it decoded, but without the claims a provenance statement
20
+ * needs.
21
+ */
22
+ reason: Schema.Literals([
23
+ "unavailable",
24
+ "requestFailed",
25
+ "malformedResponse",
26
+ "malformedToken",
27
+ "missingClaims"
28
+ ]),
29
+ /** What was wrong, in one line. */
30
+ detail: Schema.optionalKey(Schema.String),
31
+ /** The HTTP status, when the token service answered. */
32
+ status: Schema.optionalKey(Schema.Number),
33
+ /** The underlying failure, preserved structurally. */
34
+ cause: Schema.optionalKey(Schema.Defect())
35
+ }) {
36
+ get message() {
37
+ switch (this.reason) {
38
+ case "unavailable": return "The runner published no OIDC token service; the workflow needs `permissions: id-token: write`";
39
+ case "requestFailed": return `The OIDC token request failed${this.status === void 0 ? "" : ` with status ${this.status}`}`;
40
+ case "malformedResponse": return "The OIDC token service answered with an unexpected shape";
41
+ case "malformedToken": return `The OIDC token is not a decodable JWT${this.detail === void 0 ? "" : `: ${this.detail}`}`;
42
+ default: return "The OIDC token is missing claims a provenance statement needs";
43
+ }
44
+ }
45
+ };
46
+ /**
47
+ * The claims a GitHub Actions OIDC token carries about the workflow that ran.
48
+ *
49
+ * @remarks
50
+ * The field names are GitHub's own JWT claim names, kept verbatim rather than
51
+ * translated to the kit's casing. They are an **external vocabulary** — the
52
+ * same strings that appear in a SLSA provenance predicate and in every
53
+ * published example of an Actions OIDC policy — and renaming them would put a
54
+ * translation layer between a consumer and the specification they are reading.
55
+ *
56
+ * @public
57
+ */
58
+ var OidcClaims = class extends Schema.Class("OidcClaims")({
59
+ /** The issuer, e.g. `https://token.actions.githubusercontent.com`. */
60
+ iss: Schema.String,
61
+ /** The full ref the workflow ran on. */
62
+ ref: Schema.String,
63
+ /** The commit. */
64
+ sha: Schema.String,
65
+ /** `owner/repo`. */
66
+ repository: Schema.String,
67
+ /** The event that triggered the run. */
68
+ event_name: Schema.String,
69
+ /** The reusable-workflow ref, which is what a verifier pins against. */
70
+ job_workflow_ref: Schema.String,
71
+ /** The calling workflow's ref. */
72
+ workflow_ref: Schema.String,
73
+ /** The numeric repository id, as a string. */
74
+ repository_id: Schema.String,
75
+ /** The numeric owner id, as a string. */
76
+ repository_owner_id: Schema.String,
77
+ /** `github-hosted` or `self-hosted`. */
78
+ runner_environment: Schema.String,
79
+ /** The run id, as a string. */
80
+ run_id: Schema.String,
81
+ /** The run attempt, as a string. */
82
+ run_attempt: Schema.String
83
+ }) {};
84
+ /** The token envelope the runner's token service answers with. */
85
+ const TokenEnvelope = Schema.Struct({
86
+ value: Schema.String,
87
+ count: Schema.optionalKey(Schema.Number)
88
+ });
89
+ const REQUEST_TOKEN = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
90
+ const REQUEST_URL = "ACTIONS_ID_TOKEN_REQUEST_URL";
91
+ /**
92
+ * Decode a JWT payload segment.
93
+ *
94
+ * @remarks
95
+ * `base64url` states the intent, though it is not what makes this work: Node's
96
+ * plain `base64` decoder happens to accept the url alphabet and unpadded input
97
+ * too (probed at beta.101), so the two are interchangeable *here*. They are not
98
+ * interchangeable everywhere — `atob` is not forgiving — which is why the
99
+ * declaration is the strict one and there is a test whose payload actually
100
+ * contains `-` and `_`.
101
+ */
102
+ const decodeSegment = (segment) => JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
103
+ /**
104
+ * Read the claims out of a JWT **without verifying its signature**.
105
+ *
106
+ * @remarks
107
+ * The absence of verification is deliberate and is documented on
108
+ * {@link OidcTokenIssuerShape.claims}; do not "fix" it here.
109
+ */
110
+ const readClaims = (token) => Effect.gen(function* () {
111
+ const parts = token.split(".");
112
+ const payload = parts[1];
113
+ if (parts.length !== 3 || payload === void 0 || payload === "") return yield* Effect.fail(new OidcTokenError({
114
+ reason: "malformedToken",
115
+ detail: `expected three segments, got ${parts.length}`
116
+ }));
117
+ const decoded = yield* Effect.try({
118
+ try: () => decodeSegment(payload),
119
+ catch: (cause) => new OidcTokenError({
120
+ reason: "malformedToken",
121
+ detail: "the payload is not base64url JSON",
122
+ cause
123
+ })
124
+ });
125
+ return yield* Schema.decodeUnknownEffect(OidcClaims)(decoded).pipe(Effect.mapError((cause) => new OidcTokenError({
126
+ reason: "missingClaims",
127
+ cause
128
+ })));
129
+ });
130
+ const make = Effect.gen(function* () {
131
+ const env = yield* ActionEnvironment;
132
+ const http = yield* HttpClient.HttpClient;
133
+ const required = (name) => env.get(name).pipe(Effect.mapError(() => new OidcTokenError({
134
+ reason: "unavailable",
135
+ detail: name
136
+ })));
137
+ /**
138
+ * The raw JWT, before it is wrapped.
139
+ *
140
+ * @remarks
141
+ * `claims` reads this rather than unwrapping what `token` returns, so
142
+ * `Redacted.value` does not appear in this module at all — the package's
143
+ * declassification invariant is that `Secret.ts` is the only place a secret
144
+ * becomes a string, and a wrap-then-immediately-unwrap here would be a
145
+ * genuine exception to it rather than a cosmetic one. A structural test over
146
+ * `src/` enforces this, and it caught exactly this call when the first draft
147
+ * did it the obvious way.
148
+ */
149
+ const issue = Effect.fn("OidcTokenIssuer.token")(function* (audience) {
150
+ const bearer = yield* required(REQUEST_TOKEN);
151
+ const base = yield* required(REQUEST_URL);
152
+ const url = audience === void 0 ? base : `${base}&audience=${encodeURIComponent(audience)}`;
153
+ const response = yield* http.get(url, { headers: {
154
+ authorization: `Bearer ${bearer}`,
155
+ accept: "application/json"
156
+ } }).pipe(Effect.mapError((cause) => new OidcTokenError({
157
+ reason: "requestFailed",
158
+ cause
159
+ })));
160
+ if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new OidcTokenError({
161
+ reason: "requestFailed",
162
+ status: response.status
163
+ }));
164
+ return (yield* HttpClientResponse.schemaBodyJson(TokenEnvelope)(response).pipe(Effect.mapError((cause) => new OidcTokenError({
165
+ reason: "malformedResponse",
166
+ cause
167
+ })))).value;
168
+ });
169
+ return {
170
+ token: (audience) => Effect.map(issue(audience), Redacted.make),
171
+ claims: Effect.fn("OidcTokenIssuer.claims")(function* (audience) {
172
+ return yield* readClaims(yield* issue(audience));
173
+ })
174
+ };
175
+ });
176
+ const unimplemented = (member) => {
177
+ throw new Error(`OidcTokenIssuer.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
178
+ };
179
+ /**
180
+ * The runner's OIDC token service.
181
+ *
182
+ * @remarks
183
+ * Lives here rather than with attestation because it reads
184
+ * `ACTIONS_ID_TOKEN_REQUEST_TOKEN` and `ACTIONS_ID_TOKEN_REQUEST_URL`, which
185
+ * exist only when a workflow declares `permissions: id-token: write` — that is
186
+ * a fact about the runner, not about signing.
187
+ *
188
+ * @example
189
+ * ```ts
190
+ * import { OidcTokenIssuer } from "@effected/github-actions";
191
+ * import { Effect } from "effect";
192
+ *
193
+ * const program = Effect.gen(function* () {
194
+ * const issuer = yield* OidcTokenIssuer;
195
+ * const claims = yield* issuer.claims("sigstore");
196
+ * return claims.job_workflow_ref;
197
+ * });
198
+ * ```
199
+ *
200
+ * @public
201
+ */
202
+ var OidcTokenIssuer = class OidcTokenIssuer extends Context.Service()("@effected/github-actions/OidcTokenIssuer") {
203
+ static layer = Layer.effect(this, make);
204
+ /**
205
+ * An **unsigned** JWT carrying these claims.
206
+ *
207
+ * @remarks
208
+ * For building test doubles, and nothing else: the signature segment is a
209
+ * placeholder, so this token would fail any verifier. It exists because the
210
+ * alternative — a double returning a synthetic non-JWT — is what made the
211
+ * provenance path structurally unreachable in the source package, drawing
212
+ * four separate apologetic comments from one consumer.
213
+ */
214
+ static unsignedTokenFor = (claims) => {
215
+ const segment = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
216
+ return Redacted.make(`${segment({
217
+ alg: "RS256",
218
+ typ: "JWT"
219
+ })}.${segment(Schema.encodeUnknownSync(OidcClaims)(claims))}.unsigned`);
220
+ };
221
+ /** A test double. Unstubbed members die rather than answering with a non-token. */
222
+ static makeTest = (overrides = {}) => ({
223
+ token: () => Effect.sync(() => unimplemented("token")),
224
+ claims: () => Effect.sync(() => unimplemented("claims")),
225
+ ...overrides
226
+ });
227
+ /** {@link OidcTokenIssuer.makeTest} behind `Layer.succeed`. */
228
+ static layerTest = (overrides = {}) => Layer.succeed(OidcTokenIssuer, OidcTokenIssuer.makeTest(overrides));
229
+ /**
230
+ * A double that answers with these claims, consistently on both members.
231
+ *
232
+ * @remarks
233
+ * `token()` returns a real decodable JWT built from the same claims
234
+ * `claims()` returns, so a consumer that decodes the token itself and a
235
+ * consumer that asks the service both see the same thing. A double whose two
236
+ * members can disagree is how a test proves a path works while production
237
+ * takes the other one.
238
+ */
239
+ static layerFor = (claims) => Layer.succeed(OidcTokenIssuer, {
240
+ token: () => Effect.succeed(OidcTokenIssuer.unsignedTokenFor(claims)),
241
+ claims: () => Effect.succeed(claims)
242
+ });
243
+ };
244
+
245
+ //#endregion
246
+ export { OidcClaims, OidcTokenError, OidcTokenIssuer };
package/Secret.js ADDED
@@ -0,0 +1,106 @@
1
+ import { ActionOutputs } from "./ActionOutputs.js";
2
+ import { Config, Effect, Redacted } from "effect";
3
+
4
+ //#region src/Secret.ts
5
+ /**
6
+ * The declassification seam: the **only** place in this package where a
7
+ * `Redacted` value becomes a plain string.
8
+ *
9
+ * @remarks
10
+ * Some boundaries cannot carry a `Redacted`. A detached child process reads
11
+ * its configuration from environment variables; `GITHUB_STATE` is a plaintext
12
+ * file by GitHub's protocol. `Redacted` refusing to serialize is the whole
13
+ * value of the type, so this module does not try to make it serializable —
14
+ * it makes declassification **explicit, auditable, and impossible to do
15
+ * quietly**.
16
+ *
17
+ * The invariant: **masking and declassification are the same call.** Every
18
+ * member registers the value with the runner's log filter (`::add-mask::`)
19
+ * *before* returning plaintext, so a secret cannot reach a child's environment
20
+ * or a state file without the runner already knowing to redact it from logs.
21
+ *
22
+ * `Redacted.value` appears nowhere else in this package, and a test asserts
23
+ * that structurally — a reintroduction elsewhere fails the suite rather than
24
+ * review.
25
+ *
26
+ * What this deliberately does **not** do is encrypt the handoff. The child
27
+ * runs on the same runner, as the same user, as the parent; a key would have
28
+ * to travel the same channel as the secret it protects.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { Secret } from "@effected/github-actions";
33
+ * import { Effect } from "effect";
34
+ *
35
+ * const program = Effect.gen(function* () {
36
+ * const env = yield* Secret.forChildEnv({ MY_TOKEN: theToken });
37
+ * // every value in `env` is already masked in the runner log
38
+ * return env;
39
+ * });
40
+ * ```
41
+ *
42
+ * @public
43
+ */
44
+ var Secret = class Secret {
45
+ constructor() {}
46
+ /**
47
+ * Declassify a set of secrets for a child process's environment.
48
+ *
49
+ * @remarks
50
+ * Every value is masked before any plaintext is returned — including when
51
+ * one of them fails, because masking happens for the whole set first.
52
+ */
53
+ static forChildEnv = (entries) => Effect.gen(function* () {
54
+ const outputs = yield* ActionOutputs;
55
+ const declassified = {};
56
+ for (const [name, secret] of Object.entries(entries)) {
57
+ const plaintext = Redacted.value(secret);
58
+ yield* outputs.setSecret(plaintext);
59
+ declassified[name] = plaintext;
60
+ }
61
+ return declassified;
62
+ });
63
+ /**
64
+ * Declassify one secret for a runner file (`GITHUB_STATE`, `GITHUB_OUTPUT`).
65
+ *
66
+ * @remarks
67
+ * Masks first. The runner file is plaintext regardless; the mask is what
68
+ * keeps the value out of the visible log.
69
+ */
70
+ static forRunnerFile = (secret) => Effect.gen(function* () {
71
+ const outputs = yield* ActionOutputs;
72
+ const plaintext = Redacted.value(secret);
73
+ yield* outputs.setSecret(plaintext);
74
+ return plaintext;
75
+ });
76
+ /**
77
+ * Declassify one secret for an in-process use that needs the raw bytes.
78
+ *
79
+ * @remarks
80
+ * Request signing is the case this exists for: an HMAC cannot be computed
81
+ * over a `Redacted`, so a signing key has to become a string somewhere, and
82
+ * the point of this module is that "somewhere" is here and nowhere else.
83
+ *
84
+ * It masks even though the value is never *written* anywhere, and that is
85
+ * deliberate rather than superstitious: a signing key that leaks does so
86
+ * through something nobody audited — a debug log of outgoing headers, a
87
+ * serialized error, a stack trace carrying the closure. Registering it with
88
+ * the runner's filter costs one call and makes every one of those redacted.
89
+ *
90
+ * Call it **once, at layer construction**, not per operation: masking is
91
+ * idempotent but a workflow command per request is noise in the log.
92
+ */
93
+ static forSigning = (secret) => Secret.forRunnerFile(secret);
94
+ /**
95
+ * The far side of a handoff: re-wrap a plaintext environment variable.
96
+ *
97
+ * @remarks
98
+ * A `Config`, so a missing or empty handoff is an honest `ConfigError`
99
+ * naming the variable rather than an empty `Redacted` that fails much later
100
+ * as an authentication error.
101
+ */
102
+ static adopt = (name) => Config.redacted(name);
103
+ };
104
+
105
+ //#endregion
106
+ export { Secret };
@@ -0,0 +1,293 @@
1
+ import { ActionEnvironment } from "./ActionEnvironment.js";
2
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schedule, Schema, Stream } from "effect";
3
+ import { HttpClient } from "effect/unstable/http";
4
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
5
+
6
+ //#region src/ToolInstaller.ts
7
+ /**
8
+ * Raised when a tool cannot be downloaded, extracted or cached.
9
+ *
10
+ * @public
11
+ */
12
+ var ToolInstallerError = class extends Schema.TaggedErrorClass()("ToolInstallerError", {
13
+ /**
14
+ * `downloadFailed` — the archive could not be fetched. `extractFailed` — the
15
+ * extraction tool refused the archive or is not installed. `cacheFailed` —
16
+ * the tool could not be written into the runner's tool cache.
17
+ */
18
+ reason: Schema.Literals([
19
+ "downloadFailed",
20
+ "extractFailed",
21
+ "cacheFailed"
22
+ ]),
23
+ /** The HTTP status, when there was one. Drives the retry decision. */
24
+ status: Schema.optionalKey(Schema.Number),
25
+ /** What was being worked on — a url, an archive path, or a tool name. */
26
+ subject: Schema.optionalKey(Schema.String),
27
+ /** The extraction tool's own complaint, which is the only useful part of a tar failure. */
28
+ stderr: Schema.optionalKey(Schema.String),
29
+ /** The underlying failure, preserved structurally. */
30
+ cause: Schema.optionalKey(Schema.Defect())
31
+ }) {
32
+ get message() {
33
+ switch (this.reason) {
34
+ case "downloadFailed": return `Could not download ${this.subject}${this.status === void 0 ? "" : ` (HTTP ${this.status})`}`;
35
+ case "extractFailed": return `Could not extract ${this.subject}${this.stderr === void 0 ? "" : `: ${this.stderr}`}`;
36
+ default: return `Could not cache ${this.subject} into the tool cache`;
37
+ }
38
+ }
39
+ /**
40
+ * Whether retrying could plausibly help.
41
+ *
42
+ * @remarks
43
+ * A derived getter rather than a field, so it cannot be set inconsistently
44
+ * with the status it is derived from. `408`, `429` and any `5xx` are the
45
+ * server saying "later"; a `404` is it saying "never", and retrying that
46
+ * three times just makes a broken url take longer to fail.
47
+ */
48
+ get retryable() {
49
+ if (this.reason !== "downloadFailed") return false;
50
+ if (this.status === void 0) return true;
51
+ return this.status >= 500 || this.status === 408 || this.status === 429;
52
+ }
53
+ };
54
+ /** How many times a retryable download is retried before giving up. */
55
+ const DOWNLOAD_RETRIES = 2;
56
+ const make = Effect.gen(function* () {
57
+ const env = yield* ActionEnvironment;
58
+ const fs = yield* FileSystem.FileSystem;
59
+ const path = yield* Path.Path;
60
+ const http = yield* HttpClient.HttpClient;
61
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
62
+ const root = yield* Effect.map(env.getOptional("RUNNER_TOOL_CACHE"), (found) => Option.getOrElse(found, () => path.join("/tmp", "runner-tool-cache")));
63
+ const windows = yield* Effect.map(env.getOptional("RUNNER_OS"), (found) => Option.match(found, {
64
+ onNone: () => false,
65
+ onSome: (os) => os.toLowerCase() === "windows"
66
+ }));
67
+ const cachePath = (tool, version) => ToolInstaller.cachePath({
68
+ root,
69
+ tool,
70
+ version,
71
+ arch: process.arch
72
+ });
73
+ const destinationFor = (options) => {
74
+ const requested = options?.destination;
75
+ if (requested === void 0) return fs.makeTempDirectory({ prefix: "effected-extract-" }).pipe(Effect.mapError((cause) => new ToolInstallerError({
76
+ reason: "extractFailed",
77
+ cause
78
+ })));
79
+ return fs.makeDirectory(requested, { recursive: true }).pipe(Effect.as(requested), Effect.mapError((cause) => new ToolInstallerError({
80
+ reason: "extractFailed",
81
+ subject: requested,
82
+ cause
83
+ })));
84
+ };
85
+ /**
86
+ * Run an extraction command, keeping its stderr.
87
+ *
88
+ * @remarks
89
+ * `tar`'s exit code says only that it failed; its stderr says why, and it is
90
+ * the difference between "this is not a gzip archive" and "tar is not
91
+ * installed". Discarding it is what makes an extraction failure unactionable.
92
+ */
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({
100
+ reason: "extractFailed",
101
+ subject: archive,
102
+ cause
103
+ })))) !== 0) return yield* Effect.fail(new ToolInstallerError({
104
+ reason: "extractFailed",
105
+ subject: archive,
106
+ stderr: output.trim()
107
+ }));
108
+ });
109
+ /**
110
+ * Install a staged directory into the tool cache by renaming it into place.
111
+ *
112
+ * @remarks
113
+ * **Stage then swap**, and the reason is that the alternative is worse than a
114
+ * failed install: copying straight into the cache path leaves a *partial*
115
+ * tool behind when the copy fails halfway, and `find` reports a partial
116
+ * directory as a hit. Every later run then uses a half-extracted toolchain
117
+ * and never re-downloads it. A rename within one filesystem is atomic, so the
118
+ * cache only ever contains complete tools.
119
+ */
120
+ const swapIntoCache = (staging, tool, version) => Effect.gen(function* () {
121
+ const destination = cachePath(tool, version);
122
+ yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
123
+ yield* fs.remove(destination, {
124
+ recursive: true,
125
+ force: true
126
+ });
127
+ yield* fs.rename(staging, destination);
128
+ return destination;
129
+ }).pipe(Effect.mapError((cause) => new ToolInstallerError({
130
+ reason: "cacheFailed",
131
+ subject: tool,
132
+ cause
133
+ })));
134
+ /** A staging directory beside the cache, so the swap is a rename and not a copy. */
135
+ const staged = (tool, use) => Effect.gen(function* () {
136
+ yield* fs.makeDirectory(root, { recursive: true });
137
+ return yield* fs.makeTempDirectory({
138
+ directory: root,
139
+ prefix: ".staging-"
140
+ });
141
+ }).pipe(Effect.mapError((cause) => new ToolInstallerError({
142
+ reason: "cacheFailed",
143
+ subject: tool,
144
+ cause
145
+ })), Effect.flatMap((staging) => use(staging).pipe(Effect.tapCause(() => Effect.ignore(fs.remove(staging, {
146
+ recursive: true,
147
+ force: true
148
+ }))))));
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
179
+ }));
180
+ }),
181
+ extractTar: Effect.fn("ToolInstaller.extractTar")(function* (archive, options) {
182
+ const destination = yield* destinationFor(options);
183
+ const flags = options?.flags === void 0 || options.flags.length === 0 ? ["xzf"] : [...options.flags];
184
+ yield* extractWith(ChildProcess.make("tar", [
185
+ ...flags,
186
+ archive,
187
+ "-C",
188
+ destination
189
+ ]), archive);
190
+ return destination;
191
+ }),
192
+ extractZip: Effect.fn("ToolInstaller.extractZip")(function* (archive, options) {
193
+ const destination = yield* destinationFor(options);
194
+ const command = windows ? ChildProcess.make("pwsh", [
195
+ "-NoProfile",
196
+ "-NonInteractive",
197
+ "-Command",
198
+ `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archive.replaceAll("'", "''")}', '${destination.replaceAll("'", "''")}')`
199
+ ]) : ChildProcess.make("unzip", [
200
+ "-oq",
201
+ archive,
202
+ "-d",
203
+ destination
204
+ ]);
205
+ yield* extractWith(command, archive);
206
+ return destination;
207
+ }),
208
+ cacheDir: Effect.fn("ToolInstaller.cacheDir")(function* (source, tool, version) {
209
+ yield* Effect.annotateCurrentSpan({
210
+ tool,
211
+ version
212
+ });
213
+ return yield* staged(tool, (staging) => fs.copy(source, staging, { overwrite: true }).pipe(Effect.mapError((cause) => new ToolInstallerError({
214
+ reason: "cacheFailed",
215
+ subject: tool,
216
+ cause
217
+ })), Effect.flatMap(() => swapIntoCache(staging, tool, version))));
218
+ }),
219
+ cacheFile: Effect.fn("ToolInstaller.cacheFile")(function* (source, name, tool, version) {
220
+ yield* Effect.annotateCurrentSpan({
221
+ tool,
222
+ version
223
+ });
224
+ return yield* staged(tool, (staging) => fs.copyFile(source, path.join(staging, name)).pipe(Effect.mapError((cause) => new ToolInstallerError({
225
+ reason: "cacheFailed",
226
+ subject: tool,
227
+ cause
228
+ })), Effect.flatMap(() => swapIntoCache(staging, tool, version))));
229
+ })
230
+ };
231
+ });
232
+ const unimplemented = (member) => {
233
+ throw new Error(`ToolInstaller.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
234
+ };
235
+ /**
236
+ * Download, extract and cache a toolchain in the runner's tool cache.
237
+ *
238
+ * @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).
243
+ *
244
+ * No `@actions/tool-cache` dependency — the cache is a directory layout, and
245
+ * this reproduces the layout rather than importing a package to compute it.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * import { ToolInstaller } from "@effected/github-actions";
250
+ * import { Effect, Option } from "effect";
251
+ *
252
+ * const install = Effect.gen(function* () {
253
+ * const installer = yield* ToolInstaller;
254
+ * const cached = yield* installer.find("node", "22.11.0");
255
+ * if (Option.isSome(cached)) return cached.value;
256
+ * const archive = yield* installer.download("https://nodejs.org/dist/v22.11.0/node.tar.gz");
257
+ * const extracted = yield* installer.extractTar(archive);
258
+ * return yield* installer.cacheDir(extracted, "node", "22.11.0");
259
+ * });
260
+ * ```
261
+ *
262
+ * @public
263
+ */
264
+ var ToolInstaller = class ToolInstaller extends Context.Service()("@effected/github-actions/ToolInstaller") {
265
+ static layer = Layer.effect(this, make);
266
+ /**
267
+ * Where a tool lives in the cache.
268
+ *
269
+ * @remarks
270
+ * Pure, exported and tested on its own, because the layout is a **contract
271
+ * with the runner**: `<root>/<tool>/<version>/<arch>` is what the hosted
272
+ * image populates and what `@actions/tool-cache` reads, so a tool cached at
273
+ * any other path is invisible to every other step in the workflow. The `arch`
274
+ * segment uses Node's `process.arch` spelling (`x64`) rather than the
275
+ * runner's `RUNNER_ARCH` (`X64`) for the same reason.
276
+ */
277
+ static cachePath = (options) => `${options.root}/${options.tool}/${options.version}/${options.arch}`;
278
+ /** A test double. Unstubbed members die rather than reporting a tool that is not there. */
279
+ static makeTest = (overrides = {}) => ({
280
+ find: () => Effect.sync(() => unimplemented("find")),
281
+ download: () => Effect.sync(() => unimplemented("download")),
282
+ extractTar: () => Effect.sync(() => unimplemented("extractTar")),
283
+ extractZip: () => Effect.sync(() => unimplemented("extractZip")),
284
+ cacheDir: () => Effect.sync(() => unimplemented("cacheDir")),
285
+ cacheFile: () => Effect.sync(() => unimplemented("cacheFile")),
286
+ ...overrides
287
+ });
288
+ /** {@link ToolInstaller.makeTest} behind `Layer.succeed`. */
289
+ static layerTest = (overrides = {}) => Layer.succeed(ToolInstaller, ToolInstaller.makeTest(overrides));
290
+ };
291
+
292
+ //#endregion
293
+ export { ToolInstaller, ToolInstallerError };