@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.
- package/lib/CoreUtils.js +0 -3
- package/lib/index.js +1 -0
- package/lib/portal/attestation.js +189 -0
- package/lib/portal/compat.js +3 -0
- package/lib/portal/index.js +5 -0
- package/lib/portal/signer.js +432 -0
- package/lib/portal/types.js +1 -0
- package/lib/portal/workflow/contracts.js +1 -0
- package/lib/portal/workflow/execution.js +493 -0
- package/lib/portal/workflow/ingestion.js +265 -0
- package/lib/portal/workflow/lifecycle.js +616 -0
- package/lib/portal/workflow/logging.js +8 -0
- package/lib/portal/workflow/source/files.js +304 -0
- package/lib/portal/workflow/source/preparation.js +318 -0
- package/lib/portal/workflow/state/bundle.js +260 -0
- package/lib/portal/workflow/state/checkpoints.js +53 -0
- package/lib/portal/workflow/state/session.js +100 -0
- package/lib/portal/workflow.js +1 -0
- package/lib/publish/PublishCoreAttestation.js +18 -17
- package/lib/publish/PublishCoreRemove.js +7 -89
- package/lib/publish/PublishCoreSubmit.js +7 -117
- package/lib/publish/PublishCoreSupport.js +7 -86
- package/lib/publish/PublishCoreUpdate.js +7 -117
- package/lib/publish/index.js +1 -0
- package/lib/schemas/releaseJsonMetadata.json +1 -2
- package/package.json +2 -4
- package/src/CoreUtils.ts +0 -6
- package/src/index.ts +1 -0
- package/src/portal/attestation.ts +76 -0
- package/src/portal/compat.ts +5 -0
- package/src/portal/index.ts +5 -0
- package/src/portal/signer.ts +327 -0
- package/src/portal/types.ts +447 -0
- package/src/portal/workflow/contracts.ts +108 -0
- package/src/portal/workflow/execution.ts +412 -0
- package/src/portal/workflow/ingestion.ts +187 -0
- package/src/portal/workflow/lifecycle.ts +435 -0
- package/src/portal/workflow/logging.ts +17 -0
- package/src/portal/workflow/source/files.ts +49 -0
- package/src/portal/workflow/source/preparation.ts +189 -0
- package/src/portal/workflow/state/bundle.ts +193 -0
- package/src/portal/workflow/state/checkpoints.ts +70 -0
- package/src/portal/workflow/state/session.ts +87 -0
- package/src/portal/workflow.ts +9 -0
- package/src/publish/PublishCoreAttestation.ts +21 -26
- package/src/publish/PublishCoreRemove.ts +13 -109
- package/src/publish/PublishCoreSubmit.ts +18 -150
- package/src/publish/PublishCoreSupport.ts +13 -102
- package/src/publish/PublishCoreUpdate.ts +17 -155
- package/src/publish/index.ts +2 -1
- package/src/schemas/releaseJsonMetadata.json +1 -2
- package/lib/publish/dapp_publisher_portal.js +0 -206
- 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
|
+
}
|