@effected/github 0.15.1 → 0.16.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/ActionsOidc.js ADDED
@@ -0,0 +1,180 @@
1
+ import { JwksResolver, Jwt, JwtError } from "@effected/jwt";
2
+ import { Effect, Schema, SchemaTransformation } from "effect";
3
+
4
+ //#region src/ActionsOidc.ts
5
+ /** The issuer of every GitHub Actions OIDC token on github.com. */
6
+ const ISSUER = "https://token.actions.githubusercontent.com";
7
+ /**
8
+ * A numeric id as GitHub's OIDC tokens carry it: a string of decimal digits,
9
+ * decoded to a safe integer. Anything else (an empty string, a sign, a
10
+ * fraction, an exponent, surrounding space) does not decode.
11
+ */
12
+ const IdFromString = Schema.String.check(Schema.isPattern(/^\d+$/)).pipe(Schema.decodeTo(Schema.Int, SchemaTransformation.numberFromString));
13
+ /**
14
+ * The claims of a verified GitHub Actions OIDC token.
15
+ *
16
+ * @remarks
17
+ * Field names are camelCase; the token carries them in snake_case
18
+ * (`repository_id`, `job_workflow_ref`, ...), and {@link ActionsOidc.verify}
19
+ * maps between the two. GitHub sends the numeric ids (`repository_id`,
20
+ * `repository_owner_id`, `run_id`, `run_attempt`, `actor_id`) as strings;
21
+ * they decode to numbers here, and a non-numeric one fails verification as
22
+ * `claims`.
23
+ *
24
+ * Claims GitHub adds that are not modelled here are dropped on decode.
25
+ *
26
+ * @public
27
+ */
28
+ var ActionsOidcClaims = class extends Schema.Class("ActionsOidcClaims")({
29
+ /** The issuer: always {@link ActionsOidc.issuer} once verified. */
30
+ iss: Schema.String,
31
+ /** The subject, e.g. `repo:octo-org/octo-repo:ref:refs/heads/main`. */
32
+ sub: Schema.String,
33
+ /** The audience or audiences the token was minted for. */
34
+ aud: Schema.Union([Schema.String, Schema.Array(Schema.String)]),
35
+ /** Expiry, in seconds since the epoch. */
36
+ exp: Schema.Number,
37
+ /** Issued at, in seconds since the epoch. */
38
+ iat: Schema.Number,
39
+ /** Not before, in seconds since the epoch. */
40
+ nbf: Schema.optionalKey(Schema.Number),
41
+ /** The token's unique id. */
42
+ jti: Schema.optionalKey(Schema.String),
43
+ /** The repository, `owner/name`. */
44
+ repository: Schema.String,
45
+ /** The repository's numeric id (wire key `repository_id`). */
46
+ repositoryId: IdFromString,
47
+ /** The repository owner's login (wire key `repository_owner`). */
48
+ repositoryOwner: Schema.String,
49
+ /** The repository owner's numeric id (wire key `repository_owner_id`). */
50
+ repositoryOwnerId: IdFromString,
51
+ /** The workflow run's id (wire key `run_id`). */
52
+ runId: IdFromString,
53
+ /** The run attempt, from 1 (wire key `run_attempt`). */
54
+ runAttempt: IdFromString,
55
+ /** The calling workflow's ref, e.g. `octo-org/octo-repo/.github/workflows/ci.yml@refs/heads/main` (wire key `workflow_ref`). */
56
+ workflowRef: Schema.String,
57
+ /** The ref of the workflow file the job runs, which differs from `workflowRef` for a reusable workflow (wire key `job_workflow_ref`). */
58
+ jobWorkflowRef: Schema.String,
59
+ /** The git ref the run is for. */
60
+ ref: Schema.String,
61
+ /** The commit SHA the run is for. */
62
+ sha: Schema.String,
63
+ /** The event that triggered the run, e.g. `push` (wire key `event_name`). */
64
+ eventName: Schema.String,
65
+ /** The login of the user who triggered the run. */
66
+ actor: Schema.String,
67
+ /** That user's numeric id (wire key `actor_id`). */
68
+ actorId: IdFromString,
69
+ /** The deployment environment, when the job names one. */
70
+ environment: Schema.optionalKey(Schema.String),
71
+ /** `github-hosted` or `self-hosted` (wire key `runner_environment`). */
72
+ runnerEnvironment: Schema.String
73
+ }) {};
74
+ /** The claims as they arrive in the token: snake_case keys, decoding to {@link ActionsOidcClaims}. */
75
+ const ClaimsFromWire = ActionsOidcClaims.pipe(Schema.encodeKeys({
76
+ repositoryId: "repository_id",
77
+ repositoryOwner: "repository_owner",
78
+ repositoryOwnerId: "repository_owner_id",
79
+ runId: "run_id",
80
+ runAttempt: "run_attempt",
81
+ workflowRef: "workflow_ref",
82
+ jobWorkflowRef: "job_workflow_ref",
83
+ eventName: "event_name",
84
+ actorId: "actor_id",
85
+ runnerEnvironment: "runner_environment"
86
+ }));
87
+ const verify = Effect.fn("ActionsOidc.verify")(function* (token, options) {
88
+ if (options.audience.length === 0) return yield* new JwtError({
89
+ reason: "wrongAudience",
90
+ detail: "no audience was given; an Actions OIDC token is never verified without one"
91
+ });
92
+ return yield* Jwt.verify(token, {
93
+ key: JwksResolver.forIssuer(ISSUER),
94
+ claims: ClaimsFromWire,
95
+ issuer: ISSUER,
96
+ audience: options.audience,
97
+ ...options.clockTolerance !== void 0 ? { clockTolerance: options.clockTolerance } : {}
98
+ });
99
+ });
100
+ const testClaims = (overrides = {}) => ({
101
+ iss: ISSUER,
102
+ sub: "repo:octo-org/octo-repo:ref:refs/heads/main",
103
+ aud: "https://github.com/octo-org",
104
+ repository: "octo-org/octo-repo",
105
+ repository_id: "123456789",
106
+ repository_owner: "octo-org",
107
+ repository_owner_id: "987654",
108
+ run_id: "4242424242",
109
+ run_attempt: "1",
110
+ workflow_ref: "octo-org/octo-repo/.github/workflows/ci.yml@refs/heads/main",
111
+ job_workflow_ref: "octo-org/octo-repo/.github/workflows/ci.yml@refs/heads/main",
112
+ ref: "refs/heads/main",
113
+ sha: "0123456789abcdef0123456789abcdef01234567",
114
+ event_name: "push",
115
+ actor: "octocat",
116
+ actor_id: "583231",
117
+ runner_environment: "github-hosted",
118
+ ...overrides
119
+ });
120
+ /**
121
+ * Verify GitHub Actions OIDC tokens: a workflow proving to your service which
122
+ * repository, workflow and run it is.
123
+ *
124
+ * @remarks
125
+ * {@link ActionsOidc.verify} checks the signature against GitHub's published
126
+ * JWKS (through `@effected/jwt`'s `JwksResolver`, which caches it), then `iss`,
127
+ * `aud`, `exp`, `nbf` and `iat`, then decodes the payload into
128
+ * {@link ActionsOidcClaims}. Every failure is a `JwtError`; route on its
129
+ * `reason` (`wrongAudience`, `expired`, `claims`, ...), never on its message.
130
+ *
131
+ * Authorizing the caller is still yours: a verified token proves who is
132
+ * calling, not that they may. Pin the **immutable ids**, `repositoryId` or
133
+ * `repositoryOwnerId`, together with `jobWorkflowRef` (or `workflowRef`),
134
+ * never the `repository` or `repositoryOwner` name alone: a deleted
135
+ * repository's or account's name can be registered again by someone else,
136
+ * whose workflows then mint tokens carrying the old name.
137
+ *
138
+ * Only the github.com issuer is accepted. A GitHub Enterprise Cloud
139
+ * enterprise's unique issuer (`https://token.actions.githubusercontent.com/<enterprise>`)
140
+ * and a GitHub Enterprise Server issuer (`https://<host>/_services/token`)
141
+ * are refused as `wrongIssuer`.
142
+ *
143
+ * This module does not reach octokit: a service that only verifies tokens
144
+ * links `@effected/jwt` and nothing else from this package.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * import { JwksResolver } from "@effected/jwt";
149
+ * import { ActionsOidc } from "@effected/github";
150
+ * import { Data, Effect } from "effect";
151
+ *
152
+ * class Forbidden extends Data.TaggedError("Forbidden")<{ readonly repositoryId: number }> {}
153
+ *
154
+ * // Pinned by id, which survives a rename and cannot be re-registered.
155
+ * const ALLOWED_REPOSITORY_ID = 123456789;
156
+ * const ALLOWED_WORKFLOW = "octo-org/octo-repo/.github/workflows/deploy.yml@refs/heads/main";
157
+ *
158
+ * const authorize = (bearer: string) =>
159
+ * Effect.gen(function* () {
160
+ * const claims = yield* ActionsOidc.verify(bearer, { audience: "https://my-service.example" });
161
+ * if (claims.repositoryId !== ALLOWED_REPOSITORY_ID || claims.jobWorkflowRef !== ALLOWED_WORKFLOW) {
162
+ * return yield* new Forbidden({ repositoryId: claims.repositoryId });
163
+ * }
164
+ * return claims;
165
+ * });
166
+ *
167
+ * // JwksResolver.layer needs an HttpClient and a JwksStore, provided once at the edge.
168
+ * export const program = authorize("eyJ...").pipe(Effect.provide(JwksResolver.layer));
169
+ * ```
170
+ *
171
+ * @public
172
+ */
173
+ const ActionsOidc = {
174
+ issuer: ISSUER,
175
+ verify,
176
+ testClaims
177
+ };
178
+
179
+ //#endregion
180
+ export { ActionsOidc, ActionsOidcClaims };
package/CheckRun.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { GitHubError } from "./GitHubError.js";
1
2
  import { GitHubClient } from "./GitHubClient.js";
