@mannyc1/ts-release 0.2.2 → 0.3.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 (107) hide show
  1. package/ARCHITECTURE.md +60 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +129 -6
  4. package/SPEC.md +51 -0
  5. package/apps/release-ts/README.md +7 -0
  6. package/apps/ts-release-agents/package.json +1 -1
  7. package/apps/ts-release-agents/src/check.ts +7 -2
  8. package/apps/ts-release-agents/test/contract.test.ts +2 -2
  9. package/dist/bin/ts-release.js +154 -47
  10. package/dist/drivers/archive.d.ts +2 -1
  11. package/dist/drivers/archive.d.ts.map +1 -1
  12. package/dist/drivers/archive.js +12 -8
  13. package/dist/drivers/archive.js.map +1 -1
  14. package/dist/drivers/process.d.ts.map +1 -1
  15. package/dist/drivers/process.js +4 -4
  16. package/dist/drivers/process.js.map +1 -1
  17. package/dist/drivers/python-wheel.d.ts +21 -0
  18. package/dist/drivers/python-wheel.d.ts.map +1 -0
  19. package/dist/drivers/python-wheel.js +109 -0
  20. package/dist/drivers/python-wheel.js.map +1 -0
  21. package/dist/drivers/workspace.d.ts +3 -0
  22. package/dist/drivers/workspace.d.ts.map +1 -1
  23. package/dist/drivers/workspace.js +5 -1
  24. package/dist/drivers/workspace.js.map +1 -1
  25. package/dist/operation-journal/authority.d.ts +3 -0
  26. package/dist/operation-journal/authority.d.ts.map +1 -0
  27. package/dist/operation-journal/authority.js +147 -0
  28. package/dist/operation-journal/authority.js.map +1 -0
  29. package/dist/operation-journal/aws/deadline.d.ts +12 -0
  30. package/dist/operation-journal/aws/deadline.d.ts.map +1 -0
  31. package/dist/operation-journal/aws/deadline.js +38 -0
  32. package/dist/operation-journal/aws/deadline.js.map +1 -0
  33. package/dist/operation-journal/aws/oidc.d.ts +27 -0
  34. package/dist/operation-journal/aws/oidc.d.ts.map +1 -0
  35. package/dist/operation-journal/aws/oidc.js +361 -0
  36. package/dist/operation-journal/aws/oidc.js.map +1 -0
  37. package/dist/operation-journal/aws/policy.d.ts +42 -0
  38. package/dist/operation-journal/aws/policy.d.ts.map +1 -0
  39. package/dist/operation-journal/aws/policy.js +404 -0
  40. package/dist/operation-journal/aws/policy.js.map +1 -0
  41. package/dist/operation-journal/aws/s3-boundary.d.ts +17 -0
  42. package/dist/operation-journal/aws/s3-boundary.d.ts.map +1 -0
  43. package/dist/operation-journal/aws/s3-boundary.js +417 -0
  44. package/dist/operation-journal/aws/s3-boundary.js.map +1 -0
  45. package/dist/operation-journal/aws.d.ts +11 -0
  46. package/dist/operation-journal/aws.d.ts.map +1 -0
  47. package/dist/operation-journal/aws.js +50 -0
  48. package/dist/operation-journal/aws.js.map +1 -0
  49. package/dist/operation-journal/canonical.d.ts +52 -0
  50. package/dist/operation-journal/canonical.d.ts.map +1 -0
  51. package/dist/operation-journal/canonical.js +282 -0
  52. package/dist/operation-journal/canonical.js.map +1 -0
  53. package/dist/operation-journal/model.d.ts +251 -0
  54. package/dist/operation-journal/model.d.ts.map +1 -0
  55. package/dist/operation-journal/model.js +56 -0
  56. package/dist/operation-journal/model.js.map +1 -0
  57. package/dist/operation-journal/reducer.d.ts +28 -0
  58. package/dist/operation-journal/reducer.d.ts.map +1 -0
  59. package/dist/operation-journal/reducer.js +73 -0
  60. package/dist/operation-journal/reducer.js.map +1 -0
  61. package/dist/operation-journal/s3.d.ts +7 -0
  62. package/dist/operation-journal/s3.d.ts.map +1 -0
  63. package/dist/operation-journal/s3.js +667 -0
  64. package/dist/operation-journal/s3.js.map +1 -0
  65. package/dist/operation-journal.d.ts +14 -0
  66. package/dist/operation-journal.d.ts.map +1 -0
  67. package/dist/operation-journal.js +12 -0
  68. package/dist/operation-journal.js.map +1 -0
  69. package/dist/platform/credentials.d.ts.map +1 -1
  70. package/dist/platform/credentials.js +65 -3
  71. package/dist/platform/credentials.js.map +1 -1
  72. package/dist/publication/github.d.ts.map +1 -1
  73. package/dist/publication/github.js +66 -15
  74. package/dist/publication/github.js.map +1 -1
  75. package/dist/release/capabilities.d.ts.map +1 -1
  76. package/dist/release/capabilities.js +2 -2
  77. package/dist/release/capabilities.js.map +1 -1
  78. package/dist/release/prepare.d.ts.map +1 -1
  79. package/dist/release/prepare.js +4 -5
  80. package/dist/release/prepare.js.map +1 -1
  81. package/dist/release/prepared-store.d.ts.map +1 -1
  82. package/dist/release/prepared-store.js +11 -9
  83. package/dist/release/prepared-store.js.map +1 -1
  84. package/package.json +19 -4
  85. package/src/drivers/archive.ts +17 -8
  86. package/src/drivers/process.ts +3 -4
  87. package/src/drivers/python-wheel.ts +145 -0
  88. package/src/drivers/workspace.ts +7 -1
  89. package/src/operation-journal/authority.ts +162 -0
  90. package/src/operation-journal/aws/deadline.ts +41 -0
  91. package/src/operation-journal/aws/oidc.ts +439 -0
  92. package/src/operation-journal/aws/policy.ts +491 -0
  93. package/src/operation-journal/aws/s3-boundary.ts +562 -0
  94. package/src/operation-journal/aws.ts +60 -0
  95. package/src/operation-journal/canonical.ts +373 -0
  96. package/src/operation-journal/model.ts +301 -0
  97. package/src/operation-journal/reducer.ts +112 -0
  98. package/src/operation-journal/s3.ts +884 -0
  99. package/src/operation-journal.ts +51 -0
  100. package/src/platform/credentials.ts +71 -3
  101. package/src/publication/github.ts +83 -15
  102. package/src/release/capabilities.ts +3 -2
  103. package/src/release/prepare.ts +3 -5
  104. package/src/release/prepared-store.ts +8 -7
  105. package/templates/README.md +2 -2
  106. package/templates/github-actions/release.yml +24 -7
  107. package/templates/github-actions/reviewed-release.yml +49 -14
