@solana-mobile/dapp-store-publishing-tools 0.16.0 → 1.0.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 (53) hide show
  1. package/lib/CoreUtils.js +0 -3
  2. package/lib/index.js +1 -0
  3. package/lib/portal/attestation.js +189 -0
  4. package/lib/portal/compat.js +3 -0
  5. package/lib/portal/index.js +5 -0
  6. package/lib/portal/signer.js +432 -0
  7. package/lib/portal/types.js +1 -0
  8. package/lib/portal/workflow/contracts.js +1 -0
  9. package/lib/portal/workflow/execution.js +493 -0
  10. package/lib/portal/workflow/ingestion.js +265 -0
  11. package/lib/portal/workflow/lifecycle.js +616 -0
  12. package/lib/portal/workflow/logging.js +8 -0
  13. package/lib/portal/workflow/source/files.js +304 -0
  14. package/lib/portal/workflow/source/preparation.js +318 -0
  15. package/lib/portal/workflow/state/bundle.js +260 -0
  16. package/lib/portal/workflow/state/checkpoints.js +53 -0
  17. package/lib/portal/workflow/state/session.js +100 -0
  18. package/lib/portal/workflow.js +1 -0
  19. package/lib/publish/PublishCoreAttestation.js +18 -17
  20. package/lib/publish/PublishCoreRemove.js +7 -89
  21. package/lib/publish/PublishCoreSubmit.js +7 -117
  22. package/lib/publish/PublishCoreSupport.js +7 -86
  23. package/lib/publish/PublishCoreUpdate.js +7 -117
  24. package/lib/publish/index.js +1 -0
  25. package/lib/schemas/releaseJsonMetadata.json +1 -2
  26. package/package.json +2 -4
  27. package/src/CoreUtils.ts +0 -6
  28. package/src/index.ts +1 -0
  29. package/src/portal/attestation.ts +76 -0
  30. package/src/portal/compat.ts +5 -0
  31. package/src/portal/index.ts +5 -0
  32. package/src/portal/signer.ts +327 -0
  33. package/src/portal/types.ts +447 -0
  34. package/src/portal/workflow/contracts.ts +108 -0
  35. package/src/portal/workflow/execution.ts +412 -0
  36. package/src/portal/workflow/ingestion.ts +187 -0
  37. package/src/portal/workflow/lifecycle.ts +435 -0
  38. package/src/portal/workflow/logging.ts +17 -0
  39. package/src/portal/workflow/source/files.ts +49 -0
  40. package/src/portal/workflow/source/preparation.ts +189 -0
  41. package/src/portal/workflow/state/bundle.ts +193 -0
  42. package/src/portal/workflow/state/checkpoints.ts +70 -0
  43. package/src/portal/workflow/state/session.ts +87 -0
  44. package/src/portal/workflow.ts +9 -0
  45. package/src/publish/PublishCoreAttestation.ts +21 -26
  46. package/src/publish/PublishCoreRemove.ts +13 -109
  47. package/src/publish/PublishCoreSubmit.ts +18 -150
  48. package/src/publish/PublishCoreSupport.ts +13 -102
  49. package/src/publish/PublishCoreUpdate.ts +17 -155
  50. package/src/publish/index.ts +2 -1
  51. package/src/schemas/releaseJsonMetadata.json +1 -2
  52. package/lib/publish/dapp_publisher_portal.js +0 -206
  53. package/src/publish/dapp_publisher_portal.ts +0 -81
