@shivaedev/github 0.8.6 → 0.10.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +142 -8
  3. package/dist/GitHub.d.ts +360 -18
  4. package/dist/GitHub.d.ts.map +1 -1
  5. package/dist/GitHub.js +19 -6
  6. package/dist/GitHub.js.map +1 -1
  7. package/dist/GitHubToken.d.ts +2 -2
  8. package/dist/GitHubToken.d.ts.map +1 -1
  9. package/dist/GitHubToken.js +1 -1
  10. package/dist/GitHubToken.js.map +1 -1
  11. package/dist/adapters/resolveApiUrl.d.ts +2 -0
  12. package/dist/adapters/resolveApiUrl.d.ts.map +1 -0
  13. package/dist/adapters/resolveApiUrl.js +6 -0
  14. package/dist/adapters/resolveApiUrl.js.map +1 -0
  15. package/dist/adapters/zipEntries.d.ts +17 -0
  16. package/dist/adapters/zipEntries.d.ts.map +1 -0
  17. package/dist/adapters/zipEntries.js +57 -0
  18. package/dist/adapters/zipEntries.js.map +1 -0
  19. package/dist/appTokens.d.ts +6 -0
  20. package/dist/appTokens.d.ts.map +1 -0
  21. package/dist/appTokens.js +65 -0
  22. package/dist/appTokens.js.map +1 -0
  23. package/dist/artifactErrors.d.ts +34 -0
  24. package/dist/artifactErrors.d.ts.map +1 -0
  25. package/dist/artifactErrors.js +27 -0
  26. package/dist/artifactErrors.js.map +1 -0
  27. package/dist/artifactExtraction.d.ts +12 -0
  28. package/dist/artifactExtraction.d.ts.map +1 -0
  29. package/dist/artifactExtraction.js +40 -0
  30. package/dist/artifactExtraction.js.map +1 -0
  31. package/dist/artifactPaths.d.ts +4 -0
  32. package/dist/artifactPaths.d.ts.map +1 -0
  33. package/dist/artifactPaths.js +35 -0
  34. package/dist/artifactPaths.js.map +1 -0
  35. package/dist/artifacts.d.ts +13 -8
  36. package/dist/artifacts.d.ts.map +1 -1
  37. package/dist/artifacts.js +15 -8
  38. package/dist/artifacts.js.map +1 -1
  39. package/dist/authenticatedRequest.d.ts +1 -1
  40. package/dist/authenticatedRequest.d.ts.map +1 -1
  41. package/dist/authenticatedRequest.js +3 -2
  42. package/dist/authenticatedRequest.js.map +1 -1
  43. package/dist/cache.d.ts +2 -0
  44. package/dist/cache.d.ts.map +1 -1
  45. package/dist/cache.js +3 -0
  46. package/dist/cache.js.map +1 -1
  47. package/dist/checkRuns.d.ts +116 -11
  48. package/dist/checkRuns.d.ts.map +1 -1
  49. package/dist/checkRuns.js +41 -18
  50. package/dist/checkRuns.js.map +1 -1
  51. package/dist/gitSchemas.d.ts +195 -0
  52. package/dist/gitSchemas.d.ts.map +1 -0
  53. package/dist/gitSchemas.js +45 -0
  54. package/dist/gitSchemas.js.map +1 -0
  55. package/dist/githubApp.d.ts +2 -4
  56. package/dist/githubApp.d.ts.map +1 -1
  57. package/dist/githubApp.js +5 -55
  58. package/dist/githubApp.js.map +1 -1
  59. package/dist/installationClients.d.ts +461 -0
  60. package/dist/installationClients.d.ts.map +1 -0
  61. package/dist/installationClients.js +37 -0
  62. package/dist/installationClients.js.map +1 -0
  63. package/dist/json.d.ts.map +1 -1
  64. package/dist/json.js +4 -0
  65. package/dist/json.js.map +1 -1
  66. package/dist/pagedLists.d.ts +15 -0
  67. package/dist/pagedLists.d.ts.map +1 -0
  68. package/dist/pagedLists.js +16 -0
  69. package/dist/pagedLists.js.map +1 -0
  70. package/dist/repositoryReads.d.ts +261 -0
  71. package/dist/repositoryReads.d.ts.map +1 -0
  72. package/dist/repositoryReads.js +30 -0
  73. package/dist/repositoryReads.js.map +1 -0
  74. package/dist/repositorySchemas.d.ts +73 -0
  75. package/dist/repositorySchemas.d.ts.map +1 -0
  76. package/dist/repositorySchemas.js +38 -0
  77. package/dist/repositorySchemas.js.map +1 -0
  78. package/dist/representations.d.ts +4 -4
  79. package/dist/representations.d.ts.map +1 -1
  80. package/dist/representations.js +3 -2
  81. package/dist/representations.js.map +1 -1
  82. package/dist/requests.d.ts +2 -2
  83. package/dist/requests.d.ts.map +1 -1
  84. package/dist/requests.js +11 -9
  85. package/dist/requests.js.map +1 -1
  86. package/dist/status.d.ts +1 -1
  87. package/dist/status.d.ts.map +1 -1
  88. package/dist/stickyComments.d.ts +1 -1
  89. package/dist/viewerRepositoryAccess.d.ts +46 -0
  90. package/dist/viewerRepositoryAccess.d.ts.map +1 -0
  91. package/dist/viewerRepositoryAccess.js +54 -0
  92. package/dist/viewerRepositoryAccess.js.map +1 -0
  93. package/package.json +14 -7
  94. package/src/GitHub.ts +32 -11
  95. package/src/GitHubToken.ts +4 -2
  96. package/src/adapters/resolveApiUrl.ts +5 -0
  97. package/src/adapters/zipEntries.ts +69 -0
  98. package/src/appTokens.ts +93 -0
  99. package/src/artifactErrors.ts +26 -0
  100. package/src/artifactExtraction.ts +62 -0
  101. package/src/artifactPaths.ts +36 -0
  102. package/src/artifacts.ts +36 -39
  103. package/src/authenticatedRequest.ts +3 -1
  104. package/src/cache.ts +5 -0
  105. package/src/checkRuns.ts +58 -18
  106. package/src/gitSchemas.ts +52 -0
  107. package/src/githubApp.ts +6 -80
  108. package/src/installationClients.ts +50 -0
  109. package/src/json.ts +4 -0
  110. package/src/pagedLists.ts +40 -0
  111. package/src/repositoryReads.ts +61 -0
  112. package/src/repositorySchemas.ts +43 -0
  113. package/src/representations.ts +5 -4
  114. package/src/requests.ts +11 -9
  115. package/src/viewerRepositoryAccess.ts +66 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 - 2026-11-02
