@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.
- package/Action.js +138 -0
- package/ActionCache.js +317 -0
- package/ActionEnvironment.js +255 -0
- package/ActionInput.js +346 -0
- package/ActionLogger.js +205 -0
- package/ActionOutputs.js +135 -0
- package/ActionState.js +117 -0
- package/Artifact.js +423 -0
- package/BlobEnvelope.js +135 -0
- package/BlobStore.githubCache.js +183 -0
- package/BlobStore.js +203 -0
- package/BlobTransfer.js +21 -0
- package/CacheKey.js +234 -0
- package/DetachedProcess.js +231 -0
- package/DryRun.js +73 -0
- package/GitHubToken.js +226 -0
- package/LICENSE +21 -0
- package/OidcTokenIssuer.js +246 -0
- package/Secret.js +106 -0
- package/ToolInstaller.js +293 -0
- package/WorkflowCommand.js +100 -0
- package/index.d.ts +2467 -0
- package/index.js +22 -0
- package/internal/actionsResults.js +98 -0
- package/internal/sigv4.js +117 -0
- package/internal/twirp.js +97 -0
- package/package.json +52 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -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 };
|
package/ToolInstaller.js
ADDED
|
@@ -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 };
|