@@ -0,0 +1,435 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type {
4
+ PublicationBundle,
5
+ PublicationCleanupReleaseResult,
6
+ PublicationGetSessionInput,
7
+ PublicationSession,
8
+ } from "../types.js";
9
+ import type {
10
+ PublicationResumeInput,
11
+ PublicationWorkflowClient,
12
+ PublicationWorkflowInput,
13
+ PublicationWorkflowOptions,
14
+ PublicationWorkflowPollOptions,
15
+ } from "./contracts.js";
16
+ import { runPublicationWorkflow } from "./execution.js";
17
+ import { waitForIngestionSessionReady } from "./ingestion.js";
18
+ import { logWorkflowInfo } from "./logging.js";
19
+ import {
20
+ hasResolvableReleaseMetadataUri,
21
+ normalizePublicationBundle,
22
+ validatePublicationBundle,
23
+ withPublicationBundleIdentifiers,
24
+ } from "./state/bundle.js";
25
+ import { normalizePublicationSession } from "./state/session.js";
26
+ import { preparePublicationSource } from "./source/preparation.js";
27
+
28
+ function normalizeWorkflowError(error: unknown): Error {
29
+ return error instanceof Error ? error : new Error(String(error));
30
+ }
31
+
32
+ function resolvePublicationSessionLookup(input: {
33
+ publicationSessionId?: string | null;
34
+ releaseId?: string;
35
+ }): PublicationGetSessionInput {
36
+ if (input.publicationSessionId) {
37
+ return {
38
+ publicationSessionId: input.publicationSessionId,
39
+ };
40
+ }
41
+
42
+ if (!input.releaseId) {
43
+ throw new Error(
44
+ "releaseId is required when publicationSessionId is absent"
45
+ );
46
+ }
47
+
48
+ return {
49
+ releaseId: input.releaseId,
50
+ };
51
+ }
52
+
53
+ async function loadPublicationSession(
54
+ client: PublicationWorkflowClient,
55
+ input: {
56
+ publicationSessionId?: string | null;
57
+ releaseId?: string;
58
+ existingSession?: PublicationSession;
59
+ startMessage: string;
60
+ completeMessage: string;
61
+ },
62
+ options: PublicationWorkflowOptions
63
+ ): Promise<PublicationSession> {
64
+ logWorkflowInfo(options.logger, input.startMessage, {
65
+ step: "session.load",
66
+ status: "running",
67
+ releaseId: input.releaseId,
68
+ publicationSessionId: input.publicationSessionId ?? undefined,
69
+ });
70
+
71
+ const publicationSession =
72
+ input.existingSession ??
73
+ normalizePublicationSession(
74
+ await client.getPublicationSession(
75
+ resolvePublicationSessionLookup({
76
+ publicationSessionId: input.publicationSessionId,
77
+ releaseId: input.releaseId,
78
+ })
79
+ )
80
+ );
81
+
82
+ logWorkflowInfo(options.logger, input.completeMessage, {
83
+ step: "session.load",
84
+ status: "complete",
85
+ releaseId: publicationSession.releaseId,
86
+ publicationSessionId: publicationSession.id,
87
+ stage: publicationSession.stage,
88
+ });
89
+
90
+ return publicationSession;
91
+ }
92
+
93
+ async function loadPublicationBundle(
94
+ client: PublicationWorkflowClient,
95
+ input: {
96
+ releaseId: string;
97
+ publicationSessionId?: string | null;
98
+ ingestionSessionId?: string | null;
99
+ existingBundle?: PublicationBundle | null;
100
+ existingSession?: PublicationSession;
101
+ },
102
+ options: PublicationWorkflowOptions
103
+ ): Promise<PublicationBundle> {
104
+ logWorkflowInfo(options.logger, "Loading publication bundle", {
105
+ step: "bundle.load",
106
+ status: "running",
107
+ releaseId: input.releaseId,
108
+ });
109
+
110
+ const publicationBundle =
111
+ input.existingBundle &&
112
+ hasResolvableReleaseMetadataUri(input.existingBundle, input.existingSession)
113
+ ? input.existingBundle
114
+ : withPublicationBundleIdentifiers(
115
+ normalizePublicationBundle(
116
+ await client.getPublicationBundle({
117
+ releaseId: input.releaseId,
118
+ })
119
+ ),
120
+ {
121
+ releaseId: input.releaseId,
122
+ publicationSessionId: input.publicationSessionId ?? null,
123
+ ingestionSessionId: input.ingestionSessionId ?? null,
124
+ }
125
+ );
126
+
127
+ validatePublicationBundle(publicationBundle);
128
+
129
+ logWorkflowInfo(options.logger, "Publication bundle loaded", {
130
+ step: "bundle.load",
131
+ status: "complete",
132
+ releaseId: input.releaseId,
133
+ androidPackage: publicationBundle.release.androidPackage,
134
+ versionName: publicationBundle.release.versionName,
135
+ });
136
+
137
+ return publicationBundle;
138
+ }
139
+
140
+ async function recoverPublicationResult(
141
+ client: PublicationWorkflowClient,
142
+ releaseId: string,
143
+ input: Pick<PublicationWorkflowInput, "signer" | "attestationClient">,
144
+ options: PublicationWorkflowOptions
145
+ ) {
146
+ logWorkflowInfo(options.logger, "Recovering final publication state", {
147
+ step: "cleanup.recover",
148
+ status: "running",
149
+ releaseId,
150
+ });
151
+
152
+ const publicationSession = normalizePublicationSession(
153
+ await client.getPublicationSession({
154
+ releaseId,
155
+ })
156
+ );
157
+ const publicationBundle = withPublicationBundleIdentifiers(
158
+ normalizePublicationBundle(
159
+ await client.getPublicationBundle({
160
+ releaseId,
161
+ })
162
+ ),
163
+ {
164
+ releaseId,
165
+ publicationSessionId: publicationSession.id,
166
+ ingestionSessionId: publicationSession.ingestionSessionId ?? null,
167
+ }
168
+ );
169
+
170
+ logWorkflowInfo(options.logger, "Recovered final publication state", {
171
+ step: "cleanup.recover",
172
+ status: "complete",
173
+ releaseId,
174
+ publicationSessionId: publicationSession.id,
175
+ stage: publicationSession.stage,
176
+ });
177
+
178
+ return runPublicationWorkflow(
179
+ client,
180
+ publicationBundle,
181
+ input.signer,
182
+ input.attestationClient,
183
+ publicationSession,
184
+ options.logger
185
+ );
186
+ }
187
+
188
+ async function cleanupFailedRelease(
189
+ client: PublicationWorkflowClient,
190
+ releaseId: string,
191
+ error: Error,
192
+ options: PublicationWorkflowOptions
193
+ ): Promise<PublicationCleanupReleaseResult | null> {
194
+ if (!client.cleanupRelease) {
195
+ return null;
196
+ }
197
+
198
+ options.logger?.warn?.("Rolling back failed publication release", {
199
+ step: "cleanup.release",
200
+ status: "running",
201
+ releaseId,
202
+ error: error.message,
203
+ });
204
+
205
+ const cleanupResult = await client.cleanupRelease({
206
+ releaseId,
207
+ });
208
+
209
+ const message =
210
+ cleanupResult.action === "deleted"
211
+ ? "Failed publication release cleaned up"
212
+ : "Publication already reached the submitted state; preserving release";
213
+
214
+ logWorkflowInfo(options.logger, message, {
215
+ step: "cleanup.release",
216
+ status: "complete",
217
+ releaseId,
218
+ action: cleanupResult.action,
219
+ });
220
+
221
+ return cleanupResult;
222
+ }
223
+
224
+ export const createPublicationWorkflow = (
225
+ client: PublicationWorkflowClient,
226
+ options: PublicationWorkflowOptions = {}
227
+ ) => {
228
+ const pollOptions: PublicationWorkflowPollOptions = {
229
+ pollIntervalMs: options.pollIntervalMs ?? 2500,
230
+ // Large APK ingestion can legitimately take tens of minutes once upload,
231
+ // managed-storage download, hashing, aapt2, and apksigner are included.
232
+ // Keep the default wait aligned with the portal queue headroom instead of
233
+ // failing after only ~5 minutes.
234
+ maxPollAttempts: options.maxPollAttempts ?? 1080,
235
+ };
236
+
237
+ return {
238
+ async startPublication(input: PublicationWorkflowInput) {
239
+ let createdReleaseId: string | undefined;
240
+ let createdIngestionSessionId: string | undefined;
241
+
242
+ try {
243
+ logWorkflowInfo(options.logger, "Preparing publication source", {
244
+ step: "source.prepare",
245
+ status: "running",
246
+ });
247
+
248
+ const source = await preparePublicationSource(
249
+ client,
250
+ input.source,
251
+ options.logger
252
+ );
253
+
254
+ logWorkflowInfo(options.logger, "Creating ingestion session", {
255
+ step: "ingestion.create",
256
+ status: "running",
257
+ });
258
+
259
+ const ingestionSession = await client.createIngestionSession({
260
+ source,
261
+ whatsNew: input.whatsNew,
262
+ idempotencyKey: input.idempotencyKey ?? randomUUID(),
263
+ ...(input.dappId ? { dappId: input.dappId } : {}),
264
+ });
265
+
266
+ createdReleaseId =
267
+ ingestionSession.releaseId ??
268
+ ingestionSession.bundle?.releaseId ??
269
+ undefined;
270
+ createdIngestionSessionId = ingestionSession.id?.trim();
271
+
272
+ if (!createdIngestionSessionId) {
273
+ throw new Error(
274
+ "Portal createIngestionSession did not return an ingestion session id"
275
+ );
276
+ }
277
+
278
+ logWorkflowInfo(options.logger, "Ingestion session created", {
279
+ step: "ingestion.create",
280
+ status: "complete",
281
+ ingestionSessionId: createdIngestionSessionId,
282
+ releaseId: createdReleaseId,
283
+ });
284
+
285
+ const readySession = await waitForIngestionSessionReady(
286
+ client,
287
+ createdIngestionSessionId,
288
+ pollOptions,
289
+ options.logger
290
+ );
291
+
292
+ const releaseId =
293
+ readySession.releaseId ?? readySession.bundle?.releaseId ?? undefined;
294
+ if (!releaseId) {
295
+ throw new Error(
296
+ "Publication ingestion completed without a release identifier"
297
+ );
298
+ }
299
+ createdReleaseId = releaseId;
300
+
301
+ const readyPublicationSession = readySession.publicationSession
302
+ ? normalizePublicationSession(readySession.publicationSession)
303
+ : undefined;
304
+
305
+ const readyPublicationBundle = readySession.bundle
306
+ ? withPublicationBundleIdentifiers(
307
+ normalizePublicationBundle(readySession.bundle),
308
+ {
309
+ releaseId,
310
+ publicationSessionId: readySession.publicationSessionId ?? null,
311
+ ingestionSessionId: readySession.id,
312
+ }
313
+ )
314
+ : null;
315
+
316
+ const publicationBundle = await loadPublicationBundle(
317
+ client,
318
+ {
319
+ releaseId,
320
+ publicationSessionId: readySession.publicationSessionId ?? null,
321
+ ingestionSessionId: readySession.id,
322
+ existingBundle: readyPublicationBundle,
323
+ existingSession: readyPublicationSession,
324
+ },
325
+ options
326
+ );
327
+
328
+ const publicationSession = await loadPublicationSession(
329
+ client,
330
+ {
331
+ releaseId,
332
+ publicationSessionId: readySession.publicationSessionId ?? null,
333
+ existingSession: readyPublicationSession,
334
+ startMessage: "Loading publication session",
335
+ completeMessage: "Publication session loaded",
336
+ },
337
+ options
338
+ );
339
+
340
+ return await runPublicationWorkflow(
341
+ client,
342
+ publicationBundle,
343
+ input.signer,
344
+ input.attestationClient,
345
+ publicationSession,
346
+ options.logger
347
+ );
348
+ } catch (error) {
349
+ const normalizedError = normalizeWorkflowError(error);
350
+
351
+ if (!createdReleaseId && createdIngestionSessionId) {
352
+ try {
353
+ const failedIngestionSession = await client.getIngestionSession({
354
+ sessionId: createdIngestionSessionId,
355
+ ingestionSessionId: createdIngestionSessionId,
356
+ });
357
+ createdReleaseId =
358
+ failedIngestionSession.releaseId ??
359
+ failedIngestionSession.bundle?.releaseId ??
360
+ undefined;
361
+ } catch {
362
+ // Ignore follow-up lookup failures and rethrow the original error.
363
+ }
364
+ }
365
+
366
+ if (!createdReleaseId) {
367
+ throw normalizedError;
368
+ }
369
+
370
+ try {
371
+ const cleanupResult = await cleanupFailedRelease(
372
+ client,
373
+ createdReleaseId,
374
+ normalizedError,
375
+ options
376
+ );
377
+
378
+ if (cleanupResult?.action === "preservedSubmitted") {
379
+ return await recoverPublicationResult(
380
+ client,
381
+ createdReleaseId,
382
+ input,
383
+ options
384
+ );
385
+ }
386
+ } catch (cleanupError) {
387
+ const normalizedCleanupError = normalizeWorkflowError(cleanupError);
388
+ throw new Error(
389
+ `${normalizedError.message} Cleanup also failed for release ${createdReleaseId}: ${normalizedCleanupError.message}`
390
+ );
391
+ }
392
+
393
+ throw normalizedError;
394
+ }
395
+ },
396
+
397
+ async resumePublication(input: PublicationResumeInput) {
398
+ if (!input.publicationSessionId && !input.releaseId) {
399
+ throw new Error(
400
+ "Publication session id or release id is required to resume a publication"
401
+ );
402
+ }
403
+
404
+ const publicationSession = await loadPublicationSession(
405
+ client,
406
+ {
407
+ publicationSessionId: input.publicationSessionId ?? null,
408
+ releaseId: input.releaseId,
409
+ startMessage: "Loading existing publication session",
410
+ completeMessage: "Existing publication session loaded",
411
+ },
412
+ options
413
+ );
414
+
415
+ const publicationBundle = await loadPublicationBundle(
416
+ client,
417
+ {
418
+ releaseId: publicationSession.releaseId,
419
+ publicationSessionId: publicationSession.id,
420
+ ingestionSessionId: publicationSession.ingestionSessionId ?? null,
421
+ },
422
+ options
423
+ );
424
+
425
+ return runPublicationWorkflow(
426
+ client,
427
+ publicationBundle,
428
+ input.signer,
429
+ input.attestationClient,
430
+ publicationSession,
431
+ options.logger
432
+ );
433
+ },
434
+ };
435
+ };
@@ -0,0 +1,17 @@
1
+ import type { PublicationWorkflowLogger } from "../types.js";
2
+
3
+ export function logWorkflowInfo(
4
+ logger: PublicationWorkflowLogger | undefined,
5
+ message: string,
6
+ metadata?: Record<string, unknown>
7
+ ) {
8
+ logger?.info?.(message, metadata);
9
+ }
10
+
11
+ export function logWorkflowDebug(
12
+ logger: PublicationWorkflowLogger | undefined,
13
+ message: string,
14
+ metadata?: Record<string, unknown>
15
+ ) {
16
+ logger?.debug?.(message, metadata);
17
+ }
@@ -0,0 +1,49 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { basename } from "node:path";
4
+
5
+ export function ensureApkFileName(fileName: string): string {
6
+ return /\.apk$/i.test(fileName) ? fileName : `${fileName}.apk`;
7
+ }
8
+
9
+ export function inferFileNameFromUrl(url: string): string {
10
+ try {
11
+ const pathname = new URL(url).pathname;
12
+ const fileName = basename(pathname);
13
+ return fileName.length > 0 ? fileName : "release.apk";
14
+ } catch {
15
+ return "release.apk";
16
+ }
17
+ }
18
+
19
+ export function normalizeLocalFileAccessError(
20
+ filePath: string,
21
+ error: unknown
22
+ ): Error {
23
+ const code =
24
+ error && typeof error === "object" && "code" in error
25
+ ? String((error as { code?: unknown }).code || "")
26
+ : "";
27
+
28
+ if (code === "EPERM" || code === "EACCES") {
29
+ return new Error(
30
+ `Cannot read local APK at ${filePath}. macOS denied access to this location (${code}). Move the APK out of Downloads into your workspace or another accessible folder, or grant this app Full Disk Access, then retry.`
31
+ );
32
+ }
33
+
34
+ return error instanceof Error ? error : new Error(String(error));
35
+ }
36
+
37
+ export async function hashFileSha256(filePath: string): Promise<string> {
38
+ const hash = createHash("sha256");
39
+
40
+ try {
41
+ for await (const chunk of createReadStream(filePath)) {
42
+ hash.update(chunk);
43
+ }
44
+ } catch (error) {
45
+ throw normalizeLocalFileAccessError(filePath, error);
46
+ }
47
+
48
+ return hash.digest("hex");
49
+ }
@@ -0,0 +1,189 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import { Transform } from "node:stream";
5
+
6
+ import type {
7
+ PublicationCreateIngestionSessionInput,
8
+ PublicationSource,
9
+ PublicationWorkflowLogger,
10
+ } from "../../types.js";
11
+ import type { PublicationWorkflowClient } from "../contracts.js";
12
+ import { logWorkflowDebug, logWorkflowInfo } from "../logging.js";
13
+ import {
14
+ ensureApkFileName,
15
+ hashFileSha256,
16
+ inferFileNameFromUrl,
17
+ normalizeLocalFileAccessError,
18
+ } from "./files.js";
19
+
20
+ const DEFAULT_APK_CONTENT_TYPE = "application/vnd.android.package-archive";
21
+
22
+ async function uploadLocalApkToPortal(
23
+ client: PublicationWorkflowClient,
24
+ source: Extract<PublicationSource, { kind: "apk-file" }>,
25
+ logger?: PublicationWorkflowLogger
26
+ ): Promise<PublicationCreateIngestionSessionInput["source"]> {
27
+ const createUploadTarget = client.createUploadTarget;
28
+ if (!createUploadTarget) {
29
+ throw new Error(
30
+ "Local apk-file sources require a createUploadTarget client method"
31
+ );
32
+ }
33
+
34
+ const fileStat = await stat(source.filePath).catch((error) => {
35
+ throw normalizeLocalFileAccessError(source.filePath, error);
36
+ });
37
+ const contentType = source.mimeType ?? DEFAULT_APK_CONTENT_TYPE;
38
+ const fileHash = await hashFileSha256(source.filePath);
39
+ const fileName = ensureApkFileName(
40
+ source.fileName || basename(source.filePath)
41
+ );
42
+
43
+ logWorkflowInfo(logger, "Uploading APK to portal storage", {
44
+ step: "source.upload",
45
+ status: "running",
46
+ fileName,
47
+ filePath: source.filePath,
48
+ fileSize: fileStat.size,
49
+ });
50
+
51
+ const uploadTarget = await createUploadTarget({
52
+ fileHash,
53
+ fileExtension: "apk",
54
+ contentType,
55
+ });
56
+
57
+ const totalBytes = fileStat.size;
58
+ let uploadedBytes = 0;
59
+ let lastLoggedBytes = 0;
60
+ let lastLoggedAt = 0;
61
+
62
+ const emitUploadProgress = (force = false) => {
63
+ const now = Date.now();
64
+ const progress = totalBytes > 0 ? uploadedBytes / totalBytes : 1;
65
+ const byteDelta = uploadedBytes - lastLoggedBytes;
66
+ const minByteDelta = Math.max(256 * 1024, Math.floor(totalBytes * 0.01));
67
+ const shouldLog =
68
+ force ||
69
+ uploadedBytes >= totalBytes ||
70
+ now - lastLoggedAt >= 250 ||
71
+ byteDelta >= minByteDelta;
72
+
73
+ if (!shouldLog) {
74
+ return;
75
+ }
76
+
77
+ lastLoggedAt = now;
78
+ lastLoggedBytes = uploadedBytes;
79
+
80
+ logWorkflowDebug(logger, "Uploading APK to portal storage", {
81
+ step: "source.upload",
82
+ status: "running",
83
+ fileName,
84
+ filePath: source.filePath,
85
+ fileSize: totalBytes,
86
+ bytesUploaded: uploadedBytes,
87
+ bytesTotal: totalBytes,
88
+ stepProgress: progress,
89
+ });
90
+ };
91
+
92
+ const uploadBody = createReadStream(source.filePath).pipe(
93
+ new Transform({
94
+ transform(chunk, _encoding, callback) {
95
+ uploadedBytes += chunk.length;
96
+ emitUploadProgress();
97
+ callback(null, chunk);
98
+ },
99
+ })
100
+ );
101
+
102
+ const uploadResponse = await fetch(uploadTarget.uploadUrl, {
103
+ method: "PUT",
104
+ headers: {
105
+ "content-type": contentType,
106
+ "content-length": String(totalBytes),
107
+ },
108
+ body: uploadBody,
109
+ duplex: "half",
110
+ } as RequestInit & { duplex: "half" });
111
+
112
+ if (!uploadResponse.ok) {
113
+ throw new Error(
114
+ `Failed to upload APK to portal storage: ${uploadResponse.status} ${uploadResponse.statusText}`
115
+ );
116
+ }
117
+
118
+ uploadedBytes = totalBytes;
119
+ emitUploadProgress(true);
120
+
121
+ logWorkflowInfo(logger, "APK uploaded to portal storage", {
122
+ step: "source.upload",
123
+ status: "complete",
124
+ fileName,
125
+ publicUrl: uploadTarget.publicUrl,
126
+ });
127
+
128
+ return {
129
+ kind: "portalUpload",
130
+ releaseFileUrl: uploadTarget.publicUrl,
131
+ releaseFileName: fileName,
132
+ releaseFileSize: fileStat.size,
133
+ releaseFileHash: fileHash,
134
+ contentType,
135
+ };
136
+ }
137
+
138
+ export async function preparePublicationSource(
139
+ client: PublicationWorkflowClient,
140
+ source: PublicationSource,
141
+ logger?: PublicationWorkflowLogger
142
+ ): Promise<PublicationCreateIngestionSessionInput["source"]> {
143
+ switch (source.kind) {
144
+ case "portalUpload":
145
+ logWorkflowInfo(logger, "Using portal-hosted APK source", {
146
+ step: "source.ready",
147
+ status: "complete",
148
+ fileName: source.releaseFileName,
149
+ });
150
+ return source;
151
+ case "externalUrl":
152
+ logWorkflowInfo(logger, "Using external APK URL", {
153
+ step: "source.ready",
154
+ status: "complete",
155
+ fileName: source.releaseFileName ?? inferFileNameFromUrl(source.apkUrl),
156
+ apkUrl: source.apkUrl,
157
+ });
158
+ return source;
159
+ case "existingRelease":
160
+ logWorkflowInfo(logger, "Using an existing release as the source", {
161
+ step: "source.ready",
162
+ status: "complete",
163
+ sourceReleaseId: source.sourceReleaseId,
164
+ });
165
+ return source;
166
+ case "apk-url":
167
+ logWorkflowInfo(logger, "Preparing external APK URL", {
168
+ step: "source.ready",
169
+ status: "running",
170
+ fileName:
171
+ source.fileName ??
172
+ inferFileNameFromUrl(source.canonicalUrl ?? source.url),
173
+ apkUrl: source.canonicalUrl ?? source.url,
174
+ });
175
+ return {
176
+ kind: "externalUrl",
177
+ apkUrl: source.canonicalUrl ?? source.url,
178
+ releaseFileName:
179
+ source.fileName ??
180
+ inferFileNameFromUrl(source.canonicalUrl ?? source.url),
181
+ };
182
+ case "apk-file":
183
+ return uploadLocalApkToPortal(client, source, logger);
184
+ default: {
185
+ const exhaustiveCheck: never = source;
186
+ return exhaustiveCheck;
187
+ }
188
+ }
189
+ }