4
+
5
+ ### Added
6
+
7
+ - Add typed, byte-capped issue, user, collaborator-permission, commit, comparison and tree reads, plus paged issue, pull-request-file and commit lists on the GitHub service.
8
+ - Let Schema-aware lists select arrays inside envelopes, pace each request and opt into skipping undecodable items. `listPages` reports decoded items and skipped counts for each page; `listBounded` includes the total `skipped` count alongside `items` and `truncated`. Existing numeric byte-limit arguments remain supported, and decoding still fails by default.
9
+ - `GitHubApp.viewerRepositories(account)` accepts a GitHub account ID or login and returns readable and administrable installed repositories, with access results cached for 60 seconds across at most 100 accounts.
10
+
11
+ ### Fixed
12
+
13
+ - Preserve configured GitHub API path prefixes in representation reads, lists and writes, App installation discovery and token exchange, and artifact API requests. API origins without a path keep their existing behavior.
14
+ - Keep shared App clients independent of a single-installation Layer's repository and permission scope.
15
+
16
+ ### Breaking
17
+
18
+ - `downloadArtifactArchive` now returns an array of `{ path, bytes }` files instead of raw ZIP bytes. Install the optional `@zip.js/zip.js` peer and consume these validated files instead of passing the result to an application ZIP reader. Extraction refuses unsafe paths, symlinks, duplicate paths and file/directory conflicts, with distinct typed errors. The default caps are 100 MiB of extracted contents and a per-entry compression ratio of 100:1; set `totalSizeLimit` and `compressionRatioLimit` to change them. The existing `byteLimit` still caps the downloaded ZIP.
19
+ - The low-level `artifacts` constructor now takes `(apiUrl, rates)` and returns an Effect. Yield it with `HttpClient`, `GitHubToken` and `TokenRefresh` provided instead of passing those services as arguments.
20
+
21
+ ## 0.9.0 - 2026-11-02
22
+
23
+ ### Added
24
+
25
+ - Send PUT and DELETE through the shared authenticated mutation path, omit absent request bodies, and decode bodyless 204 results as `void` by omitting the response schema or using `Schema.Void`.
26
+ - Find the latest commit check run by name, app ID, or both with `findLatestCheckRun`, and create or update it with `upsertCheckRun`. Check-run responses expose the app ID when present and recognize GitHub Actions-only read statuses.
27
+ - One `githubApp` Layer now provides `GitHubApp` and `withInstallation` to share clients across installations, with separate token refresh, conditional caches and rate-limit state. The cache holds at most 100 clients, evicts the least recently used client when full and replaces clients after 30 minutes idle.
28
+
29
+ ### Fixed
30
+
31
+ - Installation lookups and token exchanges use the same rate-limit detection and retry deadlines as App and resource requests. Quota exhaustion returns typed `GitHubRateLimited`, and requests fail locally until its deadline instead of repeating authentication requests.
32
+ - Read HEAD responses and statuses 204, 205 and 304 as empty bytes or text; JSON decoding still rejects empty input.
33
+ - Limit encoded responses by bytes exposed by the native client stream, without comparing compressed Content-Length to decoded bytes. Fetch exposes decompressed bytes; Node's native HTTP transport exposes encoded bytes.
34
+ - Keep conditional response caches separate for requests with different query parameters. Refresh pagination Link metadata on 304 responses while reusing cached bodies and decoded values, so lists follow the current next page and stop when its Link disappears.
35
+ - Remove userinfo, query strings and fragments from status-error URLs.
36
+ - Keep pagination links on the request's origin.
37
+ - Release response bodies before returning status or body-size failures, and keep schema failures from holding HTTP connections.
38
+ - Release GitHub response bodies on rejection and before refreshing a rejected installation token, so repeated failures do not exhaust the connection pool.
39
+
40
+ ### Breaking
41
+
42
+ - Effectful `GitHubToken` providers can now fail with `GitHubRateLimited` alongside `GitHubAppError`. Consumers handling authentication failures must also handle `GitHubRateLimited` and use its `retryAt` when scheduling another attempt.
43
+ - Bounded response readers reject NaN, infinite and negative limits with the typed `InvalidBodyLimit` error from `errors.ts`. Pass a finite nonnegative byte limit and handle the new failure in the error channel.
44
+ - GitHub methods using the bounded response reader also expose `InvalidBodyLimit` from `@shivaedev/http/errors.ts` for invalid byte limits. Supply finite nonnegative limits and handle that failure alongside existing response failures.
45
+ - `CreateCheckRun` requires a conclusion when `status` is `completed`, with validation before any request. Consumers creating completed runs must supply `conclusion`; use `queued` or `in_progress` when no conclusion is available.
46
+ - `CheckRunStatus` includes `waiting`, `requested` and `pending` from GitHub Actions responses. Consumers with exhaustive status handling must add these cases. GitHub App create and update inputs remain restricted to `queued`, `in_progress` and `completed`.
47
+ - Pagination rejects automatic fetch redirects so custom headers cannot reach another origin. Pass a direct same-origin page URL instead of a redirecting URL; links to later pages must also use direct URLs. Other fetch options in the current Effect context are preserved. Use a client without redirect-following middleware.
48
+ - GitHub listing methods inherit the redirect restriction. List the repository's current API path instead of an endpoint that redirects after a rename or transfer.
49
+
3
50
  ## 0.8.6 - 2026-11-01