2
3
  import { Repo } from "./Repo.js";
3
4
  import { numericId } from "./internal/ids.js";
4
- import { Cause, Context, Effect, Exit, Layer, Ref, Schema } from "effect";
5
+ import { Cause, Context, DateTime, Effect, Exit, Layer, Option, Ref, Schema } from "effect";
5
6
 
6
7
  //#region src/CheckRun.ts
7
8
  /** How a check run finished. @public */
@@ -81,6 +82,17 @@ var CheckRunOutput = class CheckRunOutput extends Schema.Class("CheckRunOutput")
81
82
  });
82
83
  }
83
84
  };
85
+ /** UTF-8 encoder for the byte arithmetic; portable, unlike Node's `Buffer`. */
86
+ const utf8 = new TextEncoder();
87
+ /**
88
+ * UTF-8 decoder for the cut. Non-fatal, so a code point split by the cut decodes
89
+ * to U+FFFD for the trim loop to drop rather than throwing; `ignoreBOM` keeps a
90
+ * leading BOM as content, as `Buffer` did.
91
+ */
92
+ const utf8Lenient = new TextDecoder("utf-8", {
93
+ fatal: false,
94
+ ignoreBOM: true
95
+ });
84
96
  /**
85
97
  * Cut `value` to GitHub's byte budget without leaving a broken code point.
86
98
  *
@@ -88,11 +100,15 @@ var CheckRunOutput = class CheckRunOutput extends Schema.Class("CheckRunOutput")
88
100
  * Slicing a UTF-8 buffer mid-character decodes to U+FFFD. Splitting a four-byte
89
101
  * code point can produce **more than one** replacement character, so the trim
90
102
  * loops rather than dropping a single one.
103
+ *
104
+ * `TextEncoder`/`TextDecoder` rather than `Buffer`, which is a Node global: a
105
+ * Worker without `nodejs_compat` would throw a `ReferenceError` here.
91
106
  */