@@ -0,0 +1,41 @@
1
+ export const operationJournalNetworkDeadlineMilliseconds = 10_000
2
+
3
+ export class OperationJournalNetworkDeadlineExceeded extends Error {
4
+ readonly name = "OperationJournalNetworkDeadlineExceeded"
5
+
6
+ constructor() {
7
+ super("The operational-journal network deadline elapsed.")
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Bound one complete network operation even when the underlying transport
13
+ * ignores AbortSignal. The signal still gives cooperative transports an
14
+ * immediate opportunity to close DNS, sockets, and response streams.
15
+ */
16
+ export const withOperationJournalNetworkDeadline = async <Value>(
17
+ run: (signal: AbortSignal) => Promise<Value>,
18
+ milliseconds = operationJournalNetworkDeadlineMilliseconds
19
+ ): Promise<Value> => {
20
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 1 ||
21
+ milliseconds > operationJournalNetworkDeadlineMilliseconds) {
22
+ throw new Error("The operational-journal network deadline is not canonical.")
23
+ }
24
+ const controller = new AbortController()
25
+ let timer: ReturnType<typeof setTimeout> | undefined
26
+ const deadline = new Promise<never>((_resolve, reject) => {
27
+ timer = setTimeout(() => {
28
+ const cause = new OperationJournalNetworkDeadlineExceeded()
29
+ controller.abort(cause)
30
+ reject(cause)
31
+ }, milliseconds)
32
+ })
33
+ try {
34
+ return await Promise.race([
35
+ Promise.resolve().then(() => run(controller.signal)),
36
+ deadline
37
+ ])
38
+ } finally {
39
+ if (timer !== undefined) clearTimeout(timer)
40
+ }
41
+ }
@@ -0,0 +1,439 @@
1
+ import { Buffer } from "node:buffer"
2
+ import { existsSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import {
5
+ AssumeRoleWithWebIdentityCommand,
6
+ GetCallerIdentityCommand,
7
+ STSClient,
8
+ type STSClientConfig
9
+ } from "@aws-sdk/client-sts"
10
+ import * as Effect from "effect/Effect"
11
+ import { parseStrictJson, type Json } from "../../model/canonical.js"
12
+ import { validateS3JournalAuthority } from "../authority.js"
13
+ import {
14
+ S3JournalBoundaryError,
15
+ type S3JournalAuthority,
16
+ type S3JournalOidcClaims
17
+ } from "../model.js"
18
+ import {
19
+ operationJournalNetworkDeadlineMilliseconds,
20
+ withOperationJournalNetworkDeadline
21
+ } from "./deadline.js"
22
+
23
+ export interface AwsJournalCredentials {
24
+ readonly accessKeyId: string
25
+ readonly secretAccessKey: string
26
+ readonly sessionToken: string
27
+ readonly expiration: Date
28
+ }
29
+
30
+ export interface AwsJournalSession {
31
+ readonly credentials: AwsJournalCredentials
32
+ readonly oidc: S3JournalOidcClaims
33
+ readonly callerArn: string
34
+ }
35
+
36
+ export interface AwsJournalOidcRuntime {
37
+ readonly environment: Record<string, string | undefined>
38
+ readonly fileExists: (path: string) => boolean
39
+ readonly fetch: typeof globalThis.fetch
40
+ readonly now: () => Date
41
+ readonly makeAnonymousSts: (config: STSClientConfig) => STSClient
42
+ readonly makeSessionSts: (config: STSClientConfig) => STSClient
43
+ /** Internal qualification seam; production always uses the fixed maximum. */
44
+ readonly networkDeadlineMilliseconds?: number
45
+ }
46
+
47
+ const maximumOidcResponseBytes = 16_384
48
+ const maximumOidcTokenCharacters = 12_000
49
+ const maximumOidcHeaderCharacters = 2_048
50
+ const maximumOidcPayloadCharacters = 8_192
51
+ const maximumOidcSignatureCharacters = 2_048
52
+
53
+ const fail = (reason: string): never => {
54
+ throw S3JournalBoundaryError.make({
55
+ operation: "observe-authority",
56
+ commitment: "not-applicable",
57
+ reason
58
+ })
59
+ }
60
+
61
+ const boundaryError = (cause: unknown, reason: string): S3JournalBoundaryError =>
62
+ cause instanceof S3JournalBoundaryError
63
+ ? cause
64
+ : S3JournalBoundaryError.make({
65
+ operation: "observe-authority",
66
+ commitment: "not-applicable",
67
+ reason
68
+ })
69
+
70
+ const isObject = (value: Json): value is { readonly [key: string]: Json } =>
71
+ typeof value === "object" && value !== null && !Array.isArray(value)
72
+
73
+ const requireString = (
74
+ object: { readonly [key: string]: Json },
75
+ field: string
76
+ ): string => {
77
+ const value = object[field]
78
+ if (typeof value !== "string" || value.length === 0) fail(`GitHub OIDC claim ${field} is missing or invalid.`)
79
+ return value as string
80
+ }
81
+
82
+ const requireInteger = (
83
+ object: { readonly [key: string]: Json },
84
+ field: string
85
+ ): number => {
86
+ const value = object[field]
87
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
88
+ fail(`GitHub OIDC claim ${field} is missing or invalid.`)
89
+ }
90
+ return value as number
91
+ }
92
+
93
+ const decodeBase64Url = (value: string, maximum: number, label: string): string => {
94
+ if (value.length > maximum || !/^[A-Za-z0-9_-]+$/u.test(value)) {
95
+ fail(`${label} is not bounded canonical base64url.`)
96
+ }
97
+ const bytes = Buffer.from(value, "base64url")
98
+ if (bytes.toString("base64url") !== value) fail(`${label} is not canonical base64url.`)
99
+ try {
100
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
101
+ } catch {
102
+ return fail(`${label} is not UTF-8.`)
103
+ }
104
+ }
105
+
106
+ const decodeOidcToken = (token: string, now: Date): S3JournalOidcClaims => {
107
+ if (token.length < 64 || token.length > maximumOidcTokenCharacters ||
108
+ !/^[A-Za-z0-9_.-]+$/u.test(token)) {
109
+ fail("GitHub OIDC response is not one bounded compact JWT.")
110
+ }
111
+ const segments = token.split(".")
112
+ if (segments.length !== 3 || segments.some((segment) => segment.length === 0)) {
113
+ fail("GitHub OIDC response is not one compact JWT.")
114
+ }
115
+ const signature = segments[2]!
116
+ if (signature.length > maximumOidcSignatureCharacters ||
117
+ Buffer.from(signature, "base64url").toString("base64url") !== signature) {
118
+ fail("GitHub OIDC signature is not bounded canonical base64url.")
119
+ }
120
+ let header: Json
121
+ let payload: Json
122
+ try {
123
+ header = parseStrictJson(decodeBase64Url(
124
+ segments[0]!,
125
+ maximumOidcHeaderCharacters,
126
+ "GitHub OIDC header"
127
+ ))
128
+ payload = parseStrictJson(decodeBase64Url(
129
+ segments[1]!,
130
+ maximumOidcPayloadCharacters,
131
+ "GitHub OIDC payload"
132
+ ))
133
+ } catch (cause) {
134
+ if (cause instanceof S3JournalBoundaryError) throw cause
135
+ return fail("GitHub OIDC JWT does not contain strict JSON.")
136
+ }
137
+ if (!isObject(header) || header.alg !== "RS256" || header.typ !== "JWT" ||
138
+ typeof header.kid !== "string" || header.kid.length === 0) {
139
+ fail("GitHub OIDC JWT header is not the admitted RS256 identity.")
140
+ }
141
+ if (!isObject(payload)) fail("GitHub OIDC JWT payload must be an object.")
142
+ const claimsPayload = payload as { readonly [key: string]: Json }
143
+ const nowSeconds = Math.floor(now.getTime() / 1000)
144
+ const issuedAt = requireInteger(claimsPayload, "iat")
145
+ const notBefore = requireInteger(claimsPayload, "nbf")
146
+ const expiresAt = requireInteger(claimsPayload, "exp")
147
+ if (issuedAt > nowSeconds + 30 || notBefore > nowSeconds + 30 || expiresAt <= nowSeconds + 30 ||
148
+ expiresAt <= issuedAt || expiresAt - issuedAt > 900) {
149
+ fail("GitHub OIDC JWT validity window is not current and bounded.")
150
+ }
151
+ const claims: S3JournalOidcClaims = {
152
+ issuer: requireString(claimsPayload, "iss") as S3JournalOidcClaims["issuer"],
153
+ audience: requireString(claimsPayload, "aud") as S3JournalOidcClaims["audience"],
154
+ subject: requireString(claimsPayload, "sub"),
155
+ repository: requireString(claimsPayload, "repository"),
156
+ repositoryId: requireString(claimsPayload, "repository_id"),
157
+ repositoryOwnerId: requireString(claimsPayload, "repository_owner_id"),
158
+ repositoryVisibility: requireString(claimsPayload, "repository_visibility") as S3JournalOidcClaims["repositoryVisibility"],
159
+ eventName: requireString(claimsPayload, "event_name") as S3JournalOidcClaims["eventName"],
160
+ ref: requireString(claimsPayload, "ref"),
161
+ refType: requireString(claimsPayload, "ref_type") as S3JournalOidcClaims["refType"],
162
+ sha: requireString(claimsPayload, "sha"),
163
+ environment: requireString(claimsPayload, "environment"),
164
+ runnerEnvironment: requireString(claimsPayload, "runner_environment") as S3JournalOidcClaims["runnerEnvironment"],
165
+ runId: requireString(claimsPayload, "run_id"),
166
+ runAttempt: requireString(claimsPayload, "run_attempt"),
167
+ workflow: requireString(claimsPayload, "workflow"),
168
+ workflowRef: requireString(claimsPayload, "workflow_ref"),
169
+ workflowSha: requireString(claimsPayload, "workflow_sha"),
170
+ jobWorkflowRef: requireString(claimsPayload, "job_workflow_ref"),
171
+ jobWorkflowSha: requireString(claimsPayload, "job_workflow_sha")
172
+ }
173
+ if (claims.issuer !== "https://token.actions.githubusercontent.com" || claims.audience !== "sts.amazonaws.com") {
174
+ fail("GitHub OIDC issuer or audience is not the admitted AWS exchange identity.")
175
+ }
176
+ const immutableJobWorkflow = claims.jobWorkflowRef.match(/@([a-f0-9]{40})$/u)
177
+ if (immutableJobWorkflow?.[1] !== claims.jobWorkflowSha) {
178
+ fail("GitHub OIDC called-workflow ref is not pinned to its exact source SHA.")
179
+ }
180
+ return claims
181
+ }
182
+
183
+ const sameClaims = (left: S3JournalOidcClaims, right: S3JournalOidcClaims): boolean => {
184
+ const fields = Object.keys(left) as ReadonlyArray<keyof S3JournalOidcClaims>
185
+ return fields.length === Object.keys(right).length && fields.every((field) => left[field] === right[field])
186
+ }
187
+
188
+ const requireCleanAwsEnvironment = (
189
+ environment: Readonly<Record<string, string | undefined>>,
190
+ fileExists: (path: string) => boolean
191
+ ): void => {
192
+ const forbiddenHostVariables = new Set([
193
+ "ALL_PROXY",
194
+ "HTTP_PROXY",
195
+ "HTTPS_PROXY",
196
+ "NO_PROXY",
197
+ "NODE_DEBUG",
198
+ "NODE_DEBUG_NATIVE",
199
+ "NODE_EXTRA_CA_CERTS",
200
+ "NODE_OPTIONS",
201
+ "NODE_PATH",
202
+ "NODE_TLS_REJECT_UNAUTHORIZED",
203
+ "NODE_USE_ENV_PROXY",
204
+ "OPENSSL_CONF",
205
+ "SSLKEYLOGFILE",
206
+ "SSL_CERT_DIR",
207
+ "SSL_CERT_FILE",
208
+ "all_proxy",
209
+ "http_proxy",
210
+ "https_proxy",
211
+ "no_proxy"
212
+ ])
213
+ const ambient = Object.entries(environment)
214
+ .filter(([key, value]) => value !== undefined && value !== "" &&
215
+ (key.startsWith("AWS_") || forbiddenHostVariables.has(key)))
216
+ .map(([key]) => key)
217
+ .sort()
218
+ if (ambient.length > 0) {
219
+ fail(`Ambient AWS configuration is forbidden (${ambient.join(", ")}).`)
220
+ }
221
+ const homeDirectories = [environment.HOME, environment.USERPROFILE]
222
+ .filter((value): value is string => value !== undefined && value.length > 0)
223
+ const sharedFiles = [...new Set(homeDirectories)]
224
+ .flatMap((home) => [join(home, ".aws", "credentials"), join(home, ".aws", "config")])
225
+ if (sharedFiles.some(fileExists)) {
226
+ fail("Ambient AWS shared credential or configuration files are forbidden.")
227
+ }
228
+ }
229
+
230
+ const consumeGitHubOidcCoordinates = (
231
+ environment: Record<string, string | undefined>
232
+ ): { readonly requestUrl: string, readonly requestToken: string } => {
233
+ const requestUrl = environment.ACTIONS_ID_TOKEN_REQUEST_URL
234
+ const requestToken = environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN
235
+ delete environment.ACTIONS_ID_TOKEN_REQUEST_URL
236
+ delete environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN
237
+ if (environment.ACTIONS_ID_TOKEN_REQUEST_URL !== undefined ||
238
+ environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN !== undefined) {
239
+ return fail("GitHub OIDC request coordinates could not be consumed from the process environment.")
240
+ }
241
+ if (requestUrl === undefined || requestUrl === "" || requestToken === undefined || requestToken === "") {
242
+ return fail("GitHub OIDC request coordinates are unavailable.")
243
+ }
244
+ if (requestUrl.length > 4_096 || requestToken.length > 8_192 ||
245
+ !/^[!-~]+$/u.test(requestUrl) || !/^[!-~]+$/u.test(requestToken)) {
246
+ return fail("GitHub OIDC request coordinates exceed their canonical bounds.")
247
+ }
248
+ return { requestUrl, requestToken }
249
+ }
250
+
251
+ const readOidcResponse = async (response: Response): Promise<Json> => {
252
+ const rawLength = response.headers.get("content-length")
253
+ if (rawLength === null || !/^[1-9][0-9]{0,5}$/u.test(rawLength)) {
254
+ return fail("GitHub OIDC token response lacks one bounded Content-Length.")
255
+ }
256
+ const contentLength = Number(rawLength)
257
+ if (contentLength > maximumOidcResponseBytes || response.body === null) {
258
+ return fail("GitHub OIDC token response exceeds its byte bound.")
259
+ }
260
+ const bytes = new Uint8Array(contentLength)
261
+ const reader = response.body.getReader()
262
+ let offset = 0
263
+ try {
264
+ while (true) {
265
+ const chunk = await reader.read()
266
+ if (chunk.done) break
267
+ if (offset + chunk.value.length > bytes.length) {
268
+ return fail("GitHub OIDC token response stream exceeded Content-Length.")
269
+ }
270
+ bytes.set(chunk.value, offset)
271
+ offset += chunk.value.length
272
+ }
273
+ } finally {
274
+ reader.releaseLock()
275
+ }
276
+ if (offset !== bytes.length) {
277
+ return fail("GitHub OIDC token response stream disagrees with Content-Length.")
278
+ }
279
+ let text: string
280
+ try {
281
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
282
+ return parseStrictJson(text)
283
+ } catch {
284
+ return fail("GitHub OIDC token response is not strict UTF-8 JSON.")
285
+ }
286
+ }
287
+
288
+ const requestGitHubOidcToken = async (
289
+ requestUrl: string,
290
+ requestToken: string,
291
+ fetchImpl: typeof globalThis.fetch,
292
+ deadlineMilliseconds: number
293
+ ): Promise<string> => {
294
+ let url: URL
295
+ try {
296
+ url = new URL(requestUrl)
297
+ } catch {
298
+ return fail("GitHub OIDC request URL is invalid.")
299
+ }
300
+ const rawAuthority = requestUrl.slice(requestUrl.indexOf("//") + 2).split(/[/?#]/u, 1)[0] ?? ""
301
+ if (url.protocol !== "https:" ||
302
+ !/^[a-z0-9-]+\.actions\.githubusercontent\.com$/u.test(url.hostname) ||
303
+ rawAuthority.includes(":") || url.port !== "" ||
304
+ url.username !== "" || url.password !== "" || url.hash !== "") {
305
+ return fail("GitHub OIDC request URL is not one authenticated Actions HTTPS endpoint.")
306
+ }
307
+ url.searchParams.set("audience", "sts.amazonaws.com")
308
+ if (url.href.length > 4_096 || !/^[!-~]+$/u.test(url.href)) {
309
+ return fail("GitHub OIDC request URL exceeds its canonical bounds after audience binding.")
310
+ }
311
+ return withOperationJournalNetworkDeadline(async (signal) => {
312
+ const response = await fetchImpl(url, {
313
+ method: "GET",
314
+ headers: {
315
+ Accept: "application/json",
316
+ "Accept-Encoding": "identity",
317
+ Authorization: `bearer ${requestToken}`
318
+ },
319
+ redirect: "error",
320
+ signal
321
+ })
322
+ if (!response.ok || response.redirected || response.url !== url.href) {
323
+ fail("GitHub OIDC token request did not return the exact requested endpoint.")
324
+ }
325
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase()
326
+ if (contentType !== "application/json") fail("GitHub OIDC token response is not JSON.")
327
+ const body = await readOidcResponse(response)
328
+ if (!isObject(body) || Object.keys(body).length !== 1 || typeof body.value !== "string") {
329
+ return fail("GitHub OIDC token response fields are not the exact admitted set.")
330
+ }
331
+ return body.value as string
332
+ }, deadlineMilliseconds)
333
+ }
334
+
335
+ const roleName = (roleArn: string): string => roleArn.slice(roleArn.lastIndexOf("/") + 1)
336
+
337
+ export const defaultAwsJournalOidcRuntime: AwsJournalOidcRuntime = {
338
+ environment: process.env,
339
+ fileExists: existsSync,
340
+ fetch: globalThis.fetch,
341
+ now: () => new Date(),
342
+ makeAnonymousSts: (config) => new STSClient(config),
343
+ makeSessionSts: (config) => new STSClient(config)
344
+ }
345
+
346
+ export const acquireAwsJournalSession = (
347
+ authority: S3JournalAuthority,
348
+ runtime: AwsJournalOidcRuntime = defaultAwsJournalOidcRuntime
349
+ ): Effect.Effect<AwsJournalSession, S3JournalBoundaryError> => Effect.tryPromise({
350
+ try: async () => {
351
+ try {
352
+ validateS3JournalAuthority(authority)
353
+ } catch {
354
+ fail("AWS journal activation contract is malformed.")
355
+ }
356
+ const coordinates = consumeGitHubOidcCoordinates(runtime.environment)
357
+ let runnerRequestToken = coordinates.requestToken
358
+ let webIdentityToken = ""
359
+ try {
360
+ requireCleanAwsEnvironment(runtime.environment, runtime.fileExists)
361
+ const deadlineMilliseconds = runtime.networkDeadlineMilliseconds ??
362
+ operationJournalNetworkDeadlineMilliseconds
363
+ webIdentityToken = await requestGitHubOidcToken(
364
+ coordinates.requestUrl,
365
+ runnerRequestToken,
366
+ runtime.fetch,
367
+ deadlineMilliseconds
368
+ )
369
+ const observedClaims = decodeOidcToken(webIdentityToken, runtime.now())
370
+ if (!sameClaims(observedClaims, authority.oidc)) {
371
+ fail("GitHub OIDC claims drifted from the exact activation contract.")
372
+ }
373
+ const sessionName = `ts-release-journal-${observedClaims.runId}-${observedClaims.runAttempt}`
374
+ if (!/^[A-Za-z0-9+=,.@-]{2,64}$/u.test(sessionName)) {
375
+ fail("Derived AWS journal session name is not canonical.")
376
+ }
377
+ const noAmbientCredentials = async (): Promise<never> => {
378
+ throw new Error("Ambient AWS credential resolution is disabled.")
379
+ }
380
+ const anonymous = runtime.makeAnonymousSts({
381
+ region: authority.region,
382
+ maxAttempts: 1,
383
+ credentials: noAmbientCredentials
384
+ })
385
+ const assumed = await withOperationJournalNetworkDeadline((signal) => anonymous.send(
386
+ new AssumeRoleWithWebIdentityCommand({
387
+ RoleArn: authority.roleArn,
388
+ RoleSessionName: sessionName,
389
+ WebIdentityToken: webIdentityToken,
390
+ DurationSeconds: 900
391
+ }),
392
+ { abortSignal: signal }
393
+ ), deadlineMilliseconds)
394
+ const accessKeyId = assumed.Credentials?.AccessKeyId
395
+ const secretAccessKey = assumed.Credentials?.SecretAccessKey
396
+ const sessionToken = assumed.Credentials?.SessionToken
397
+ const expiration = assumed.Credentials?.Expiration
398
+ if (accessKeyId === undefined || secretAccessKey === undefined ||
399
+ sessionToken === undefined || expiration === undefined ||
400
+ accessKeyId.length === 0 || secretAccessKey.length === 0 || sessionToken.length === 0) {
401
+ fail("AWS STS did not return one complete temporary credential set.")
402
+ }
403
+ const now = runtime.now().getTime()
404
+ const validExpiration = expiration as Date
405
+ if (validExpiration.getTime() <= now + 5 * 60_000 || validExpiration.getTime() > now + 20 * 60_000) {
406
+ fail("AWS STS journal session expiration is not current and bounded.")
407
+ }
408
+ const sessionCredentials: AwsJournalCredentials = {
409
+ accessKeyId: accessKeyId as string,
410
+ secretAccessKey: secretAccessKey as string,
411
+ sessionToken: sessionToken as string,
412
+ expiration: validExpiration
413
+ }
414
+ const authenticated = runtime.makeSessionSts({
415
+ region: authority.region,
416
+ maxAttempts: 1,
417
+ credentials: sessionCredentials
418
+ })
419
+ const caller = await withOperationJournalNetworkDeadline((signal) => authenticated.send(
420
+ new GetCallerIdentityCommand({}),
421
+ { abortSignal: signal }
422
+ ), deadlineMilliseconds)
423
+ const expectedArn = `arn:aws:sts::${authority.accountId}:assumed-role/${roleName(authority.roleArn)}/${sessionName}`
424
+ if (caller.Account !== authority.accountId || caller.Arn !== expectedArn ||
425
+ assumed.AssumedRoleUser?.Arn !== expectedArn || caller.UserId === undefined || caller.UserId.length === 0) {
426
+ fail("AWS STS caller identity does not equal the exact admitted journal role session.")
427
+ }
428
+ return {
429
+ credentials: sessionCredentials,
430
+ oidc: observedClaims,
431
+ callerArn: expectedArn
432
+ }
433
+ } finally {
434
+ runnerRequestToken = ""
435
+ webIdentityToken = ""
436
+ }
437
+ },
438
+ catch: (cause) => boundaryError(cause, "GitHub OIDC to AWS STS journal authentication failed.")
439
+ })