4
51
 
5
52
  ### Fixed
package/README.md CHANGED
@@ -42,7 +42,7 @@ await Effect.runPromise(program.pipe(Effect.provide(github)));
42
42
 
43
43
  The default base URL is `https://api.github.com`. Provide `GitHubConfig` from `@shivaedev/github/GitHubConfig.ts` with `{ apiUrl }` for another GitHub API endpoint.
44
44
 
45
- `read(path, schema)` decodes a JSON response. `list(path, itemSchema)` returns a Stream of decoded items across GitHub's Link pages, and stops fetching when consumption stops. `write(method, path, body, schema)` sends JSON with `POST` or `PATCH` and decodes its response. JSON response bodies default to a 1 MiB cap. Set `GitHubConfig.responseByteLimit` once for a client or pass a trailing byte-limit argument to these helpers for one request. The cap applies to each list page and is enforced while reading, before Schema decoding; exceeding it fails with typed `ResponseBodyTooLarge` without buffering the complete body. The conditional cache holds up to 1000 representations and keeps decoded values separate for each Schema.
45
+ `read(path, schema)` decodes a JSON response. `list(path, itemSchema)` returns a Stream of decoded items across GitHub's Link pages, and stops fetching when consumption stops. `write(method, path, body, schema)` sends JSON with `POST`, `PATCH`, `PUT` or `DELETE` and decodes its response. Pass `undefined` for a bodyless request. For a 204 response, omit the schema to receive `void`, or pass `Schema.Void` explicitly; the service representation also supports `decode(Schema.Void)`. These mutations share authentication, quota observations and the single installation-token refresh. JSON response bodies default to a 1 MiB cap. Set `GitHubConfig.responseByteLimit` once for a client or pass a trailing byte-limit argument to these helpers for one request. The cap applies to each list page and is enforced while reading, before Schema decoding; exceeding it fails with typed `ResponseBodyTooLarge` without buffering the complete body. The conditional cache holds up to 1000 representations and keeps decoded values separate for each Schema.
46
46
 
