@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,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.