92
107
  const capBytes = (value) => {
93
- if (Buffer.byteLength(value, "utf8") <= CheckRunOutput.LIMIT_BYTES) return value;
94
- const budget = CheckRunOutput.LIMIT_BYTES - Buffer.byteLength(CheckRunOutput.NOTICE, "utf8");
95
- let cut = Buffer.from(value, "utf8").subarray(0, budget).toString("utf8");
108
+ const bytes = utf8.encode(value);
109
+ if (bytes.length <= CheckRunOutput.LIMIT_BYTES) return value;
110
+ const budget = CheckRunOutput.LIMIT_BYTES - utf8.encode(CheckRunOutput.NOTICE).length;
111
+ let cut = utf8Lenient.decode(bytes.subarray(0, budget));
96
112
  while (cut.endsWith("�")) cut = cut.slice(0, -1);
97
113
  return `${cut}${CheckRunOutput.NOTICE}`;
98
114
  };
@@ -106,7 +122,13 @@ var CheckRunRef = class extends Schema.Class("CheckRunRef")({
106
122
  name: Schema.String,
107
123
  /** The web URL. */
108
124
  url: Schema.String,
109
- status: Schema.String
125
+ status: Schema.String,
126
+ /**
127
+ * The integrator's own id for the run (wire `external_id`), when it has
128
+ * one. GitHub reports a run created without one as `null` or `""`; both
129
+ * leave this absent.
130
+ */
131
+ externalId: Schema.optionalKey(Schema.String)
110
132
  }) {};
