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.
- package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +22 -0
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +5 -3
- package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +12 -6
- package/dist/bundling-sources/shared_libs/plays/deploy/run-deployment.ts +1355 -0
- package/dist/cli/index.js +3365 -373
- package/dist/cli/index.mjs +3309 -295
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/install-integrity.json +1 -0
- package/dist/plays/bundle-play-file.d.mts +11 -1
- package/dist/plays/bundle-play-file.d.ts +11 -1
- package/dist/plays/bundle-play-file.mjs +12 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|