@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,231 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { closeSync, openSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
//#region src/DetachedProcess.ts
|
|
7
|
+
/**
|
|
8
|
+
* Raised when a detached child cannot be started, waited for, or reaped.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
var DetachedProcessError = class extends Schema.TaggedErrorClass()("DetachedProcessError", {
|
|
13
|
+
/**
|
|
14
|
+
* `logUnavailable` — the log file could not be opened, so there is nowhere
|
|
15
|
+
* for the child's output to go. `spawnFailed` — the child did not start.
|
|
16
|
+
* `invalidPid` — a non-positive pid was handed to {@link DetachedProcess.reap};
|
|
17
|
+
* see there for why this is the most important value in the module.
|
|
18
|
+
* `signalFailed` — the signal was refused, e.g. the process belongs to
|
|
19
|
+
* another user. `notReady` — the readiness probe never held.
|
|
20
|
+
*/
|
|
21
|
+
reason: Schema.Literals([
|
|
22
|
+
"logUnavailable",
|
|
23
|
+
"spawnFailed",
|
|
24
|
+
"invalidPid",
|
|
25
|
+
"signalFailed",
|
|
26
|
+
"notReady"
|
|
27
|
+
]),
|
|
28
|
+
/** The pid involved, when one is. */
|
|
29
|
+
pid: Schema.optionalKey(Schema.Number),
|
|
30
|
+
/** The path involved, when one is. */
|
|
31
|
+
path: Schema.optionalKey(Schema.String),
|
|
32
|
+
/** The underlying failure, preserved structurally. */
|
|
33
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
34
|
+
}) {
|
|
35
|
+
get message() {
|
|
36
|
+
switch (this.reason) {
|
|
37
|
+
case "logUnavailable": return `Could not open the detached log file "${this.path}"`;
|
|
38
|
+
case "spawnFailed": return "The detached child did not start";
|
|
39
|
+
case "invalidPid": return `Refusing to signal pid ${this.pid}: a non-positive pid targets a process group, not a process`;
|
|
40
|
+
case "signalFailed": return `Could not signal pid ${this.pid}`;
|
|
41
|
+
default: return "The detached child never became ready";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The schema a pid crosses the phase boundary through.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* A pid is persisted in `GITHUB_STATE` by `main` and read back by `post`, which
|
|
50
|
+
* means it makes the round trip as **text**. This is the boundary check on the
|
|
51
|
+
* way back in: a truncated file, an absent key or a `Number("")` all decode to
|
|
52
|
+
* `0`, and `0` is not a process — it is the current process's whole group.
|
|
53
|
+
* Refusing it here means the bad value never reaches {@link DetachedProcess.reap}
|
|
54
|
+
* in the first place, and reap's own guard is the second of two defenses rather
|
|
55
|
+
* than the only one.
|
|
56
|
+
*
|
|
57
|
+
* **The brand is core's**, from `ChildProcessSpawner` — the subprocess
|
|
58
|
+
* vocabulary belongs to core and this package does not get to re-declare it.
|
|
59
|
+
* What core does not have is a *validating* way to reconstruct one: its
|
|
60
|
+
* constructor is `Brand.nominal`, which applies no runtime check, so
|
|
61
|
+
* `ChildProcessSpawner.ProcessId(0)` succeeds. That is exactly right for a pid
|
|
62
|
+
* the spawner just produced, and exactly wrong for a pid that has been through a
|
|
63
|
+
* text file. This schema is that missing check, and it decodes to core's type so
|
|
64
|
+
* the two are interchangeable.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* import { ActionState, DetachedProcess, ProcessId } from "@effected/github-actions";
|
|
69
|
+
* import { Effect } from "effect";
|
|
70
|
+
*
|
|
71
|
+
* const post = Effect.gen(function* () {
|
|
72
|
+
* const pid = yield* (yield* ActionState).get("server-pid", ProcessId);
|
|
73
|
+
* return yield* DetachedProcess.reap(pid);
|
|
74
|
+
* });
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
const ProcessId = Schema.Number.pipe(Schema.check(Schema.makeFilter((value) => Number.isInteger(value) && value > 0 ? void 0 : "Expected a positive integer process id")), Schema.brand("ProcessId"));
|
|
80
|
+
/** Whether a thrown value is a Node errno error with the given code. */
|
|
81
|
+
const isErrno = (cause, code) => typeof cause === "object" && cause !== null && cause.code === code;
|
|
82
|
+
/**
|
|
83
|
+
* A long-lived child that outlives the phase that started it.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* The shape an action needs is three separate things, because the child
|
|
87
|
+
* survives the process that spawned it: `main` starts it and persists the pid,
|
|
88
|
+
* `main` or `post` waits on a domain predicate rather than on time, and `post`
|
|
89
|
+
* — a **different process**, holding no handle to the child — signals the pid
|
|
90
|
+
* it read back out of `GITHUB_STATE`.
|
|
91
|
+
*
|
|
92
|
+
* That last step is why this lives here rather than in a command runner. There
|
|
93
|
+
* is no `ChildProcess` to `.kill()`; there is only a number that has been
|
|
94
|
+
* through a text file. See {@link DetachedProcess.reap}.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* import { DetachedProcess } from "@effected/github-actions";
|
|
99
|
+
* import { Effect } from "effect";
|
|
100
|
+
*
|
|
101
|
+
* const program = Effect.gen(function* () {
|
|
102
|
+
* const pid = yield* DetachedProcess.spawn({
|
|
103
|
+
* command: "node",
|
|
104
|
+
* args: ["server.js"],
|
|
105
|
+
* logFile: "/tmp/server.log",
|
|
106
|
+
* });
|
|
107
|
+
* yield* DetachedProcess.awaitReady(healthCheck);
|
|
108
|
+
* return pid;
|
|
109
|
+
* });
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
var DetachedProcess = class {
|
|
115
|
+
constructor() {}
|
|
116
|
+
/**
|
|
117
|
+
* Start a detached child with its output routed to a log file.
|
|
118
|
+
*
|
|
119
|
+
* @remarks
|
|
120
|
+
* The parent closes its own copy of the descriptor immediately — the child
|
|
121
|
+
* holds a duplicate, so the log keeps filling after the action's process
|
|
122
|
+
* exits, which is the entire point.
|
|
123
|
+
*/
|
|
124
|
+
static spawn = Effect.fn("DetachedProcess.spawn")(function* (options) {
|
|
125
|
+
const descriptor = yield* Effect.try({
|
|
126
|
+
try: () => openSync(options.logFile, "a"),
|
|
127
|
+
catch: (cause) => new DetachedProcessError({
|
|
128
|
+
reason: "logUnavailable",
|
|
129
|
+
path: options.logFile,
|
|
130
|
+
cause
|
|
131
|
+
})
|
|
132
|
+
});
|
|
133
|
+
const pid = yield* Effect.try({
|
|
134
|
+
try: () => {
|
|
135
|
+
try {
|
|
136
|
+
const child = spawn(options.command, [...options.args ?? []], {
|
|
137
|
+
...options.cwd === void 0 ? {} : { cwd: options.cwd },
|
|
138
|
+
env: {
|
|
139
|
+
...process.env,
|
|
140
|
+
...options.env
|
|
141
|
+
},
|
|
142
|
+
detached: true,
|
|
143
|
+
stdio: [
|
|
144
|
+
"ignore",
|
|
145
|
+
descriptor,
|
|
146
|
+
descriptor
|
|
147
|
+
]
|
|
148
|
+
});
|
|
149
|
+
child.on("error", () => {});
|
|
150
|
+
child.unref();
|
|
151
|
+
if (child.pid === void 0) throw new Error(`"${options.command}" produced no process id`);
|
|
152
|
+
return child.pid;
|
|
153
|
+
} finally {
|
|
154
|
+
closeSync(descriptor);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
catch: (cause) => new DetachedProcessError({
|
|
158
|
+
reason: "spawnFailed",
|
|
159
|
+
cause
|
|
160
|
+
})
|
|
161
|
+
});
|
|
162
|
+
yield* Effect.annotateCurrentSpan({ pid });
|
|
163
|
+
return ChildProcessSpawner.ProcessId(pid);
|
|
164
|
+
});
|
|
165
|
+
/**
|
|
166
|
+
* Poll a predicate until it holds.
|
|
167
|
+
*
|
|
168
|
+
* @remarks
|
|
169
|
+
* A domain predicate rather than a sleep: "the server answers" is the thing
|
|
170
|
+
* being waited for, and any fixed delay is simultaneously too long on a fast
|
|
171
|
+
* runner and too short on a slow one. Two consumers hand-rolled this, one of
|
|
172
|
+
* them around an `Effect.suspend` footgun it had to explain in a comment.
|
|
173
|
+
*
|
|
174
|
+
* A probe *failure* is propagated rather than treated as "not ready" — a
|
|
175
|
+
* probe that cannot run is a different situation from a child that is not up
|
|
176
|
+
* yet, and silently retrying the first is how a misconfigured probe becomes a
|
|
177
|
+
* timeout six seconds later with nothing to show for it.
|
|
178
|
+
*/
|
|
179
|
+
static awaitReady = Effect.fn("DetachedProcess.awaitReady")(function* (probe, options) {
|
|
180
|
+
const interval = options?.interval ?? "150 millis";
|
|
181
|
+
const attempts = options?.attempts ?? 40;
|
|
182
|
+
const poll = (remaining) => Effect.flatMap(probe, (ready) => {
|
|
183
|
+
if (ready) return Effect.void;
|
|
184
|
+
if (remaining <= 0) return Effect.fail(new DetachedProcessError({ reason: "notReady" }));
|
|
185
|
+
return Effect.flatMap(Effect.sleep(interval), () => poll(remaining - 1));
|
|
186
|
+
});
|
|
187
|
+
return yield* poll(attempts);
|
|
188
|
+
});
|
|
189
|
+
/**
|
|
190
|
+
* Signal a pid that came back across the phase boundary.
|
|
191
|
+
*
|
|
192
|
+
* @remarks
|
|
193
|
+
* **The guard is the reason this function exists.** `process.kill(0, …)`
|
|
194
|
+
* signals the caller's entire process group and `process.kill(-1, …)` signals
|
|
195
|
+
* every process the user owns — so on a GitHub runner, an unguarded reap of a
|
|
196
|
+
* pid that decoded to `0` takes down the job that is running it. The value is
|
|
197
|
+
* exactly the one most likely to be wrong: it is what an absent state key, a
|
|
198
|
+
* truncated state file or a `Number("")` all produce.
|
|
199
|
+
*
|
|
200
|
+
* A non-positive pid therefore fails **typed and before any signal is sent**.
|
|
201
|
+
* The parameter is a plain `number` rather than a {@link ProcessId} on purpose:
|
|
202
|
+
* the value arrives as text from another process, so the type system stopped
|
|
203
|
+
* applying the moment it crossed that boundary, and a guard that only a
|
|
204
|
+
* well-typed caller can trip is not a guard.
|
|
205
|
+
*
|
|
206
|
+
* Returns `false` for a process that is already gone, because a `post` phase
|
|
207
|
+
* finding its child already dead is the normal ending, not a failure.
|
|
208
|
+
*/
|
|
209
|
+
static reap = Effect.fn("DetachedProcess.reap")(function* (pid, signal = "SIGTERM") {
|
|
210
|
+
yield* Effect.annotateCurrentSpan({ pid });
|
|
211
|
+
if (!Number.isInteger(pid) || pid <= 0) return yield* Effect.fail(new DetachedProcessError({
|
|
212
|
+
reason: "invalidPid",
|
|
213
|
+
pid
|
|
214
|
+
}));
|
|
215
|
+
return yield* Effect.suspend(() => {
|
|
216
|
+
try {
|
|
217
|
+
process.kill(pid, signal);
|
|
218
|
+
return Effect.succeed(true);
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
return isErrno(cause, "ESRCH") ? Effect.succeed(false) : Effect.fail(new DetachedProcessError({
|
|
221
|
+
reason: "signalFailed",
|
|
222
|
+
pid,
|
|
223
|
+
cause
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
//#endregion
|
|
231
|
+
export { DetachedProcess, DetachedProcessError, ProcessId };
|
package/DryRun.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ActionInput } from "./ActionInput.js";
|
|
2
|
+
import { Config, Context, Effect, Layer } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/DryRun.ts
|
|
5
|
+
/** The input a workflow author sets to rehearse a run. */
|
|
6
|
+
const DEFAULT_INPUT = "dry-run";
|
|
7
|
+
const make = (enabled) => ({
|
|
8
|
+
isDryRun: Effect.succeed(enabled),
|
|
9
|
+
guard: (label, effect, fallback) => enabled ? Effect.as(Effect.logInfo(`[DRY-RUN] ${label}`), fallback) : effect
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* The rehearsal guard: every mutation an action performs goes through it, so a
|
|
13
|
+
* workflow can be run end to end without changing anything.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* A service rather than a boolean threaded through call sites, because the
|
|
17
|
+
* decision has to be available wherever a mutation is, and because a test that
|
|
18
|
+
* wants to prove "this path mutates nothing" provides one layer instead of
|
|
19
|
+
* auditing every branch.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* import { DryRun } from "@effected/github-actions";
|
|
24
|
+
* import { Effect } from "effect";
|
|
25
|
+
*
|
|
26
|
+
* const program = Effect.gen(function* () {
|
|
27
|
+
* const dryRun = yield* DryRun;
|
|
28
|
+
* yield* dryRun.guard("publish @acme/thing@1.2.3", publish, undefined);
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
var DryRun = class DryRun extends Context.Service()("@effected/github-actions/DryRun") {
|
|
35
|
+
/**
|
|
36
|
+
* Driven by the `dry-run` action input, defaulting to a real run.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* Fails with a `ConfigError` when the input is present but is not a YAML 1.2
|
|
40
|
+
* core-schema boolean — a workflow that writes `dry-run: yes` should stop,
|
|
41
|
+
* not quietly perform the mutations it meant to rehearse.
|
|
42
|
+
*/
|
|
43
|
+
static layer = Layer.effect(this, Effect.map(ActionInput.boolean(DEFAULT_INPUT).pipe(Config.withDefault(false)), make));
|
|
44
|
+
/**
|
|
45
|
+
* Driven by a named input.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* A parameterized layer factory mints a fresh layer per call and layers
|
|
49
|
+
* memoize by reference — bind it to a `const` rather than calling it at each
|
|
50
|
+
* composition site.
|
|
51
|
+
*/
|
|
52
|
+
static layerFromInput = (name) => Layer.effect(DryRun, Effect.map(ActionInput.boolean(name).pipe(Config.withDefault(false)), make));
|
|
53
|
+
/** Driven by an explicit decision the caller has already made. */
|
|
54
|
+
static layerFrom = (enabled) => Layer.succeed(DryRun, make(enabled));
|
|
55
|
+
/**
|
|
56
|
+
* A double that rehearses.
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* **A recorded exception to the die-on-unstubbed rule.** The default is not a
|
|
60
|
+
* fabrication but the safe direction: a test that forgot to say which mode it
|
|
61
|
+
* wants gets the mode that mutates nothing. The members are the real
|
|
62
|
+
* implementation, so the double cannot drift from it.
|
|
63
|
+
*/
|
|
64
|
+
static makeTest = (overrides = {}) => ({
|
|
65
|
+
...make(true),
|
|
66
|
+
...overrides
|
|
67
|
+
});
|
|
68
|
+
/** {@link DryRun.makeTest} behind `Layer.succeed`. */
|
|
69
|
+
static layerTest = (overrides = {}) => Layer.succeed(DryRun, DryRun.makeTest(overrides));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { DryRun };
|
package/GitHubToken.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { ActionState } from "./ActionState.js";
|
|
2
|
+
import { Secret } from "./Secret.js";
|
|
3
|
+
import { DateTime, Duration, Effect, Exit, Layer, Option, Result, Schema } from "effect";
|
|
4
|
+
import { GitHubApp, GitHubClient, InstallationToken, TokenPermissions } from "@effected/github";
|
|
5
|
+
|
|
6
|
+
//#region src/GitHubToken.ts
|
|
7
|
+
/**
|
|
8
|
+
* Raised when the token an earlier phase persisted cannot be used.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
var GitHubTokenError = class extends Schema.TaggedErrorClass()("GitHubTokenError", {
|
|
13
|
+
/**
|
|
14
|
+
* One reason, deliberately: everything else that can go wrong here already
|
|
15
|
+
* has an owner — persistence is an `ActionStateError`, minting is a
|
|
16
|
+
* `GitHubAppError`, and scope verification is a `TokenPermissionError`.
|
|
17
|
+
*/
|
|
18
|
+
reason: Schema.Literals(["expired"]),
|
|
19
|
+
/** When GitHub stopped accepting it, ISO-8601. */
|
|
20
|
+
expiresAt: Schema.String
|
|
21
|
+
}) {
|
|
22
|
+
get message() {
|
|
23
|
+
return `The installation token persisted by an earlier phase expired at ${this.expiresAt}. An installation token lives about an hour and no later phase can re-mint one, so a long-running phase must provision its own.`;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** The default `GITHUB_STATE` key the token is filed under. */
|
|
27
|
+
const DEFAULT_KEY = "githubToken";
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the app's identity onto a minted token, degrading rather than failing.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* The identity is what makes a commit say `my-app[bot]` instead of
|
|
33
|
+
* `github-actions[bot]`. It is worth a request and it is **not** worth an
|
|
34
|
+
* action: a `GET /app` hiccup must not fail a release, so the failure is logged
|
|
35
|
+
* and the token comes back without the identity fields. `botIdentity()` then
|
|
36
|
+
* falls back on its own.
|
|
37
|
+
*
|
|
38
|
+
* The installation token is supplied to the lookup on purpose:
|
|
39
|
+
* `GET /users/{slug}[bot]` rejects an app JWT, so without one the request runs
|
|
40
|
+
* unauthenticated against GitHub's sixty-per-hour-per-IP limit.
|
|
41
|
+
*/
|
|
42
|
+
const identified = (app, options, minted) => Effect.gen(function* () {
|
|
43
|
+
const resolved = yield* Effect.result(app.identity({
|
|
44
|
+
appId: options.appId,
|
|
45
|
+
privateKey: options.privateKey,
|
|
46
|
+
installationToken: minted.token
|
|
47
|
+
}));
|
|
48
|
+
if (Result.isFailure(resolved)) {
|
|
49
|
+
yield* Effect.logWarning(`Could not resolve the app's identity; commits will be attributed to github-actions[bot]: ${resolved.failure.message}`);
|
|
50
|
+
return minted;
|
|
51
|
+
}
|
|
52
|
+
const identity = resolved.success;
|
|
53
|
+
return InstallationToken.make({
|
|
54
|
+
token: minted.token,
|
|
55
|
+
expiresAt: minted.expiresAt,
|
|
56
|
+
installationId: minted.installationId,
|
|
57
|
+
permissions: minted.permissions,
|
|
58
|
+
appSlug: identity.slug,
|
|
59
|
+
appName: identity.name,
|
|
60
|
+
...identity.userId === void 0 ? {} : { appUserId: identity.userId }
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* The GitHub App token lifecycle, shaped like a workflow.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* One of the two seams between this package and
|
|
68
|
+
* [`@effected/github`](https://npmjs.com) — mint in `pre`, use in `main`,
|
|
69
|
+
* revoke in `post`. The three phases are **three separate processes**, so
|
|
70
|
+
* nothing survives between them except what `GITHUB_STATE` carries, which is
|
|
71
|
+
* why this is a persistence problem rather than a `Scope`.
|
|
72
|
+
*
|
|
73
|
+
* That is also why `GitHubApp.scopedToken` is the wrong primitive here and
|
|
74
|
+
* `GitHubApp.clientLayer` is a different thing entirely: both revoke when their
|
|
75
|
+
* scope closes, and the scope closes at the end of `pre` — before `main` has
|
|
76
|
+
* run a single request.
|
|
77
|
+
*
|
|
78
|
+
* **The token lives about an hour, and no later phase can re-mint one.** The
|
|
79
|
+
* credentials that could are the app's private key, and persisting *that*
|
|
80
|
+
* through `GITHUB_STATE` — a plaintext file by GitHub's protocol — would trade
|
|
81
|
+
* a one-hour token for a permanent one. So the v1 contract is stated rather
|
|
82
|
+
* than worked around: {@link GitHubToken.read} fails typed when the persisted
|
|
83
|
+
* token is spent, and a phase that can outlive the hour calls
|
|
84
|
+
* {@link GitHubToken.provision} itself.
|
|
85
|
+
*
|
|
86
|
+
* Grouped statics on one module reaching one dependency — the carve-out to the
|
|
87
|
+
* no-namespace-objects rule, not the hazard it names. Every member's `R` says
|
|
88
|
+
* exactly which services it needs, and the members below say which parts of
|
|
89
|
+
* each they touch, so a partial double is built from the documentation rather
|
|
90
|
+
* than from a stack trace:
|
|
91
|
+
*
|
|
92
|
+
* | Member | `ActionState` | `ActionOutputs` | `GitHubApp` |
|
|
93
|
+
* | --- | --- | --- | --- |
|
|
94
|
+
* | `provision` | `save` | `setSecret` | `token`, `identity`, `revoke` |
|
|
95
|
+
* | `read` | `get` | — | — |
|
|
96
|
+
* | `botIdentity` | `get` | — | — |
|
|
97
|
+
* | `clientLayer` | `get` | — | — |
|
|
98
|
+
* | `dispose` | `getOptional` | — | `revoke` |
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* import { GitHubToken } from "@effected/github-actions";
|
|
103
|
+
* import { Effect, Redacted } from "effect";
|
|
104
|
+
*
|
|
105
|
+
* const pre = Effect.gen(function* () {
|
|
106
|
+
* return yield* GitHubToken.provision({
|
|
107
|
+
* appId: "123456",
|
|
108
|
+
* privateKey: Redacted.make(pem),
|
|
109
|
+
* owner: "acme",
|
|
110
|
+
* required: { contents: "write", pull_requests: "write" },
|
|
111
|
+
* });
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* @public
|
|
116
|
+
*/
|
|
117
|
+
var GitHubToken = class GitHubToken {
|
|
118
|
+
constructor() {}
|
|
119
|
+
/**
|
|
120
|
+
* Mint a token, verify its scopes, mask it and persist it for later phases.
|
|
121
|
+
*
|
|
122
|
+
* @remarks
|
|
123
|
+
* An `acquireUseRelease`, and the release arm is the load-bearing part: if
|
|
124
|
+
* scope verification or persistence fails, the minted token is **revoked**
|
|
125
|
+
* rather than left live until GitHub expires it. A workflow that retries a
|
|
126
|
+
* failing `pre` would otherwise leave an hour's worth of unreferenced write
|
|
127
|
+
* tokens behind, each of which is a credential nobody is tracking.
|
|
128
|
+
*
|
|
129
|
+
* The masking happens **before** the persistence, through
|
|
130
|
+
* {@link Secret.forRunnerFile}, so the ordering is structural rather than
|
|
131
|
+
* remembered: `GITHUB_STATE` is plaintext by protocol, and the runner's log
|
|
132
|
+
* filter is the only defense available.
|
|
133
|
+
*/
|
|
134
|
+
static provision = Effect.fn("GitHubToken.provision")(function* (options) {
|
|
135
|
+
const app = yield* GitHubApp;
|
|
136
|
+
const state = yield* ActionState;
|
|
137
|
+
const key = options.stateKey ?? DEFAULT_KEY;
|
|
138
|
+
return yield* Effect.acquireUseRelease(app.token({
|
|
139
|
+
appId: options.appId,
|
|
140
|
+
privateKey: options.privateKey,
|
|
141
|
+
...options.installationId === void 0 ? {} : { installationId: options.installationId },
|
|
142
|
+
...options.owner === void 0 ? {} : { owner: options.owner }
|
|
143
|
+
}), (minted) => Effect.gen(function* () {
|
|
144
|
+
if (options.required !== void 0) yield* TokenPermissions.fromGitHub(minted.permissions).assertSufficient(options.required);
|
|
145
|
+
yield* Secret.forRunnerFile(minted.token);
|
|
146
|
+
const enriched = yield* identified(app, options, minted);
|
|
147
|
+
yield* state.save(key, enriched, InstallationToken);
|
|
148
|
+
return enriched;
|
|
149
|
+
}), (minted, exit) => Exit.isSuccess(exit) ? Effect.void : Effect.ignore(app.revoke(minted.token)));
|
|
150
|
+
});
|
|
151
|
+
/**
|
|
152
|
+
* The token an earlier phase persisted.
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* Fails typed when it is spent. The package this replaces persisted the
|
|
156
|
+
* expiry and read it nowhere, so a `main` phase that outlived the hour simply
|
|
157
|
+
* started answering `401` with no explanation — which is the single hardest
|
|
158
|
+
* failure in this whole lifecycle to diagnose from a workflow log.
|
|
159
|
+
*/
|
|
160
|
+
static read = Effect.fn("GitHubToken.read")(function* (options = {}) {
|
|
161
|
+
const token = yield* (yield* ActionState).get(options.stateKey ?? DEFAULT_KEY, InstallationToken);
|
|
162
|
+
const now = yield* DateTime.now;
|
|
163
|
+
if (options.skew === void 0 ? token.isExpired(DateTime.toEpochMillis(now)) : token.isExpired(DateTime.toEpochMillis(now), options.skew)) return yield* Effect.fail(new GitHubTokenError({
|
|
164
|
+
reason: "expired",
|
|
165
|
+
expiresAt: DateTime.formatIso(token.expiresAt)
|
|
166
|
+
}));
|
|
167
|
+
return token;
|
|
168
|
+
});
|
|
169
|
+
/**
|
|
170
|
+
* The committer identity a commit made with the persisted token should carry.
|
|
171
|
+
*
|
|
172
|
+
* @remarks
|
|
173
|
+
* Pure once the token is in hand — the identity fields travel with it — so
|
|
174
|
+
* this costs one state read and no request.
|
|
175
|
+
*/
|
|
176
|
+
static botIdentity = Effect.fn("GitHubToken.botIdentity")(function* (options = {}) {
|
|
177
|
+
return (yield* GitHubToken.read(options)).botIdentity();
|
|
178
|
+
});
|
|
179
|
+
/**
|
|
180
|
+
* A client built from the persisted token.
|
|
181
|
+
*
|
|
182
|
+
* @remarks
|
|
183
|
+
* Built with `GitHubClient.layerFromToken` rather than through `GitHubApp`,
|
|
184
|
+
* and the difference matters: the App path links a JWT signer and needs the
|
|
185
|
+
* private key, neither of which a later phase has or should have. This path
|
|
186
|
+
* needs only the token the `pre` phase already minted.
|
|
187
|
+
*
|
|
188
|
+
* A parameterized layer factory mints a fresh layer per call and layers
|
|
189
|
+
* memoize by reference — bind it to a `const` rather than calling it at each
|
|
190
|
+
* composition site.
|
|
191
|
+
*/
|
|
192
|
+
static clientLayer = (options = {}) => Layer.unwrap(Effect.map(GitHubToken.read(options), (token) => GitHubClient.layerFromToken({
|
|
193
|
+
token: token.token,
|
|
194
|
+
...options.retry === void 0 ? {} : { retry: options.retry },
|
|
195
|
+
...options.baseUrl === void 0 ? {} : { baseUrl: options.baseUrl },
|
|
196
|
+
...options.userAgent === void 0 ? {} : { userAgent: options.userAgent },
|
|
197
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch }
|
|
198
|
+
})));
|
|
199
|
+
/**
|
|
200
|
+
* Revoke the persisted token, if there is one.
|
|
201
|
+
*
|
|
202
|
+
* @remarks
|
|
203
|
+
* `getOptional`, so a `post` phase that runs after a `pre` that never got as
|
|
204
|
+
* far as provisioning is a **no-op rather than a failure** — which is the
|
|
205
|
+
* common shape of a workflow that failed early, and the last thing it needs
|
|
206
|
+
* is a second failure on the way out.
|
|
207
|
+
*
|
|
208
|
+
* An already-expired token is not revoked either: GitHub has already stopped
|
|
209
|
+
* accepting it, so the request would fail and the only thing it could
|
|
210
|
+
* accomplish is turning a successful run into a failed one.
|
|
211
|
+
*/
|
|
212
|
+
static dispose = Effect.fn("GitHubToken.dispose")(function* (options = {}) {
|
|
213
|
+
const app = yield* GitHubApp;
|
|
214
|
+
const found = yield* (yield* ActionState).getOptional(options.stateKey ?? DEFAULT_KEY, InstallationToken);
|
|
215
|
+
if (Option.isNone(found)) return;
|
|
216
|
+
const now = yield* DateTime.now;
|
|
217
|
+
if (found.value.isExpired(DateTime.toEpochMillis(now), Duration.zero)) {
|
|
218
|
+
yield* Effect.logDebug("The installation token had already expired; nothing to revoke.");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
yield* app.revoke(found.value.token);
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
//#endregion
|
|
226
|
+
export { GitHubToken, GitHubTokenError };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|