deepline 0.3.118 → 0.3.119

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.
@@ -0,0 +1,1355 @@
1
+ import { join } from 'node:path';
2
+ import { rm } from 'node:fs/promises';
3
+ import type { PlayCompilerManifest } from '../compiler-manifest';
4
+ import {
5
+ DeployApiClient,
6
+ DeployApiError,
7
+ type DeploymentPlayState,
8
+ type RegisterArtifactEntry,
9
+ } from './api-client';
10
+ import {
11
+ discoverPlayFiles,
12
+ readDeeplineManifest,
13
+ DeeplineManifestError,
14
+ DEFAULT_MANIFEST_FILE,
15
+ } from './manifest';
16
+ import type { SerializedPlayDeployment } from './project-serializer';
17
+ import { resolveOwnedPlayReferenceName } from '../play-reference';
18
+ import type { BundledPlayFileResult } from '../bundling/index';
19
+ import type { PlayBundleArtifact } from '../artifact-types';
20
+ import { isAstNode, parsePlaySourceForAnalysis, type AstNode } from '../ts-ast';
21
+
22
+ const RUN_PLAY_METHOD_NAMES = new Set(['runPlay', 'runPlayAsync']);
23
+
24
+ function isRunPlayReceiver(node: unknown): boolean {
25
+ return (
26
+ isAstNode(node) &&
27
+ node.type === 'Identifier' &&
28
+ typeof node.name === 'string' &&
29
+ (node.name === 'ctx' || node.name.endsWith('Ctx'))
30
+ );
31
+ }
32
+
33
+ function stringLiteralValue(node: unknown): string | null {
34
+ if (!isAstNode(node)) return null;
35
+ if (node.type === 'Literal' && typeof node.value === 'string') {
36
+ return node.value;
37
+ }
38
+ // A template with no substitutions is a string literal too.
39
+ if (
40
+ node.type === 'TemplateLiteral' &&
41
+ Array.isArray(node.expressions) &&
42
+ node.expressions.length === 0 &&
43
+ Array.isArray(node.quasis) &&
44
+ node.quasis.length === 1
45
+ ) {
46
+ const cooked = (node.quasis[0] as { value?: { cooked?: unknown } }).value
47
+ ?.cooked;
48
+ return typeof cooked === 'string' ? cooked : null;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Cheap, standalone scan for ctx.runPlay(...)/ctx.runPlayAsync(...) calls
55
+ * whose second argument is an inline string literal - the only playRef form
56
+ * the cloud-tool typecheck accepts for ctx.runPlay (an imported handle or a
57
+ * const-indirected identifier both fail validateRunPlayCall's string-literal
58
+ * requirement), so it is also the only form worth graphing here. Lets a play
59
+ * that calls another brand-new play in this same push, by name, compile
60
+ * after its target - the same way an imported play already does - instead of
61
+ * only ever finding it (or not) in already-published Convex state.
62
+ */
63
+ export async function extractRunPlayStringTargets(
64
+ sourceCode: string,
65
+ ): Promise<string[]> {
66
+ const program = parsePlaySourceForAnalysis(sourceCode);
67
+ // acorn cannot parse some valid TypeScript (`<T>value` casts). Where the
68
+ // compiler is installed (the deploy worker) it reads those; elsewhere the
69
+ // play gets no ordering edges, and compile-manifest owns any syntax error.
70
+ if (!program) return (await extractWithCompiler(sourceCode)) ?? [];
71
+ const targets = new Set<string>();
72
+ const visit = (node: AstNode) => {
73
+ const callee = node.callee;
74
+ if (
75
+ node.type === 'CallExpression' &&
76
+ isAstNode(callee) &&
77
+ callee.type === 'MemberExpression' &&
78
+ callee.computed !== true &&
79
+ isAstNode(callee.property) &&
80
+ RUN_PLAY_METHOD_NAMES.has(String(callee.property.name)) &&
81
+ isRunPlayReceiver(callee.object) &&
82
+ Array.isArray(node.arguments)
83
+ ) {
84
+ const playRef = stringLiteralValue(node.arguments[1]);
85
+ const ownedName =
86
+ playRef === null ? null : resolveOwnedPlayReferenceName(playRef);
87
+ if (ownedName) targets.add(ownedName);
88
+ }
89
+ for (const [key, value] of Object.entries(node)) {
90
+ if (key === 'loc') continue;
91
+ for (const child of Array.isArray(value) ? value : [value]) {
92
+ if (isAstNode(child)) visit(child);
93
+ }
94
+ }
95
+ };
96
+ visit(program);
97
+ return [...targets];
98
+ }
99
+
100
+ async function extractWithCompiler(
101
+ sourceCode: string,
102
+ ): Promise<string[] | null> {
103
+ let ts: typeof import('typescript');
104
+ try {
105
+ const loaded = await import('typescript');
106
+ // CommonJS through ESM: the compiler API is the default export.
107
+ ts = loaded.default ?? loaded;
108
+ } catch {
109
+ return null;
110
+ }
111
+ const sourceFile = ts.createSourceFile(
112
+ 'play.ts',
113
+ sourceCode,
114
+ ts.ScriptTarget.Latest,
115
+ true,
116
+ ts.ScriptKind.TS,
117
+ );
118
+ const targets = new Set<string>();
119
+ const visit = (node: import('typescript').Node) => {
120
+ if (
121
+ ts.isCallExpression(node) &&
122
+ ts.isPropertyAccessExpression(node.expression) &&
123
+ RUN_PLAY_METHOD_NAMES.has(node.expression.name.text) &&
124
+ ts.isIdentifier(node.expression.expression) &&
125
+ (node.expression.expression.text === 'ctx' ||
126
+ node.expression.expression.text.endsWith('Ctx'))
127
+ ) {
128
+ const playRefArgument = node.arguments[1];
129
+ if (playRefArgument && ts.isStringLiteralLike(playRefArgument)) {
130
+ const ownedName = resolveOwnedPlayReferenceName(playRefArgument.text);
131
+ if (ownedName) targets.add(ownedName);
132
+ }
133
+ }
134
+ ts.forEachChild(node, visit);
135
+ };
136
+ visit(sourceFile);
137
+ return [...targets];
138
+ }
139
+
140
+ export type PlayDeployOutcome = {
141
+ name: string;
142
+ file: string;
143
+ status: 'promoted' | 'registered' | 'unchanged' | 'failed';
144
+ version?: number;
145
+ /**
146
+ * The version that was live BEFORE this deployment promoted over it, so a
147
+ * receipt can show the transition rather than only the destination. Absent
148
+ * for a play's first deploy, and for outcomes that never moved the live
149
+ * pointer.
150
+ */
151
+ previousVersion?: number;
152
+ /**
153
+ * Something the reader must see about a play that still deployed. Kept
154
+ * orthogonal to `status` on purpose: "what happened to this play" and "is
155
+ * there something wrong with it" are different questions, and a play can be
156
+ * promoted normally and still be worth flagging. One warning anywhere turns
157
+ * the deployment into `succeeded_with_warnings`.
158
+ */
159
+ warning?: string;
160
+ error?: string;
161
+ };
162
+
163
+ export type PlayDeploymentRunResult = {
164
+ finalStatus:
165
+ | 'succeeded'
166
+ | 'succeeded_with_warnings'
167
+ | 'failed'
168
+ | 'superseded'
169
+ | 'cancelled';
170
+ outcomes: PlayDeployOutcome[];
171
+ checkRunId?: number;
172
+ };
173
+
174
+ export type CheckRunAnnotation = {
175
+ path: string;
176
+ start_line: number;
177
+ end_line: number;
178
+ annotation_level: 'notice' | 'warning' | 'failure';
179
+ message: string;
180
+ raw_details?: string;
181
+ };
182
+
183
+ export type DeploymentCheckVerdict = {
184
+ conclusion: 'success' | 'failure' | 'neutral';
185
+ title: string;
186
+ summary: string;
187
+ annotations?: CheckRunAnnotation[];
188
+ };
189
+
190
+ /**
191
+ * Where a deployment's plays come from, and what the git host (if there is
192
+ * one) can tell or be told about them.
193
+ *
194
+ * A managed build of a linked repository fetches the commit, asks the host
195
+ * whether its branch head moved, and writes a Check Run. A workspace
196
+ * deploying a directory of plays supplies only `open`, which hands back the
197
+ * directory it was given: no git, no host, no checks.
198
+ */
199
+ export type DeploymentSource = {
200
+ /** Make the plays readable; returns the root to build from. */
201
+ open(workDir: string): Promise<{ repoRoot: string }>;
202
+ /** The branch's current head, when the host can be asked. */
203
+ branchHead?: (branch: string) => Promise<string | null>;
204
+ /** Post (or replace, by deployment id) this commit's check. */
205
+ postCheck?: (
206
+ verdict: DeploymentCheckVerdict & { name?: string; externalId: string },
207
+ ) => Promise<{ checkRunId: number }>;
208
+ /** Production only: the pull request whose merge produced the commit. */
209
+ findMergedPullRequest?: () => Promise<number | null>;
210
+ };
211
+
212
+ export type DeploymentRunParams = {
213
+ deploymentId: string;
214
+ deployToken: string;
215
+ appBaseUrl: string;
216
+ };
217
+
218
+ export type DeploymentRunHooks = {
219
+ bundle: (
220
+ entryFile: string,
221
+ options: { sourceIdentityRoot: string },
222
+ ) => Promise<BundledPlayFileResult>;
223
+ /**
224
+ * Opened inside the build's failure boundary, so a host outage still ends
225
+ * in a terminal status rather than a stranded deployment.
226
+ */
227
+ openSource: (input: {
228
+ client: DeployApiClient;
229
+ deployment: SerializedPlayDeployment;
230
+ }) => Promise<DeploymentSource>;
231
+ /**
232
+ * What to build, for a deployment with no linked repository to read it
233
+ * from. A linked deployment takes these from its project.
234
+ */
235
+ target?: { rootDirectory: string; manifestPath: string };
236
+ fetchImpl?: typeof fetch;
237
+ workRoot: string;
238
+ /** Test hook: delay between Check Run write retries (default 1000ms). */
239
+ checkRunRetryDelayMs?: number;
240
+ };
241
+
242
+ /**
243
+ * Thrown when this attempt failed for a reason a fresh attempt can fix (e.g.
244
+ * a GitHub Checks outage after the build itself succeeded). The outer catch
245
+ * rethrows it WITHOUT writing a terminal status, so the deployment stays
246
+ * `building` and the Absurd retry (or ultimately the stale sweeper) takes
247
+ * over, instead of the row being failed while its token still burns.
248
+ */
249
+ export class RetryableDeploymentError extends Error {}
250
+
251
+ const TERMINAL_TOKEN_CODES = new Set([
252
+ 'PLAY_DEPLOYMENT_SUPERSEDED',
253
+ 'PLAY_DEPLOYMENT_TERMINAL',
254
+ 'PLAY_PROJECT_DISABLED',
255
+ 'GITHUB_INSTALLATION_INACTIVE',
256
+ ]);
257
+
258
+ /**
259
+ * The control plane revoked this deployment's token: it was cancelled,
260
+ * superseded, or its project/installation went away. The attempt must stop
261
+ * without writing anything — no status (the row is already terminal) and no
262
+ * Check Run (the build did not fail; someone stopped it).
263
+ */
264
+ function revokedFinalStatus(error: unknown): 'cancelled' | 'superseded' | null {
265
+ if (
266
+ !(error instanceof DeployApiError) ||
267
+ !error.code ||
268
+ !TERMINAL_TOKEN_CODES.has(error.code)
269
+ ) {
270
+ return null;
271
+ }
272
+ return error.code === 'PLAY_DEPLOYMENT_SUPERSEDED'
273
+ ? 'superseded'
274
+ : 'cancelled';
275
+ }
276
+
277
+ /**
278
+ * A revision already carries this bundle when all four identity hashes agree.
279
+ * One definition, used for both the live and the working revision.
280
+ */
281
+ function carriesArtifact(
282
+ revision: DeploymentPlayState['liveRevision'],
283
+ artifact: Pick<
284
+ PlayBundleArtifact,
285
+ 'sourceHash' | 'artifactHash' | 'graphHash' | 'artifactKind'
286
+ >,
287
+ ): boolean {
288
+ return Boolean(
289
+ revision &&
290
+ revision.sourceHash === artifact.sourceHash &&
291
+ revision.artifactHash === artifact.artifactHash &&
292
+ revision.graphHash === artifact.graphHash &&
293
+ revision.artifactKind === artifact.artifactKind,
294
+ );
295
+ }
296
+
297
+ function annotationForFailure(input: {
298
+ file: string;
299
+ message: string;
300
+ }): CheckRunAnnotation {
301
+ return {
302
+ path: input.file,
303
+ start_line: 1,
304
+ end_line: 1,
305
+ annotation_level: 'failure',
306
+ message: input.message.slice(0, 4000),
307
+ };
308
+ }
309
+
310
+ /**
311
+ * The deploy build, host-agnostic: everything flows through the deploy API
312
+ * client (per-deployment token) and the injected bundler, so the Absurd
313
+ * task handler and tests drive the same body. Retries are safe end to end:
314
+ * the deployment fetch aborts superseded work, registration retries are
315
+ * absorbed by the hash-based skip, and promote is idempotent per revision.
316
+ */
317
+ export async function runDeployment(
318
+ params: DeploymentRunParams,
319
+ hooks: DeploymentRunHooks,
320
+ ): Promise<PlayDeploymentRunResult> {
321
+ const startedAt = Date.now();
322
+ const client = new DeployApiClient({
323
+ appBaseUrl: params.appBaseUrl,
324
+ deployToken: params.deployToken,
325
+ deploymentId: params.deploymentId,
326
+ fetchImpl: hooks.fetchImpl,
327
+ });
328
+
329
+ let state;
330
+ try {
331
+ state = await client.getDeployment();
332
+ } catch (error) {
333
+ const revoked = revokedFinalStatus(error);
334
+ if (revoked) {
335
+ console.info('[deploy-worker] aborting: deployment no longer buildable', {
336
+ deploymentId: params.deploymentId,
337
+ code: (error as DeployApiError).code,
338
+ });
339
+ return { finalStatus: revoked, outcomes: [] };
340
+ }
341
+ if (error instanceof DeployApiError && error.status === 401) {
342
+ // The deploy token is dead (expired past the queue-aware TTL or
343
+ // invalid) and can never become valid again — retrying burns attempts
344
+ // for nothing. Abort cleanly; the stale-deployment sweeper cron
345
+ // terminalizes the stranded row.
346
+ console.error('[deploy-worker] aborting: deploy token rejected', {
347
+ deploymentId: params.deploymentId,
348
+ error: error.message.slice(0, 200),
349
+ });
350
+ return { finalStatus: 'cancelled', outcomes: [] };
351
+ }
352
+ throw error;
353
+ }
354
+ const { deployment, project } = state;
355
+ // A deployment of a directory has no project; its caller states the target.
356
+ const target = project ?? hooks.target;
357
+ if (!target) {
358
+ throw new Error(
359
+ `Deployment ${params.deploymentId} has no linked repository and no target directory was given.`,
360
+ );
361
+ }
362
+ // Server-computed; qualified when several targets build one repo (A2).
363
+ const checkRunName = state.checkRunName?.trim() || undefined;
364
+
365
+ // Production push only: the PR whose merge produced this commit, so the
366
+ // dashboard links the PR and not only the commit. Resolved once the
367
+ // installation token exists and stamped on EVERY status write after that
368
+ // through this one wrapper — a failed or superseded merge deploy is
369
+ // exactly where the reader wants the PR, and a per-site spread would be
370
+ // one more thing each terminal path could forget. Decoration, never a
371
+ // gate: a lookup failure logs and the deployment proceeds without it.
372
+ let mergedPullRequestNumber: number | null = null;
373
+ // Set once this deployment's live-pointer flip committed. From then on its
374
+ // plays are live, so no cancel can make its Check Run untrue.
375
+ let promoted = false;
376
+ const setStatus: DeployApiClient['setStatus'] = (input) =>
377
+ client.setStatus({
378
+ ...input,
379
+ ...(mergedPullRequestNumber ? { mergedPullRequestNumber } : {}),
380
+ });
381
+
382
+ try {
383
+ await setStatus({ status: 'building' });
384
+ } catch (error) {
385
+ // A retried attempt may already be past 'building'; that is fine.
386
+ if (
387
+ !(
388
+ error instanceof DeployApiError &&
389
+ error.code === 'PLAY_DEPLOYMENT_INVALID_TRANSITION'
390
+ )
391
+ ) {
392
+ throw error;
393
+ }
394
+ }
395
+
396
+ const buildSeconds = () => Math.max(0, (Date.now() - startedAt) / 1000);
397
+ const finishFailed = async (input: {
398
+ error: string;
399
+ outcomes: PlayDeployOutcome[];
400
+ checkRunId?: number;
401
+ }): Promise<PlayDeploymentRunResult> => {
402
+ try {
403
+ await setStatus({
404
+ status: 'failed',
405
+ error: input.error.slice(0, 2000),
406
+ results: { plays: input.outcomes },
407
+ buildSeconds: buildSeconds(),
408
+ ...(input.checkRunId ? { checkRunId: input.checkRunId } : {}),
409
+ });
410
+ } catch (statusError) {
411
+ // The terminal status MUST land: swallowing it here would ACK the
412
+ // Absurd task and strand the deployment non-terminal. Rethrow so the
413
+ // task retries until the terminal write persists.
414
+ console.error(
415
+ '[deploy-worker] failed-status write failed; retrying task',
416
+ {
417
+ deploymentId: params.deploymentId,
418
+ error:
419
+ statusError instanceof Error
420
+ ? statusError.message
421
+ : String(statusError),
422
+ },
423
+ );
424
+ throw new Error(
425
+ `Terminal status write failed for ${params.deploymentId} (original failure: ${input.error.slice(0, 300)})`,
426
+ );
427
+ }
428
+ return {
429
+ finalStatus: 'failed',
430
+ outcomes: input.outcomes,
431
+ checkRunId: input.checkRunId,
432
+ };
433
+ };
434
+ const finishSucceeded = async (
435
+ outcomes: PlayDeployOutcome[],
436
+ checkRun: { checkRunId: number } | null,
437
+ ): Promise<PlayDeploymentRunResult> => {
438
+ // Carried by the terminal status rather than per-play results so every
439
+ // reader — API, CLI, dashboard, alerting — sees it without parsing them.
440
+ const finalStatus = outcomes.some((outcome) => outcome.warning)
441
+ ? ('succeeded_with_warnings' as const)
442
+ : ('succeeded' as const);
443
+ try {
444
+ await setStatus({
445
+ status: finalStatus,
446
+ results: { plays: outcomes },
447
+ buildSeconds: buildSeconds(),
448
+ ...(checkRun ? { checkRunId: checkRun.checkRunId } : {}),
449
+ });
450
+ } catch (statusError) {
451
+ // The work this status reports (promotion included) already happened,
452
+ // so a terminal `failed` here would be a lie about a live deployment —
453
+ // and terminal status kills the token, so no retry could ever correct
454
+ // the record. Stay non-terminal and retry the task instead: the rerun
455
+ // is idempotent (register dedupes, promote re-flips the same
456
+ // revisions) and ends at this same write.
457
+ const message =
458
+ statusError instanceof Error
459
+ ? statusError.message
460
+ : String(statusError);
461
+ console.warn(
462
+ '[deploy-worker] succeeded-status write failed; retrying task',
463
+ { deploymentId: params.deploymentId, error: message.slice(0, 300) },
464
+ );
465
+ throw new RetryableDeploymentError(
466
+ `SUCCEEDED_STATUS_WRITE_FAILED: ${message}`,
467
+ );
468
+ }
469
+ return {
470
+ finalStatus,
471
+ outcomes,
472
+ checkRunId: checkRun?.checkRunId,
473
+ };
474
+ };
475
+
476
+ const workDir = join(hooks.workRoot, `deploy-${params.deploymentId}`);
477
+ // Check writes are customer-visible; give transient host failures bounded
478
+ // retries before any caller decides to swallow or fail.
479
+ const postCheck = async (
480
+ postCheckOnce: NonNullable<DeploymentSource['postCheck']>,
481
+ input: Parameters<NonNullable<DeploymentSource['postCheck']>>[0],
482
+ ) => {
483
+ let lastError: unknown;
484
+ for (let attempt = 0; ; attempt += 1) {
485
+ try {
486
+ return await postCheckOnce(input);
487
+ } catch (error) {
488
+ lastError = error;
489
+ if (attempt >= 2) {
490
+ throw lastError;
491
+ }
492
+ await new Promise((resolve) =>
493
+ setTimeout(resolve, hooks.checkRunRetryDelayMs ?? 1000),
494
+ );
495
+ }
496
+ }
497
+ };
498
+ // Assigned once the GitHub token exists. The terminal catch below is
499
+ // reached by every non-retryable crash — a rejected registration, a bad
500
+ // promote — and without this those land as a `failed` row with nothing on
501
+ // the commit, so the push looks to GitHub like it never happened.
502
+ let postFailureCheck:
503
+ | ((summary: string) => Promise<number | undefined>)
504
+ | null = null;
505
+ try {
506
+ // Inside the try so a GitHub outage / revoked installation still writes
507
+ // a terminal `failed` status instead of stranding the deployment in
508
+ // `building` (loud failures over silent fallbacks).
509
+ const source = await hooks.openSource({ client, deployment });
510
+ // Every production trigger, not only 'push': a 'redeliver' recovery of a
511
+ // superseded head, or a manual re-deploy, is still deploying the commit
512
+ // some PR produced — and the recovery is the row that actually reaches
513
+ // production. PR builds are never production and already know their PR.
514
+ if (deployment.isProduction) {
515
+ try {
516
+ mergedPullRequestNumber =
517
+ (await source.findMergedPullRequest?.()) ?? null;
518
+ } catch (error) {
519
+ console.warn('[deploy-worker] merged PR lookup failed', {
520
+ deploymentId: params.deploymentId,
521
+ error: (error instanceof Error ? error.message : String(error)).slice(
522
+ 0,
523
+ 300,
524
+ ),
525
+ });
526
+ }
527
+ }
528
+
529
+ // Every Check Run in this deployment targets the same repo, commit, and
530
+ // (server-computed) name; call sites supply only the verdict.
531
+ //
532
+ // For PR builds a Check Run — success OR failure — is THE deliverable: if
533
+ // it cannot be written even with retries, keep the attempt retryable (no
534
+ // terminal write) instead of ACKing a PR that never got its check. For
535
+ // production the status row is authoritative, so a lost check is logged
536
+ // and swallowed.
537
+ type CheckVerdict = DeploymentCheckVerdict;
538
+ const writeCheck = async (input: CheckVerdict) => {
539
+ // No host check surface (a directory deploy): the status row is the
540
+ // only verdict, which is exactly what its caller reads.
541
+ if (!source.postCheck) return null;
542
+ try {
543
+ return await postCheck(source.postCheck, {
544
+ name: checkRunName,
545
+ // Idempotency key: a retry after a lost create response updates
546
+ // the existing run instead of posting a duplicate check.
547
+ externalId: params.deploymentId,
548
+ ...input,
549
+ });
550
+ } catch (error) {
551
+ const message = error instanceof Error ? error.message : String(error);
552
+ if (!deployment.isProduction) {
553
+ throw new RetryableDeploymentError(
554
+ `GITHUB_CHECK_WRITE_FAILED: could not post the PR Check Run after retries: ${message}`,
555
+ );
556
+ }
557
+ console.warn(
558
+ '[deploy-worker] check write lost; status row is authoritative',
559
+ {
560
+ deploymentId: params.deploymentId,
561
+ error: message.slice(0, 300),
562
+ },
563
+ );
564
+ return null;
565
+ }
566
+ };
567
+ // Throws the control plane's revocation of this deployment, if any. Any
568
+ // other read failure is not evidence of a cancel, so it is ignored here.
569
+ const assertNotRevoked = async () => {
570
+ try {
571
+ await client.getDeployment();
572
+ } catch (error) {
573
+ if (revokedFinalStatus(error)) throw error;
574
+ }
575
+ };
576
+ // Check Runs use the GitHub App's token, not the deploy token, so a
577
+ // cancel does not stop them. Before promotion a cancel makes any verdict
578
+ // untrue: check for one before posting, and again after — a cancel that
579
+ // landed in between turns the posted check neutral (same external id, so
580
+ // it replaces the verdict) and aborts the attempt.
581
+ const postCheckOrNull = async (input: CheckVerdict) => {
582
+ if (promoted) return writeCheck(input);
583
+ await assertNotRevoked();
584
+ const checkRun = await writeCheck(input);
585
+ await assertNotRevoked().catch(async (revocation: unknown) => {
586
+ const stopped = revokedFinalStatus(revocation);
587
+ await writeCheck({
588
+ conclusion: 'neutral',
589
+ title: `Deployment ${stopped}`,
590
+ summary:
591
+ 'This deployment was stopped before it finished. Nothing from it went live.',
592
+ }).catch(() => undefined);
593
+ throw revocation;
594
+ });
595
+ return checkRun;
596
+ };
597
+ // The common failure shape: a failure Check Run, then the terminal
598
+ // `failed` status carrying the same diagnostics.
599
+ const failWithCheck = async (input: {
600
+ title: string;
601
+ summary: string;
602
+ error: string;
603
+ outcomes: PlayDeployOutcome[];
604
+ annotations?: CheckRunAnnotation[];
605
+ }): Promise<PlayDeploymentRunResult> => {
606
+ const checkRun = await postCheckOrNull({
607
+ conclusion: 'failure',
608
+ title: input.title,
609
+ summary: input.summary,
610
+ ...(input.annotations ? { annotations: input.annotations } : {}),
611
+ });
612
+ return finishFailed({
613
+ error: input.error,
614
+ outcomes: input.outcomes,
615
+ checkRunId: checkRun?.checkRunId,
616
+ });
617
+ };
618
+
619
+ postFailureCheck = async (summary) =>
620
+ (
621
+ await postCheckOrNull({
622
+ conclusion: 'failure',
623
+ title: 'Deploy failed',
624
+ summary,
625
+ })
626
+ )?.checkRunId;
627
+
628
+ // Out-of-order guard: if GitHub delivered an OLDER push after the new
629
+ // head, enqueue-time supersede may already have retired the true head's
630
+ // queued build — so every terminal path in this function must only be
631
+ // reachable while this commit is still the branch head. When it is not,
632
+ // supersede with observedHeadSha so the control plane re-enqueues the
633
+ // uncovered head. Runs here (before any early return: empty tree, bad
634
+ // manifest, org guard, bundle failures) and again right before promote.
635
+ // The guard's question is "is MY commit still the head of MY ref" — so
636
+ // the branch comes from the deployment's own ref, never the project's
637
+ // live productionBranch setting. An admin retargeting the project
638
+ // mid-build must not make an old deployment observe the NEW branch and
639
+ // re-enqueue (or promote) a foreign SHA under its old ref. (Settings
640
+ // changes also cancel in-flight deployments control-plane-side; this
641
+ // keeps the worker correct even inside that race window.)
642
+ const deploymentBranch = deployment.ref?.startsWith('refs/heads/')
643
+ ? deployment.ref.slice('refs/heads/'.length)
644
+ : (project?.productionBranch ?? null);
645
+ const supersedeIfHeadMoved = async () => {
646
+ // Only a host that can be asked about a branch can supersede on it.
647
+ if (!deployment.isProduction || !source.branchHead || !deploymentBranch) {
648
+ return null;
649
+ }
650
+ const head = await source.branchHead(deploymentBranch);
651
+ if (head === deployment.commitSha) return null;
652
+ console.info('[deploy-worker] superseding: branch head moved', {
653
+ deploymentId: params.deploymentId,
654
+ commitSha: deployment.commitSha,
655
+ head: head ?? '<branch deleted>',
656
+ });
657
+ // Terminal write must persist; a failure retries the task.
658
+ await setStatus({
659
+ status: 'superseded',
660
+ error: `Branch ${deploymentBranch} is now at ${head?.slice(0, 12) ?? '<deleted>'}; this deployment's commit is no longer the head.`,
661
+ buildSeconds: buildSeconds(),
662
+ ...(head ? { observedHeadSha: head } : {}),
663
+ });
664
+ return { finalStatus: 'superseded' as const, outcomes: [] };
665
+ };
666
+ {
667
+ const superseded = await supersedeIfHeadMoved();
668
+ if (superseded) return superseded;
669
+ }
670
+ const { repoRoot } = await source.open(workDir);
671
+ const projectRoot = target.rootDirectory
672
+ ? join(repoRoot, target.rootDirectory)
673
+ : repoRoot;
674
+
675
+ const manifestFile = target.manifestPath || DEFAULT_MANIFEST_FILE;
676
+ let manifest;
677
+ let playFiles: string[];
678
+ try {
679
+ manifest = await readDeeplineManifest(projectRoot, manifestFile);
680
+ playFiles = await discoverPlayFiles({
681
+ projectRoot,
682
+ globs: manifest.plays,
683
+ });
684
+ } catch (error) {
685
+ if (error instanceof DeeplineManifestError) {
686
+ return await failWithCheck({
687
+ title: `${manifestFile} is invalid`,
688
+ summary: error.message,
689
+ error: error.message,
690
+ outcomes: [],
691
+ });
692
+ }
693
+ throw error;
694
+ }
695
+
696
+ // The manifest's optional `org` field is the repo's tenant guard: a repo
697
+ // that declares which workspace it expects must never deploy anywhere
698
+ // else. This is what keeps a shared-repo misbinding from publishing one
699
+ // push into the wrong tenant.
700
+ if (manifest.org) {
701
+ const expected = manifest.org.trim().toLowerCase();
702
+ const actual = (state.workspaceSlug ?? '').trim().toLowerCase();
703
+ if (expected !== actual) {
704
+ const message = `DEEPLINE_MANIFEST_ORG_MISMATCH: ${manifestFile} declares org "${manifest.org}", but this deployment targets workspace "${state.workspaceSlug ?? '<unknown>'}". Fix the manifest or unbind the repo from this workspace.`;
705
+ return await failWithCheck({
706
+ title: 'Workspace guard rejected this deployment',
707
+ summary: message,
708
+ error: message,
709
+ outcomes: [],
710
+ });
711
+ }
712
+ }
713
+
714
+ if (playFiles.length === 0) {
715
+ const checkRun = await postCheckOrNull({
716
+ conclusion: 'neutral',
717
+ title: 'No plays matched',
718
+ summary: `No files matched ${manifest.plays.join(', ')} under ${target.rootDirectory || 'the deployed directory'}.`,
719
+ });
720
+ return await finishSucceeded([], checkRun);
721
+ }
722
+
723
+ // One deployment handles at most 100 plays: promote enforces it
724
+ // (PLAY_DEPLOY_BATCH_TOO_LARGE in promote.ts) and plays-state caps its
725
+ // name list at 100. Refuse at discovery, before any bundling — the
726
+ // rejection is guaranteed, so burning metered build compute (or, later,
727
+ // control-plane writes that churn revision history on retries) to reach
728
+ // it would be pure waste.
729
+ if (playFiles.length > 100) {
730
+ const message = `PLAY_DEPLOY_BATCH_TOO_LARGE: this deployment contains ${playFiles.length} plays (max 100). Split the repo into multiple projects.`;
731
+ return await failWithCheck({
732
+ title: 'Too many plays in one deployment',
733
+ summary: message,
734
+ error: message,
735
+ outcomes: [],
736
+ });
737
+ }
738
+
739
+ // Bundle every play. Any failure fails the whole deploy (all-or-nothing
740
+ // through every phase).
741
+ type BundledPlay = {
742
+ /**
743
+ * Repo-relative, decided once here. Every later reader — outcomes,
744
+ * annotations, the stored source path — means "where this play lives in
745
+ * the repository", so there is no second form to convert to and no site
746
+ * that can forget.
747
+ */
748
+ file: string;
749
+ name: string;
750
+ result: Extract<BundledPlayFileResult, { success: true }>;
751
+ /** ctx.runPlay(...)/ctx.runPlayAsync(...) string-literal targets, by
752
+ * owned name. Only names that are ALSO in this push (seenNames) become
753
+ * dependency-graph edges; anything else falls through to Convex
754
+ * exactly as before. */
755
+ runPlayStringTargets: string[];
756
+ };
757
+ const bundled: BundledPlay[] = [];
758
+ const failures: PlayDeployOutcome[] = [];
759
+ const annotations: CheckRunAnnotation[] = [];
760
+ const seenNames = new Map<string, string>();
761
+ const repoRelative = (file: string) =>
762
+ target.rootDirectory ? `${target.rootDirectory}/${file}` : file;
763
+ const failPlay = (name: string, file: string, message: string) => {
764
+ failures.push({ name, file, status: 'failed', error: message });
765
+ annotations.push(annotationForFailure({ file, message }));
766
+ };
767
+
768
+ for (const file of playFiles) {
769
+ const entryFile = join(projectRoot, file);
770
+ const result = await hooks.bundle(entryFile, {
771
+ sourceIdentityRoot: projectRoot,
772
+ });
773
+ const repoRelativeFile = repoRelative(file);
774
+ if (!result.success) {
775
+ // Bundler errors embed the absolute entry path under the worker's
776
+ // temp workDir. Those strings become the customer-visible failure
777
+ // card and Check Run annotations — rewrite them repo-relative so
778
+ // the message names the file the customer actually has, and the
779
+ // worker's filesystem layout never leaks.
780
+ const message =
781
+ result.errors
782
+ .join('\n')
783
+ .split(`${projectRoot}/`)
784
+ .join(target.rootDirectory ? `${target.rootDirectory}/` : '')
785
+ .split(`${repoRoot}/`)
786
+ .join('') || 'Bundle failed.';
787
+ failPlay(file, repoRelativeFile, message);
788
+ continue;
789
+ }
790
+ const name = result.playName?.trim() ?? '';
791
+ if (!name) {
792
+ failPlay(
793
+ file,
794
+ repoRelativeFile,
795
+ `${file} does not export a definePlay(...) play with a name.`,
796
+ );
797
+ continue;
798
+ }
799
+ const duplicateOf = seenNames.get(name);
800
+ if (duplicateOf) {
801
+ // A name that resolves to two files has no right answer. Deploying
802
+ // either one silently picks code the author may not have meant, so
803
+ // fail and name both files: the reader cannot fix a collision they
804
+ // can only see one half of.
805
+ failPlay(
806
+ name,
807
+ repoRelativeFile,
808
+ `The play name "${name}" is declared by more than one file: ${repoRelative(duplicateOf)} and ${repoRelativeFile}. A play name must be unique across the repository. Rename one of them or delete it.`,
809
+ );
810
+ continue;
811
+ }
812
+ seenNames.set(name, file);
813
+
814
+ // v1 dependency policy: the deepline SDK and node builtins only. This
815
+ // is a build-environment policy, not a security boundary — the same
816
+ // artifact could be produced by any client bundler.
817
+ const disallowed = (result.artifact.importPolicy?.packages ?? [])
818
+ .map((pkg) =>
819
+ typeof pkg === 'string'
820
+ ? pkg
821
+ : ((pkg as { name?: string }).name ?? ''),
822
+ )
823
+ .filter((pkgName) => pkgName && pkgName !== 'deepline');
824
+ if (disallowed.length > 0) {
825
+ failPlay(
826
+ name,
827
+ repoRelativeFile,
828
+ `PLAY_DEPLOY_DEPENDENCY_NOT_ALLOWED: ${file} imports npm package(s) ${disallowed.join(', ')}. Deploy-on-push builds support only the deepline SDK and node builtins.`,
829
+ );
830
+ continue;
831
+ }
832
+ bundled.push({
833
+ file: repoRelativeFile,
834
+ name,
835
+ result,
836
+ runPlayStringTargets: await extractRunPlayStringTargets(
837
+ result.sourceCode,
838
+ ),
839
+ });
840
+ }
841
+
842
+ if (failures.length > 0) {
843
+ return await failWithCheck({
844
+ title: `${failures.length} play${failures.length === 1 ? '' : 's'} failed to build`,
845
+ summary: failures
846
+ .map((failure) => `- \`${failure.file}\`: ${failure.error}`)
847
+ .join('\n'),
848
+ annotations,
849
+ error: `Build failed for ${failures.length} play file(s).`,
850
+ outcomes: failures,
851
+ });
852
+ }
853
+
854
+ // Compile manifests in dependency order so same-deploy play imports AND
855
+ // same-deploy ctx.runPlay(...) string refs both resolve from this
856
+ // deployment, not stale (or absent) Convex state.
857
+ const manifestByName = new Map<string, PlayCompilerManifest>();
858
+ const ordered = topoSortByPlayDependencies(bundled);
859
+ const validationFailures: PlayDeployOutcome[] = [];
860
+ for (const play of ordered) {
861
+ const dependencyNames = new Set([
862
+ ...(play.result.importedPlayDependencies ?? []).map(
863
+ (dependency) => dependency.playName,
864
+ ),
865
+ ...play.runPlayStringTargets,
866
+ ]);
867
+ const dependencies = [...dependencyNames]
868
+ .map((dependencyName) => manifestByName.get(dependencyName))
869
+ .filter((entry): entry is PlayCompilerManifest => Boolean(entry));
870
+ try {
871
+ const compiled = await client.compileManifest({
872
+ name: play.name,
873
+ sourceCode: play.result.sourceCode,
874
+ sourceFiles: play.result.sourceFiles,
875
+ artifact: play.result.artifact,
876
+ ...(dependencies.length
877
+ ? { importedPlayDependencies: dependencies }
878
+ : {}),
879
+ });
880
+ manifestByName.set(play.name, compiled.compilerManifest);
881
+ } catch (error) {
882
+ // A revoked token is not a validation failure of this play.
883
+ if (revokedFinalStatus(error)) throw error;
884
+ const body =
885
+ error instanceof DeployApiError
886
+ ? (error.body as { errors?: unknown } | null)
887
+ : null;
888
+ const messages =
889
+ body && Array.isArray(body.errors)
890
+ ? body.errors.map(String)
891
+ : [error instanceof Error ? error.message : String(error)];
892
+ const message = messages.join('\n');
893
+ validationFailures.push({
894
+ name: play.name,
895
+ file: play.file,
896
+ status: 'failed',
897
+ error: message,
898
+ });
899
+ annotations.push(annotationForFailure({ file: play.file, message }));
900
+ }
901
+ }
902
+
903
+ if (validationFailures.length > 0) {
904
+ return await failWithCheck({
905
+ title: `${validationFailures.length} play${validationFailures.length === 1 ? '' : 's'} failed validation`,
906
+ summary: validationFailures
907
+ .map((failure) => `- \`${failure.name}\`: ${failure.error}`)
908
+ .join('\n'),
909
+ annotations,
910
+ error: `Validation failed for ${validationFailures.length} play(s).`,
911
+ outcomes: validationFailures,
912
+ });
913
+ }
914
+
915
+ // A4: every deployment registers changed plays; only production
916
+ // promotes. A PR's revisions are runnable pre-merge via
917
+ // `plays run --revision-id` and never touch the live pointer.
918
+ //
919
+ // Production re-checks that this commit is STILL the branch head after
920
+ // bundling: two pushes 200ms apart can arrive reversed, and promoting
921
+ // the older tree would silently revert production. (The same guard
922
+ // already ran before the build; bundling takes long enough that it must
923
+ // run again here, and a final check runs right before promote because
924
+ // registration is slower still.)
925
+ {
926
+ const superseded = await supersedeIfHeadMoved();
927
+ if (superseded) return superseded;
928
+ }
929
+
930
+ await setStatus({ status: 'publishing' }).catch(() => undefined);
931
+ const states = await client.playsState(ordered.map((play) => play.name));
932
+ const stateByName = new Map<string, DeploymentPlayState>(
933
+ states.plays.map((play) => [play.name, play]),
934
+ );
935
+
936
+ // A name owned by another ACTIVE target cannot be taken: the control
937
+ // plane refuses that write. Decide it here, from state already fetched,
938
+ // so the customer gets a named per-play failure — the same shape as a
939
+ // duplicate name — instead of a mid-registration server error. A DISABLED
940
+ // owner has released the name; that takeover is allowed and only warns.
941
+ //
942
+ // A directory deploy owns no name: it is a manual publish of each play,
943
+ // which the control plane allows for a repo-owned play and marks drifted.
944
+ // It warns (below) instead of refusing.
945
+ const conflicts: PlayDeployOutcome[] = [];
946
+ for (const play of ordered) {
947
+ const owner = stateByName.get(play.name);
948
+ if (
949
+ !project ||
950
+ !owner?.playProjectRepo ||
951
+ owner.playProjectRepo === project?.repoFullName ||
952
+ owner.playProjectDisabled
953
+ ) {
954
+ continue;
955
+ }
956
+ const message = `The play name "${play.name}" is already deployed from ${owner.playProjectRepo}. A play name belongs to one repository at a time. Rename it here, or disable that deployment target first.`;
957
+ conflicts.push({
958
+ name: play.name,
959
+ file: play.file,
960
+ status: 'failed',
961
+ error: message,
962
+ });
963
+ annotations.push(annotationForFailure({ file: play.file, message }));
964
+ }
965
+ if (conflicts.length > 0) {
966
+ return await failWithCheck({
967
+ title: `${conflicts.length} play name${conflicts.length === 1 ? '' : 's'} owned by another repository`,
968
+ summary: conflicts
969
+ .map((conflict) => `- \`${conflict.file}\`: ${conflict.error}`)
970
+ .join('\n'),
971
+ annotations,
972
+ error: `${conflicts.length} play name(s) are deployed from another repository.`,
973
+ outcomes: conflicts,
974
+ });
975
+ }
976
+
977
+ const playByName = new Map(ordered.map((play) => [play.name, play]));
978
+ const outcomes: PlayDeployOutcome[] = [];
979
+ const toRegister: Array<{
980
+ play: (typeof ordered)[number];
981
+ entry: RegisterArtifactEntry;
982
+ }> = [];
983
+ const toPromote: Array<{ name: string; revisionId: string }> = [];
984
+
985
+ // A play has one source of truth, and the control plane already refuses a
986
+ // registration that would take a play from an ACTIVE project
987
+ // (PLAY_PROJECT_NAME_CONFLICT). What reaches here is the takeover the
988
+ // system deliberately allows: the previous owner's target is disabled,
989
+ // which is the supported "moved repos" flow. That move is legitimate and
990
+ // silent, and a play changing which repository it answers to is exactly
991
+ // the kind of durable change a reader must be told about.
992
+ //
993
+ // Keyed on the repo, not the project row, so a monorepo re-binding its own
994
+ // play at a new base path is not flagged.
995
+ const takeoverWarning = (
996
+ state?: DeploymentPlayState,
997
+ ): string | undefined => {
998
+ const owner = state?.playProjectRepo;
999
+ if (!owner || owner === project?.repoFullName) return undefined;
1000
+ if (!project) {
1001
+ // A directory deploy of a repo-owned play: live now differs from
1002
+ // that repository, and a later push from it will replace this.
1003
+ return `This play is deployed from ${owner}. This directory deploy made a different revision live, so it is marked drifted from ${owner} until that repository deploys again.`;
1004
+ }
1005
+ return `This play was deployed from ${owner} and now deploys from ${project.repoFullName}. Taking it over was allowed because the old deployment target is disabled; re-enabling that target will not give the play back — its deploys will fail on this name instead.`;
1006
+ };
1007
+
1008
+ for (const play of ordered) {
1009
+ const playState = stateByName.get(play.name);
1010
+ // Directory deploys warn only where they changed what is live.
1011
+ const warning = project ? takeoverWarning(playState) : undefined;
1012
+ const artifact = play.result.artifact;
1013
+ // Imported plays are PROXIED by the bundler: the parent's hashes cover
1014
+ // the child's (filePath, playName) but not its content, so a child
1015
+ // change leaves the parent's hashes identical while its server-compiled
1016
+ // contract moves. Importer plays therefore never hash-skip.
1017
+ const importsOtherPlays =
1018
+ (play.result.importedPlayDependencies ?? []).length > 0;
1019
+ const matches = (revision: DeploymentPlayState['liveRevision']) =>
1020
+ !importsOtherPlays && carriesArtifact(revision, artifact);
1021
+ if (matches(playState?.liveRevision ?? null)) {
1022
+ // A live-identical play still needs binding + provenance on its
1023
+ // FIRST deploy from this project — otherwise it never carries
1024
+ // playProjectId and later manual publishes escape the drift flag.
1025
+ // Re-promoting the identical revision is idempotent and stamps both.
1026
+ // Same when driftFromGit is set with matching hashes (a manual
1027
+ // republish of the identical bundle): only the git-synced flip
1028
+ // clears the flag, so skipping as unchanged would leave the play
1029
+ // falsely marked drifted forever.
1030
+ //
1031
+ // A directory deploy binds nothing and never clears drift, so an
1032
+ // identical bundle is simply unchanged: re-promoting it would churn
1033
+ // revision history for no change in what is live.
1034
+ if (
1035
+ deployment.isProduction &&
1036
+ project &&
1037
+ (playState?.playProjectId !== project.id || playState?.driftFromGit)
1038
+ ) {
1039
+ toPromote.push({
1040
+ name: play.name,
1041
+ revisionId: playState!.liveRevision!.revisionId,
1042
+ });
1043
+ continue;
1044
+ }
1045
+ outcomes.push({
1046
+ name: play.name,
1047
+ file: play.file,
1048
+ status: 'unchanged',
1049
+ version: playState?.liveRevision?.version ?? undefined,
1050
+ ...(warning ? { warning } : {}),
1051
+ });
1052
+ continue;
1053
+ }
1054
+ if (matches(playState?.workingRevision ?? null)) {
1055
+ // Registered before (an earlier attempt, another push of this PR, or
1056
+ // a sibling PR); reuse the revision instead of writing a duplicate.
1057
+ if (deployment.isProduction) {
1058
+ toPromote.push({
1059
+ name: play.name,
1060
+ revisionId: playState!.workingRevision!.revisionId,
1061
+ });
1062
+ } else {
1063
+ outcomes.push({
1064
+ name: play.name,
1065
+ file: play.file,
1066
+ status: 'registered',
1067
+ version: playState?.workingRevision?.version ?? undefined,
1068
+ ...(warning ? { warning } : {}),
1069
+ });
1070
+ }
1071
+ continue;
1072
+ }
1073
+ toRegister.push({
1074
+ play,
1075
+ entry: {
1076
+ name: play.name,
1077
+ sourceCode: play.result.sourceCode,
1078
+ sourceFiles: play.result.sourceFiles,
1079
+ ...(play.result.playDescription
1080
+ ? { description: play.result.playDescription }
1081
+ : {}),
1082
+ artifact: play.result.artifact,
1083
+ compilerManifest: manifestByName.get(play.name)!,
1084
+ sourceFilePath: play.file,
1085
+ },
1086
+ });
1087
+ }
1088
+
1089
+ if (toRegister.length > 0) {
1090
+ const registered = await client.registerArtifacts(
1091
+ toRegister.map((item) => item.entry),
1092
+ );
1093
+ registered.forEach((artifact, index) => {
1094
+ const item = toRegister[index];
1095
+ if (!artifact.revisionId) {
1096
+ throw new Error(
1097
+ `Registration returned no revision id for ${item.play.name}.`,
1098
+ );
1099
+ }
1100
+ if (deployment.isProduction) {
1101
+ toPromote.push({
1102
+ name: item.play.name,
1103
+ revisionId: String(artifact.revisionId),
1104
+ });
1105
+ } else {
1106
+ const warning = takeoverWarning(stateByName.get(item.play.name));
1107
+ outcomes.push({
1108
+ name: item.play.name,
1109
+ file: item.play.file,
1110
+ status: 'registered',
1111
+ version:
1112
+ typeof artifact.version === 'number'
1113
+ ? artifact.version
1114
+ : undefined,
1115
+ ...(warning ? { warning } : {}),
1116
+ });
1117
+ }
1118
+ });
1119
+ }
1120
+
1121
+ // PR deployments stop here: registered but never promoted (A4).
1122
+ if (!deployment.isProduction) {
1123
+ const registeredCount = outcomes.filter(
1124
+ (outcome) => outcome.status === 'registered',
1125
+ ).length;
1126
+ // postCheckOrNull throws RetryableDeploymentError for PR builds when
1127
+ // the check cannot be written even with retries: the deployment stays
1128
+ // `building` for the task retry instead of recording `succeeded` with
1129
+ // no validation signal (the deploy token dies with a terminal status).
1130
+ const checkRun = await postCheckOrNull({
1131
+ conclusion: 'success',
1132
+ title: `${ordered.length} play${ordered.length === 1 ? '' : 's'} validated and registered (not deployed)`,
1133
+ ...(annotations.length > 0 ? { annotations } : {}),
1134
+ summary: outcomes
1135
+ .map(
1136
+ (outcome) =>
1137
+ `- \`${outcome.name}\`: ${outcome.status}${outcome.version !== undefined ? ` (v${outcome.version})` : ''}${outcome.warning ? `\n - :warning: ${outcome.warning}` : ''}`,
1138
+ )
1139
+ .join('\n'),
1140
+ });
1141
+ const result = await finishSucceeded(outcomes, checkRun);
1142
+ console.info('[deploy-worker] PR registration complete', {
1143
+ deploymentId: params.deploymentId,
1144
+ registered: registeredCount,
1145
+ unchanged: outcomes.filter((o) => o.status === 'unchanged').length,
1146
+ });
1147
+ return result;
1148
+ }
1149
+
1150
+ // Third head check, immediately before the flip: registration is the
1151
+ // slowest phase (up to 100 plays, batched), so a push landing during it
1152
+ // would otherwise let this older commit promote over the new head. The
1153
+ // registered-but-unpromoted revisions are harmless — the newer
1154
+ // deployment registers its own.
1155
+ {
1156
+ const superseded = await supersedeIfHeadMoved();
1157
+ if (superseded) return superseded;
1158
+ }
1159
+
1160
+ if (toPromote.length > 0) {
1161
+ try {
1162
+ const promotion = await client.promote(toPromote);
1163
+ promoted = true;
1164
+ for (const outcome of promotion.outcomes) {
1165
+ // Read from the pre-promote snapshot: whatever was live when this
1166
+ // build started is what the new version replaced.
1167
+ const previousVersion =
1168
+ stateByName.get(outcome.name)?.liveRevision?.version ?? null;
1169
+ const warning = takeoverWarning(stateByName.get(outcome.name));
1170
+ outcomes.push({
1171
+ name: outcome.name,
1172
+ file: playByName.get(outcome.name)?.file ?? outcome.name,
1173
+ status: outcome.status === 'promoted' ? 'promoted' : 'failed',
1174
+ version: outcome.version,
1175
+ ...(outcome.status === 'promoted' &&
1176
+ typeof previousVersion === 'number' &&
1177
+ previousVersion !== outcome.version
1178
+ ? { previousVersion }
1179
+ : {}),
1180
+ ...(outcome.status === 'promoted' && warning ? { warning } : {}),
1181
+ ...(outcome.error ? { error: outcome.error } : {}),
1182
+ });
1183
+ }
1184
+ } catch (error) {
1185
+ if (revokedFinalStatus(error)) throw error;
1186
+ const body =
1187
+ error instanceof DeployApiError
1188
+ ? (error.body as {
1189
+ error?: string;
1190
+ outcomes?: Array<{
1191
+ name: string;
1192
+ status: string;
1193
+ error?: string;
1194
+ }>;
1195
+ } | null)
1196
+ : null;
1197
+ const errorText =
1198
+ body?.error ??
1199
+ (error instanceof Error ? error.message : String(error));
1200
+ if (/PLAY_DEPLOYMENT_NOT_ACTIVE/.test(errorText)) {
1201
+ // Unbind/uninstall cancelled this deployment mid-publish; the row
1202
+ // is already terminal, so abort without further writes.
1203
+ console.info('[deploy-worker] cancelled at promote', {
1204
+ deploymentId: params.deploymentId,
1205
+ });
1206
+ return { finalStatus: 'cancelled', outcomes: [] };
1207
+ }
1208
+ if (/PLAY_DEPLOYMENT_STALE/.test(errorText)) {
1209
+ // The atomic flip guard fired: a newer production deployment
1210
+ // exists. Retire this one instead of failing it.
1211
+ console.info('[deploy-worker] superseded at promote', {
1212
+ deploymentId: params.deploymentId,
1213
+ });
1214
+ // Terminal write must persist; a failure retries the task.
1215
+ await setStatus({
1216
+ status: 'superseded',
1217
+ error: errorText.slice(0, 2000),
1218
+ buildSeconds: buildSeconds(),
1219
+ });
1220
+ return { finalStatus: 'superseded', outcomes: [] };
1221
+ }
1222
+ return await failWithCheck({
1223
+ title: 'Deploy failed during promotion',
1224
+ summary: errorText,
1225
+ error: errorText,
1226
+ outcomes: [
1227
+ ...outcomes,
1228
+ ...(body?.outcomes ?? toPromote).map((entry) => ({
1229
+ name: entry.name,
1230
+ file: playByName.get(entry.name)?.file ?? entry.name,
1231
+ status: 'failed' as const,
1232
+ error: ('error' in entry ? entry.error : undefined) ?? errorText,
1233
+ })),
1234
+ ],
1235
+ });
1236
+ }
1237
+ }
1238
+
1239
+ const checkRun = await postCheckOrNull({
1240
+ conclusion: 'success',
1241
+ title: `Deployed ${outcomes.filter((o) => o.status === 'promoted').length} play(s) (${outcomes.filter((o) => o.status === 'unchanged').length} unchanged)`,
1242
+ ...(annotations.length > 0 ? { annotations } : {}),
1243
+ summary: outcomes
1244
+ .map(
1245
+ (outcome) =>
1246
+ `- \`${outcome.name}\`: ${outcome.status}${outcome.warning ? `\n - :warning: ${outcome.warning}` : ''}`,
1247
+ )
1248
+ .join('\n'),
1249
+ });
1250
+ return await finishSucceeded(outcomes, checkRun);
1251
+ } catch (error) {
1252
+ const revoked = revokedFinalStatus(error);
1253
+ if (revoked) {
1254
+ console.info('[deploy-worker] aborting: deployment revoked mid-build', {
1255
+ deploymentId: params.deploymentId,
1256
+ code: (error as DeployApiError).code,
1257
+ });
1258
+ return { finalStatus: revoked, outcomes: [] };
1259
+ }
1260
+ if (error instanceof RetryableDeploymentError) {
1261
+ // Deliberately no terminal write: the deployment stays `building` for
1262
+ // the task retry (or the stale sweeper as the final backstop).
1263
+ console.warn('[deploy-worker] retryable failure, leaving non-terminal', {
1264
+ deploymentId: params.deploymentId,
1265
+ error: error.message.slice(0, 300),
1266
+ });
1267
+ throw error;
1268
+ }
1269
+ const message = error instanceof Error ? error.message : String(error);
1270
+ console.error('[deploy-worker] deployment crashed', {
1271
+ deploymentId: params.deploymentId,
1272
+ error: message.slice(0, 500),
1273
+ });
1274
+ // Never let a check-write failure mask the real error — except when it
1275
+ // is itself the PR retry signal: postCheckOrNull throws
1276
+ // RetryableDeploymentError when a PR's Check Run cannot be written even
1277
+ // after retries, precisely so the deployment stays non-terminal rather
1278
+ // than finalizing with no check. Swallowing it here to reach setStatus
1279
+ // below would finalize that PR deployment anyway, killing the deploy
1280
+ // token before any retry could publish the check it is missing.
1281
+ let checkRunId: number | undefined;
1282
+ try {
1283
+ checkRunId = await postFailureCheck?.(message.slice(0, 2000));
1284
+ } catch (checkError) {
1285
+ const revokedAtCheck = revokedFinalStatus(checkError);
1286
+ if (revokedAtCheck) {
1287
+ return { finalStatus: revokedAtCheck, outcomes: [] };
1288
+ }
1289
+ if (checkError instanceof RetryableDeploymentError) {
1290
+ console.warn(
1291
+ '[deploy-worker] retryable failure, leaving non-terminal',
1292
+ {
1293
+ deploymentId: params.deploymentId,
1294
+ error: checkError.message.slice(0, 300),
1295
+ },
1296
+ );
1297
+ throw checkError;
1298
+ }
1299
+ }
1300
+ await setStatus({
1301
+ status: 'failed',
1302
+ error: message.slice(0, 2000),
1303
+ buildSeconds: buildSeconds(),
1304
+ ...(checkRunId ? { checkRunId } : {}),
1305
+ }).catch(() => undefined);
1306
+ throw error;
1307
+ } finally {
1308
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
1309
+ }
1310
+ }
1311
+
1312
+ type SortablePlay = {
1313
+ name: string;
1314
+ result: { importedPlayDependencies?: Array<{ playName: string }> };
1315
+ runPlayStringTargets: string[];
1316
+ };
1317
+
1318
+ /**
1319
+ * Dependencies compile first so same-deploy file imports AND same-deploy
1320
+ * ctx.runPlay(...) string refs both resolve. A dependency name outside this
1321
+ * push (not in `byName`) is simply not an edge here - it still resolves
1322
+ * against Convex when this play compiles, unchanged from today.
1323
+ */
1324
+ function topoSortByPlayDependencies<T extends SortablePlay>(plays: T[]): T[] {
1325
+ const byName = new Map(plays.map((play) => [play.name, play]));
1326
+ const visited = new Set<string>();
1327
+ const ordered: T[] = [];
1328
+ const visit = (play: T, stack: Set<string>) => {
1329
+ if (visited.has(play.name)) return;
1330
+ if (stack.has(play.name)) {
1331
+ // A genuine cycle (import or ctx.runPlay name) fails later in
1332
+ // preflight with a clearer, per-play message; do not infinite-loop
1333
+ // here. ctx.runPlay is synchronous inline composition - a real cycle
1334
+ // can never resolve, so detection belongs to the compile step that
1335
+ // already reports an unresolvable reference, not to this sort.
1336
+ return;
1337
+ }
1338
+ stack.add(play.name);
1339
+ const dependencyNames = new Set([
1340
+ ...(play.result.importedPlayDependencies ?? []).map(
1341
+ (dependency) => dependency.playName,
1342
+ ),
1343
+ ...play.runPlayStringTargets,
1344
+ ]);
1345
+ for (const dependencyName of dependencyNames) {
1346
+ const target = byName.get(dependencyName);
1347
+ if (target) visit(target, stack);
1348
+ }
1349
+ stack.delete(play.name);
1350
+ visited.add(play.name);
1351
+ ordered.push(play);
1352
+ };
1353
+ for (const play of plays) visit(play, new Set());
1354
+ return ordered;
1355
+ }