111
133
  /**
112
134
  * Create, update and conclude GitHub check runs, including the
@@ -126,6 +148,7 @@ var CheckRun = class CheckRun extends Context.Service()("@effected/github/CheckR
126
148
  create: overrides.create ?? (() => unstubbed("create")),
127
149
  get: overrides.get ?? (() => unstubbed("get")),
128
150
  update: overrides.update ?? (() => unstubbed("update")),
151
+ findByExternalId: overrides.findByExternalId ?? (() => unstubbed("findByExternalId")),
129
152
  complete: overrides.complete ?? (() => unstubbed("complete")),
130
153
  withCheckRun: overrides.withCheckRun ?? (() => unstubbed("withCheckRun"))
131
154
  });
@@ -202,32 +225,49 @@ const concludeFor = (name, id, exit, recorded, complete) => {
202
225
  const write = complete(id, settled.conclusion, settled.output);
203
226
  return Exit.isSuccess(exit) ? write : Effect.ignore(write);
204
227
  };
205
- const refOf = (raw) => CheckRunRef.make({
228
+ const decodeRef = Schema.decodeUnknownEffect(CheckRunRef);
229
+ /**
230
+ * Project a check-run response onto {@link CheckRunRef}, **decoding** it.
231
+ *
232
+ * @remarks
233
+ * Decoded rather than built with `make`, which throws: a response missing a
234
+ * field (a hand-written double, or GitHub changing shape) is input, and input
235
+ * failures are a typed `decode` `GitHubError` naming the operation rather than
236
+ * a defect.
237
+ */
238
+ const refOf = (operation, raw) => decodeRef({
206
239
  id: numericId(raw.id),
207
240
  name: raw.name,
208
241
  url: raw.html_url ?? "",
209
- status: raw.status
210
- });
242
+ status: raw.status,
243
+ ...raw.external_id ? { externalId: raw.external_id } : {}
244
+ }).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(operation, "GitHub returned an unexpected check run", error))));
245
+ /** The current time as GitHub's ISO 8601 timestamp, from `Clock` so `TestClock` drives it. */
246
+ const isoNow = Effect.map(DateTime.now, DateTime.formatIso);
211
247
  const make = (client) => {
212
- const create = Effect.fn("CheckRun.create")(function* (name, headSha) {
248
+ const create = Effect.fn("CheckRun.create")(function* (name, headSha, options) {
213
249
  const { owner, repo } = yield* Repo;
250
+ const status = options?.status ?? "in_progress";
214
251
  yield* Effect.annotateCurrentSpan({
215
252
  owner,
216
253
  repo,
217
254
  name,
218
- headSha
255
+ headSha,
256
+ status
219
257
  });
220
258
  const created = yield* client.request("POST /repos/{owner}/{repo}/check-runs", {
221
259
  owner,
222
260
  repo,
223
261
  name,
224
262
  head_sha: headSha,
225
- status: "in_progress",
226
- started_at: (/* @__PURE__ */ new Date()).toISOString()
263
+ status,
264
+ ...status === "in_progress" ? { started_at: yield* isoNow } : {},
265
+ ...options?.externalId ? { external_id: options.externalId } : {},
266
+ ...options?.detailsUrl !== void 0 ? { details_url: options.detailsUrl } : {}
227
267
  });
228
- return refOf(created);
268
+ return yield* refOf("CheckRun.create", created);
229
269
  });
230
- const complete = Effect.fn("CheckRun.complete")(function* (id, conclusion, output) {
270
+ const complete = Effect.fn("CheckRun.complete")(function* (id, conclusion, output, options) {
231
271
  const { owner, repo } = yield* Repo;
232
272
  yield* Effect.annotateCurrentSpan({
233
273
  owner,
@@ -241,8 +281,9 @@ const make = (client) => {
241
281
  check_run_id: id,
242
282
  status: "completed",
243
283
  conclusion,
244
- completed_at: (/* @__PURE__ */ new Date()).toISOString(),
245
- ...output !== void 0 ? { output: wireOutput(output) } : {}
284
+ completed_at: yield* isoNow,
285
+ ...output !== void 0 ? { output: wireOutput(output) } : {},
286
+ ...options?.detailsUrl !== void 0 ? { details_url: options.detailsUrl } : {}
246
287
  });
247
288
  });
248
289
  return {
@@ -260,9 +301,9 @@ const make = (client) => {
260
301
  repo,
261
302
  check_run_id: id
262
303
  });
263
- return refOf(raw);
304
+ return yield* refOf("CheckRun.get", raw);
264
305
  }),
265
- update: Effect.fn("CheckRun.update")(function* (id, output) {
306
+ update: Effect.fn("CheckRun.update")(function* (id, output, options) {
266
307
  const { owner, repo } = yield* Repo;
267
308
  yield* Effect.annotateCurrentSpan({
268
309
  owner,
@@ -273,8 +314,35 @@ const make = (client) => {
273
314
  owner,
274
315
  repo,
275
316
  check_run_id: id,
276
- output: wireOutput(output)
317
+ ...output !== void 0 ? { output: wireOutput(output) } : {},
318
+ ...options?.status !== void 0 ? { status: options.status } : {},
319
+ ...options?.status === "in_progress" ? { started_at: yield* isoNow } : {},
320
+ ...options?.detailsUrl !== void 0 ? { details_url: options.detailsUrl } : {}
321
+ });
322
+ }),
323
+ findByExternalId: Effect.fn("CheckRun.findByExternalId")(function* (headSha, name, externalId) {
324
+ if (externalId === "") return Option.none();
325
+ const { owner, repo } = yield* Repo;
326
+ yield* Effect.annotateCurrentSpan({
327
+ owner,
328
+ repo,
329
+ headSha,
330
+ name,
331
+ externalId
332
+ });
333
+ const runs = yield* client.paginate("GET /repos/{owner}/{repo}/commits/{ref}/check-runs", {
334
+ owner,
335
+ repo,
336
+ ref: headSha,
337
+ check_name: name,
338
+ filter: "all"
277
339
  });
340
+ let newest;
341
+ for (const run of runs) {
342
+ if (run.external_id !== externalId) continue;
343
+ if (newest === void 0 || numericId(run.id) > numericId(newest.id)) newest = run;
344
+ }
345
+ return newest === void 0 ? Option.none() : Option.some(yield* refOf("CheckRun.findByExternalId", newest));
278
346
  }),
279
347
  withCheckRun: (name, headSha, use) => Effect.gen(function* () {
280
348
  const run = yield* create(name, headSha);