47
47
  For large comparisons and listings, allow up to 16 MiB through the same bounded, Schema-decoded path instead of maintaining a separate fetch, body reader and decoder in the application.
48
48
 
@@ -76,7 +76,7 @@ const diff = Effect.gen(function* () {
76
76
 
77
77
  The service's `read(path)` and `write(method, path, body)` return a representation with a generic `decode(schema)` method. Its `list(path)` returns an Effect containing a Stream of page representations. These methods accept the same trailing byte limit: `read(path, byteLimit)`, `write(method, path, body, byteLimit)` and `list(path, beforeRequest, byteLimit)`; pass `undefined` for `beforeRequest` when no pacing callback is needed. Prefer the exported functions for Schema-decoded items. Each page representation exposes `hasNext`, which reports whether its Link header names a next page without fetching it. A caller with a pacing policy can pass `github.list(path, beforeRequest)`: the callback receives the resource name before each page request, and its typed failure stops the stream. `github.resource(path)` returns the observed resource for that path, defaulting to core or search before the first response.
78
78
 
79
- Use `listBounded(path, itemSchema, pageCap, byteLimit?)` to collect a prefix within a positive page cap. It returns `{ items, truncated }`; `truncated` is true exactly when the last fetched page names a next page. It never requests or decodes a page beyond the cap, and uses the same pagination and per-page byte limits as `list`.
79
+ Use `listBounded(path, itemSchema, pageCap, options?)` to collect a prefix within a positive page cap. It returns `{ items, skipped, truncated }`; `truncated` is true exactly when the last fetched page names a next page. It never requests or decodes a page beyond the cap, and uses the same pagination and per-page byte limits as `list`.
80
80
 
81
81
  ```ts
82
82
  import { listBounded } from "@shivaedev/github/GitHub.ts";
@@ -84,6 +84,55 @@ import { listBounded } from "@shivaedev/github/GitHub.ts";
84
84
  const issues = listBounded("/repos/acme/widgets/issues", Issue, 2);
85
85
  ```
86
86
 
87
+ ### Enveloped lists and partial decoding
88
+
89
+ `list`, `listPages` and `listBounded` accept either the existing numeric byte cap or a `ListOptions` object (from `@shivaedev/github/pagedLists.ts`) as their last argument. Set `arrayKey` to read an array inside an envelope, such as `check_runs` or `artifacts`. The options also accept `responseByteLimit` and the service's `beforeRequest` pacing callback. Item Schemas retain their decoding service requirements.
90
+
91
+ ```ts
92
+ const checks = listBounded("/repos/acme/widgets/commits/main/check-runs", CheckRun, 2, {
93
+ arrayKey: "check_runs",
94
+ onDecodeError: "skip",
95
+ responseByteLimit: 2 * 1024 * 1024,
96
+ });
97
+ ```
98
+
99
+ Import `CheckRun` from `@shivaedev/github/checkRuns.ts`. Decoding fails at the first invalid item by default. With `onDecodeError: "skip"`, valid items keep their order and `skipped` counts rejected items across the fetched pages. Invalid JSON, a missing envelope key, a non-array value, HTTP failures and byte-limit failures still fail the read. A page containing only rejected items still follows its next Link. The page cap counts fetched pages, including empty pages, and never counts pages beyond the cap.
100
+
101
+ `listPages(path, itemSchema, options?)` returns a Stream of `{ items, skipped, hasNext }` pages for consumers that need streaming counts. `list` flattens those pages into items. Both fetch only while consumed.
102
+
103
+ ### Daily repository reads
104
+
105
+ The `GitHub` service includes Schema-decoded reads for common repository work:
106
+
107
+ | Method | Result |
108
+ | -------------------------------------------------------------- | ------------------------------------------------------------------ |
109
+ | `getIssue(owner, repo, number, byteLimit?)` | `Issue` |
110
+ | `getUser(username, byteLimit?)` | `User` |
111
+ | `getCollaboratorPermission(owner, repo, username, byteLimit?)` | `CollaboratorPermission` |
112
+ | `getCommit(owner, repo, ref, byteLimit?)` | `CommitDetail`, including stats and optional files |
113
+ | `compareCommits(owner, repo, base, head, byteLimit?)` | `Comparison`, including commits, optional files and `totalCommits` |
114
+ | `getTree(owner, repo, ref, options?)` | `GitTree`, including GitHub's `truncated` flag |
115
+ | `listIssues(owner, repo, options?)` | Effect containing a Stream of decoded issue pages |
116
+ | `listPullRequestFiles(owner, repo, number, options?)` | Effect containing a Stream of decoded file pages |
117
+ | `listCommits(owner, repo, options?)` | Effect containing a Stream of decoded commit pages |
118
+
119
+ The Schemas live in `@shivaedev/github/repositorySchemas.ts` and `@shivaedev/github/gitSchemas.ts`. Results use camelCase field names. Commit authors can be null, file patches can be absent for binary files, and renamed files expose `previousFilename`.
120
+
121
+ ```ts
122
+ const review = Effect.gen(function* () {
123
+ const github = yield* GitHub;
124
+ const issue = yield* github.getIssue("acme", "widgets", 3);
125
+ const pages = yield* github.listPullRequestFiles("acme", "widgets", 3, { responseByteLimit: 2 * 1024 * 1024 });
126
+ const files = yield* pages.pipe(
127
+ Stream.flatMap((page) => Stream.fromIterable(page.items)),
128
+ Stream.runCollect,
129
+ );
130
+ return { issue, files };
131
+ });
132
+ ```
133
+
134
+ These reads share the client's authentication, conditional representations and body caps. List options accept `query` for GitHub filters such as issue `state` or commit `sha`, along with the decoding and pacing options above. Lists request `per_page=100` unless overridden in `query`. Tree options accept `recursive` and `responseByteLimit`. Refs containing slashes are encoded as path segments. Compare and single-commit reads return one GitHub response; use `totalCommits` and GitHub's endpoint limits when choosing whether the response is sufficient for your application.
135
+
87
136
  ### Acting as a GitHub App
88
137
 
89
138
  Provide `githubApp` as the `GitHubToken` Layer. With just the App ID and a PEM private key wrapped in `Redacted`, the client signs App JWTs for App-level requests. Add an installation ID for installation requests. Both use the same Schema-aware `read` and paginated `list` functions.
@@ -116,6 +165,43 @@ const repository = read("/repos/acme/widgets", Schema.Struct({ id: Schema.Number
116
165
 
117
166
  Provide `clients(privateKey).app` to `appInfo` or `installations`, and `.accessCheck` to `repository`, using `Effect.provide`. The `.background` client receives a full installation token. These Layers replace application-written JWT signing, installation-token exchanges, expiry timers and the resource-401 refresh wrapper; provide the client Layer once around the application or job for each installation and scope to share its token cache.
118
167
 
168
+ A single `githubApp(credentials)` Layer also provides `GitHubApp`, whose `installation(id)` method returns a cached `GitHub` client. Use `withInstallation` to run the usual GitHub operations for a selected installation without maintaining client Layers in the application:
169
+
170
+ ```ts
171
+ import { Effect, Layer, Redacted, Schema } from "effect";
172
+ import { FetchHttpClient } from "effect/http";
173
+ import { read } from "@shivaedev/github/GitHub.ts";
174
+ import { githubApp } from "@shivaedev/github/githubApp.ts";
175
+ import { withInstallation } from "@shivaedev/github/installationClients.ts";
176
+
177
+ const app = (privateKey: Redacted.Redacted<string>) => githubApp({ appId: "123456", privateKey }).pipe(Layer.provide(FetchHttpClient.layer));
178
+ const Repositories = Schema.Struct({ repositories: Schema.Array(Schema.Struct({ id: Schema.Number, name: Schema.String })) });
179
+ const repositories = read("/installation/repositories", Repositories);
180
+ const bothInstallations = Effect.all([withInstallation(7, repositories), withInstallation(8, repositories)]);
181
+ ```
182
+
183
+ Provide `app(privateKey)` once around `bothInstallations` or the application. Each installation has its own full-access token, conditional representations and rate-limit state. A 401 refreshes only that installation's rejected token; a rate limit blocks only that installation's resource. Existing scoped single-installation Layers remain available for access checks.
184
+
185
+ The shared cache holds at most 100 installation clients and evicts the least recently used client when full. Selecting a client renews its 30-minute idle lifetime; a client idle for that interval is replaced on its next selection. Expiry is lazy, with no background timer. Eviction discards the client's token and request state, and its next request exchanges a new token. Keep operations inside `withInstallation` so each selection uses the cache's current client.
186
+
187
+ ### Repositories visible to an account
188
+
189
+ Pass the GitHub account from your application's session to `GitHubApp.viewerRepositories(account)`. A number is a GitHub account ID; a string is a login. Provide the same `githubApp(credentials)` Layer as above. The package has no dependency on your session or identity service.
190
+
191
+ ```ts
192
+ import { Effect } from "effect";
193
+ import { GitHubApp } from "@shivaedev/github/installationClients.ts";
194
+
195
+ const access = Effect.gen(function* () {
196
+ const app = yield* GitHubApp;
197
+ return yield* app.viewerRepositories(12345);
198
+ });
199
+ ```
200
+
201
+ The result contains `readable` and `administrable` arrays. Each entry has `id`, `name`, `owner.login`, `htmlUrl`, `private` and `installationId`. Only repositories installed for this App are included, across all installation and repository pages. Public repositories are readable even without a collaborator grant. Private repositories require GitHub's `read`, `triage`, `write`, `maintain` or `admin` permission; only `admin` grants administration. A permission lookup returning 404 means no collaborator grant. Other lookup failures fail the operation instead of returning partial access. The App needs repository Metadata read permission for these lookups.
202
+
203
+ Successful results are cached for 60 seconds, across at most 100 account keys, evicting the least recently used key when full. Login casing shares a key. Reads do not extend the lifetime. After expiry, the next call revalidates installation lists, repository lists and permissions, so revoked access, changed roles and removed repositories remain cached for at most that interval. Failed lookups are immediately eligible for another attempt. These are cached observations of GitHub access; the application chooses its authorization policy.
204
+
119
205
  `scope` accepts `repositoryIds` or `repositories` (repository names), and a `permissions` record of GitHub permission names to `"read"`, `"write"` or `"admin"`. Omit `scope` for the installation's full access. Explicit `{ permissions: { metadata: "read" } }` requests a metadata-only token for access checks. GitHub decides whether the requested scope is allowed.
120
206
 
121
207
  Alternatively, pass `{ appId, privateKey, owner: "acme", repository: "widgets", scope? }`. The Layer resolves the installation through `GET /repos/{owner}/{repo}/installation` before exchanging the App JWT for an installation token. Signing uses Node's built-in crypto. The RS256 JWT carries the App ID as `iss`, `iat` 60 seconds before the current time, and `exp` ten minutes ahead.
@@ -124,28 +210,53 @@ Alternatively, pass `{ appId, privateKey, owner: "acme", repository: "widgets",
124
210
 
125
211
  An installation token is fetched on the first request and reused until five minutes before `expires_at`, using Effect's Clock. Concurrent requests through the same Layer share the in-flight exchange, including at refresh. A resource 401 invalidates only the rejected cached token and retries once with a newly exchanged token; simultaneous 401s share that exchange. A second 401 fails with typed `RemoteUnauthorized`. App-level JWTs and custom tokens are not automatically retried.
126
212
 
127
- App authentication failures use `GitHubAppError` from `errors.ts`. Its string `reason` distinguishes `"sign"` (JWT signing), `"encode"` (request body encoding), `"transport"` (sending or reading the response), `"status"` (an HTTP refusal) and `"response"` (invalid or oversized response data). A refused exchange or installation lookup with HTTP 401 has `{ reason: "status", status: 401 }`. Caught failures remain available as a non-enumerable `cause`, excluded from JSON serialization; causes can contain credentials from the underlying HTTP request, so keep them in trusted diagnostics. The serialized errors, logs and spans exclude the private key, App JWT and installation token.
213
+ Installation lookups and token exchanges follow the same primary and secondary rate-limit rules as resource and App-level requests: they fail with `GitHubRateLimited` and block further authentication requests locally until `retryAt`. Retry scheduling belongs to the caller.
214
+
215
+ Other App authentication failures use `GitHubAppError` from `errors.ts`. Its string `reason` distinguishes `"sign"` (JWT signing), `"encode"` (request body encoding), `"transport"` (sending or reading the response), `"status"` (an HTTP refusal) and `"response"` (invalid or oversized response data). A refused exchange or installation lookup with HTTP 401 has `{ reason: "status", status: 401 }`. Caught failures remain available as a non-enumerable `cause`, excluded from JSON serialization; causes can contain credentials from the underlying HTTP request, so keep them in trusted diagnostics. The serialized errors, logs and spans exclude the private key, App JWT and installation token.
128
216
 
129
- ### Downloading workflow-artifact archives
217
+ ### Downloading and extracting workflow artifacts
130
218
 
131
- Call `downloadArtifactArchive` with the artifact ID and a byte cap. It requests GitHub's ZIP endpoint with the current installation token, reads the 302 Location explicitly, and issues a fresh request to storage without the GitHub Authorization or API-version headers. Fetch requests use manual redirects and omit ambient credentials; provide an HTTP client without automatic redirect middleware.
219
+ Install the optional ZIP reader to extract artifacts:
220
+
221
+ ```sh
222
+ pnpm add @zip.js/zip.js
223
+ ```
224
+
225
+ Call `downloadArtifactArchive` with the artifact ID and a download byte cap. It requests GitHub's ZIP endpoint with the current installation token, reads the 302 Location explicitly, and issues a fresh request to storage without the GitHub Authorization or API-version headers. Fetch requests use manual redirects and omit ambient credentials; provide an HTTP client without automatic redirect middleware.
132
226
 
133
227
  ```ts
134
228
  import { Effect } from "effect";
135
229
  import { GitHub } from "@shivaedev/github/GitHub.ts";
136
230
 
137
- const archive = Effect.gen(function* () {
231
+ const files = Effect.gen(function* () {
138
232
  const github = yield* GitHub;
139
233
  return yield* github.downloadArtifactArchive({
140
234
  owner: "acme",
141
235
  repo: "widgets",
142
236
  artifactId: 7,
143
237
  byteLimit: 10 * 1024 * 1024,
238
+ totalSizeLimit: 100 * 1024 * 1024,
239
+ compressionRatioLimit: 100,
144
240
  });
145
241
  });
146
242
  ```
147
243
 
148
- Provide an installation client such as `clients(privateKey).background` from the GitHub App example above. The result is a `Uint8Array` of archive bytes. The package counts bytes while streaming, independently of transport limits, and stops on overflow with `GitHubArtifactTooLarge({ limit })` from `errors.ts`. This replaces application-owned redirect handling and archive body limits. It does not extract the ZIP.
244
+ Provide an installation client such as `clients(privateKey).background` from the GitHub App example above. The result is an array of `{ path, bytes }` files (`ArtifactFile` from `artifactExtraction.ts`), with relative POSIX paths and `Uint8Array` contents. Directory entries are validated and omitted from the result. Extraction happens in memory and writes no files. The ZIP reader loads on demand; other GitHub operations do not need it.
245
+
246
+ `byteLimit` caps the downloaded ZIP while streaming and fails with `GitHubArtifactTooLarge({ limit })` from `errors.ts`. Extraction defaults to a `totalSizeLimit` of 100 MiB across all entries and a per-entry `compressionRatioLimit` of 100:1. Both caps are inclusive. The total limit must be a non-negative safe integer; the ratio must be finite and positive. Limits check declared sizes before decompression and emitted bytes before retaining them. No partial file list is returned after a refusal.
247
+
248
+ Extraction failures come from `artifactErrors.ts`:
249
+
250
+ | Error | Refusal |
251
+ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
252
+ | `GitHubArtifactPathTraversal({ path })` | Absolute paths, drive paths, backslashes, colons, NUL, empty or dot path components, and parent traversal |
253
+ | `GitHubArtifactSymlink({ path })` | Symbolic links |
254
+ | `GitHubArtifactDuplicateEntry({ path })` | Duplicate paths and file/directory conflicts |
255
+ | `GitHubArtifactCompressionRatio({ path, limit })` | An entry exceeds the ratio cap |
256
+ | `GitHubArtifactExtractedTooLarge({ limit })` | Combined extracted contents exceed the total cap |
257
+ | `GitHubArtifactExtractionError({ reason })` | Missing reader (`"reader-unavailable"`), malformed ZIP (`"invalid-archive"`) or invalid caps (`"invalid-limits"`) |
258
+
259
+ Paths remain case-sensitive.
149
260
 
150
261
  A missing (404) or expired (410) artifact fails with `GitHubArtifactError({ stage: "redirect", status })`. A storage HTTP failure uses the same error with `stage: "download"`. Transport and GitHub rate-limit failures retain their usual typed errors.
151
262
 
@@ -189,7 +300,30 @@ const report = Effect.gen(function* () {
189
300
  });
190
301
  ```
191
302
 
192
- `createCheckRun(owner, repo, input)` creates a check for `headSha` with a name and status. `updateCheckRun(owner, repo, id, input)` moves it to `in_progress` or `completed`; completion requires a conclusion. Output contains `title`, `summary` and optional `text`. An optional `detailsUrl` on creation and either update shape links to an external report; the returned `CheckRun.detailsUrl` is the GitHub URL or `null` when none exists. It is sent and decoded as `details_url`. Request and response Schemas are exported from `checkRuns.ts`.
303
+ `createCheckRun(owner, repo, input)` creates a check for `headSha` with a name and status. `updateCheckRun(owner, repo, id, input)` moves it to `in_progress` or `completed`; both creation and update with `status: "completed"` require a conclusion, including runtime validation before any request. Output contains `title`, `summary` and optional `text`. An optional `detailsUrl` on creation and either update shape links to an external report; the returned `CheckRun.detailsUrl` is the GitHub URL or `null` when none exists. It is sent and decoded as `details_url`. Request and response Schemas are exported from `checkRuns.ts`.
304
+
305
+ `findLatestCheckRun(owner, repo, { headSha, name?, appId? })` pages the commit's check runs and returns an `Option<CheckRun>`. Supply a name, an app ID, or both; both filters must match when supplied together. It selects the greatest run ID across all pages. `CheckRun.app` exposes the app ID when GitHub supplies it. Read statuses include GitHub Actions-only `waiting`, `requested` and `pending`, so another app's run in one of those states does not prevent ownership lookup.
306
+
307
+ `upsertCheckRun(owner, repo, input, appId?)` uses the same name and commit lookup, then updates the latest match or creates a run when none exists. Pass the app ID to keep another app's same-named check untouched. Without an app ID, use a name unique to your app. A lookup failure propagates without creating a run. Lookup and mutation are separate GitHub requests, so concurrent callers can create duplicates; callers own serialization.
308
+
309
+ ```ts
310
+ const publishCheck = (headSha: string) =>
311
+ Effect.gen(function* () {
312
+ const github = yield* GitHub;
313
+ return yield* github.upsertCheckRun(
314
+ "acme",
315
+ "widgets",
316
+ {
317
+ headSha,
318
+ name: "Coverage",
319
+ status: "completed",
320
+ conclusion: "success",
321
+ output: { title: "Coverage", summary: "90% covered" },
322
+ },
323
+ 123456,
324
+ );
325
+ });
326
+ ```
193
327
 
194
328
  ```ts
195
329
  const check = (headSha: string) =>