@akanjs/devkit 3.0.2-rc.0 → 3.1.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -7
- package/akanApp/akanApp.host.test.ts +62 -0
- package/akanApp/akanApp.host.ts +16 -4
- package/akanApp/devHostPolicy.test.ts +14 -0
- package/akanApp/devHostPolicy.ts +11 -0
- package/akanConfig/akanConfig.test.ts +77 -7
- package/akanConfig/akanConfig.ts +111 -13
- package/akanConfig/akanWorkspaceConfig.test.ts +79 -0
- package/akanConfig/akanWorkspaceConfig.ts +63 -0
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +23 -1
- package/akanContext.ts +1 -1
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +73 -84
- package/artifact/implicitRootLayout.test.ts +6 -2
- package/artifact/implicitRootLayout.ts +12 -7
- package/biome.base.json +7 -2
- package/cloud/cloudApi.ts +82 -0
- package/cloud/globalConfig.ts +4 -0
- package/cloud/index.ts +1 -0
- package/cloud/updatePackUpload.test.ts +195 -0
- package/cloud/updatePackUpload.ts +115 -0
- package/commandDecorators/command.ts +8 -2
- package/commandDecorators/targetMeta.ts +1 -0
- package/createTunnel.test.ts +51 -0
- package/createTunnel.ts +32 -14
- package/executors.test.ts +21 -0
- package/executors.ts +9 -2
- package/frontendBuild/csrArtifactBuilder.ts +10 -1
- package/frontendBuild/cssCandidateCache.test.ts +10 -0
- package/frontendBuild/cssCandidateCache.ts +4 -2
- package/frontendBuild/pagesBundleBuilder.ts +5 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +6 -2
- package/incrementalBuilder/buildBatch.proc.ts +14 -2
- package/incrementalBuilder/buildBatchProtocol.ts +3 -2
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +96 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +30 -1
- package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +6 -0
- package/lint/no-import-external-library-in-test.grit +24 -0
- package/mobile/desktopServerStage.test.ts +6 -0
- package/mobile/desktopServerStage.ts +25 -3
- package/mobile/index.ts +1 -0
- package/mobile/nativeApp.test.ts +1 -0
- package/mobile/nativeApp.ts +96 -34
- package/mobile/nativeConfig.test.ts +59 -1
- package/mobile/nativeConfig.ts +13 -3
- package/mobile/serverBundleSecrets.test.ts +61 -0
- package/mobile/serverBundleSecrets.ts +85 -0
- package/mobile/updateUploader.test.ts +49 -0
- package/mobile/updateUploader.ts +71 -0
- package/package.json +3 -2
- package/pipeline/PipelineCompiler.test.ts +631 -0
- package/pipeline/PipelineCompiler.ts +646 -0
- package/pipeline/PipelineDefinition.test.ts +89 -0
- package/pipeline/PipelineDefinition.ts +117 -0
- package/pipeline/PipelineDryRun.test.ts +93 -0
- package/pipeline/PipelineDryRun.ts +89 -0
- package/pipeline/PipelineInputReader.ts +739 -0
- package/pipeline/index.ts +4 -0
- package/qualityScanner.test.ts +9 -3
- package/qualityScanner.ts +5 -2
- package/scanInfo.ts +13 -6
- package/subspace.test.ts +108 -4
- package/subspace.ts +162 -10
- package/subspaceConfig.ts +11 -14
- package/testHelpers.ts +4 -0
- package/transforms/externalizeFrameworkPlugin.ts +1 -7
- package/transforms/serverEnvPlugin.test.ts +93 -2
- package/transforms/serverEnvPlugin.ts +71 -18
- package/transforms/serverEnvProfile.test.ts +82 -0
- package/transforms/serverEnvProfile.ts +114 -0
- package/typecheck/projectTypecheck.test.ts +32 -0
- package/typecheck/projectTypecheck.ts +71 -0
- package/workspaceLayout.test.ts +80 -3
- package/workspaceLayout.ts +15 -4
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AppPipelineConfig,
|
|
3
|
+
type AppPipelineContext,
|
|
4
|
+
defaultAppPipeline,
|
|
5
|
+
type PipelineContext,
|
|
6
|
+
type WorkspacePipelineConfig,
|
|
7
|
+
} from "akanjs";
|
|
8
|
+
import {
|
|
9
|
+
type PipelineActionName,
|
|
10
|
+
type PipelineDiagnostic,
|
|
11
|
+
type PipelineMilestone,
|
|
12
|
+
type PipelineOtaPlatform,
|
|
13
|
+
PipelinePattern,
|
|
14
|
+
type PipelinePlan,
|
|
15
|
+
type PipelinePlanContext,
|
|
16
|
+
type PipelinePlanPhase,
|
|
17
|
+
type PipelinePlanPost,
|
|
18
|
+
type PipelinePlanResult,
|
|
19
|
+
PipelinePlanSchema,
|
|
20
|
+
type PipelinePlanShellStep,
|
|
21
|
+
type PipelinePlanSource,
|
|
22
|
+
type PipelinePlanStage,
|
|
23
|
+
type PipelineSkip,
|
|
24
|
+
pipelineActionNames,
|
|
25
|
+
pipelineActions,
|
|
26
|
+
pipelineMilestones,
|
|
27
|
+
pipelineOtaPlatforms,
|
|
28
|
+
pipelinePlanVersion,
|
|
29
|
+
} from "akanjs/common";
|
|
30
|
+
import {
|
|
31
|
+
type PipelineActionStageRule,
|
|
32
|
+
type PipelineAppRule,
|
|
33
|
+
PipelineInputReader,
|
|
34
|
+
type PipelinePostRule,
|
|
35
|
+
type PipelineShellStageRule,
|
|
36
|
+
type PipelineStepRule,
|
|
37
|
+
type PipelineWhenRule,
|
|
38
|
+
type PipelineWorkspaceRule,
|
|
39
|
+
type PipelineWorkspaceStageRule,
|
|
40
|
+
} from "./PipelineInputReader";
|
|
41
|
+
|
|
42
|
+
export interface PipelineSourceFile<T> {
|
|
43
|
+
/** Relative to the workspace root, with `/`. */
|
|
44
|
+
file: string;
|
|
45
|
+
sha256: string;
|
|
46
|
+
pipeline: T | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An app's native targets as `akan pack-update --target` names them, or why they could not be read. */
|
|
50
|
+
export type PipelineNativeTargets = { targets: { name: string; updates: boolean }[] } | { error: string };
|
|
51
|
+
|
|
52
|
+
export interface PipelineCompileInput {
|
|
53
|
+
context: PipelinePlanContext;
|
|
54
|
+
workspaceApps: readonly string[];
|
|
55
|
+
workspace: PipelineSourceFile<WorkspacePipelineConfig> | null;
|
|
56
|
+
apps: ReadonlyMap<string, PipelineSourceFile<AppPipelineConfig>>;
|
|
57
|
+
/** Read only for an app whose pipeline takes `ota`. */
|
|
58
|
+
nativeTargets?: ReadonlyMap<string, PipelineNativeTargets>;
|
|
59
|
+
akanVersion: string;
|
|
60
|
+
/** Function forms run against a sample context (`validate`), which is said once per file. */
|
|
61
|
+
sampled?: boolean;
|
|
62
|
+
/** What loading the files already found. */
|
|
63
|
+
diagnostics?: readonly PipelineDiagnostic[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type CustomSlot = "before-build" | "after-build" | "after-docker" | "after-deploy" | "after-ota";
|
|
67
|
+
|
|
68
|
+
interface AppLane {
|
|
69
|
+
file: string;
|
|
70
|
+
env: Record<string, string>;
|
|
71
|
+
after: string[];
|
|
72
|
+
actions: Map<PipelineActionName, PipelineActionStageRule>;
|
|
73
|
+
skips: Map<PipelineActionName, PipelineSkip | null>;
|
|
74
|
+
slots: Record<CustomSlot, PipelineShellStageRule[]>;
|
|
75
|
+
ota: { targets: string[]; platforms: PipelineOtaPlatform[] } | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface StageOptions {
|
|
79
|
+
id: string;
|
|
80
|
+
name?: string;
|
|
81
|
+
app?: string | null;
|
|
82
|
+
milestone: PipelineMilestone;
|
|
83
|
+
env: Record<string, string>;
|
|
84
|
+
inheritedSkip?: PipelineSkip | null;
|
|
85
|
+
allowFailure?: boolean;
|
|
86
|
+
changed?: string[] | null;
|
|
87
|
+
timeoutSeconds?: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Composes the workspace's and each app's rules into one plan for one run. */
|
|
91
|
+
export class PipelineCompiler {
|
|
92
|
+
static readonly #slotAfter = {
|
|
93
|
+
build: "after-build",
|
|
94
|
+
docker: "after-docker",
|
|
95
|
+
deploy: "after-deploy",
|
|
96
|
+
ota: "after-ota",
|
|
97
|
+
} as const;
|
|
98
|
+
static readonly #slotMilestones = {
|
|
99
|
+
"before-build": "prepare",
|
|
100
|
+
"after-build": "verify",
|
|
101
|
+
"after-docker": "package",
|
|
102
|
+
"after-deploy": "deploy",
|
|
103
|
+
"after-ota": "deploy",
|
|
104
|
+
} as const;
|
|
105
|
+
static readonly #slotNames = {
|
|
106
|
+
"before-build": "Before build",
|
|
107
|
+
"after-build": "After build",
|
|
108
|
+
"after-docker": "After docker",
|
|
109
|
+
"after-deploy": "After deploy",
|
|
110
|
+
"after-ota": "After OTA",
|
|
111
|
+
} as const;
|
|
112
|
+
static readonly #reservedPhaseId =
|
|
113
|
+
/^(?:build|docker|deploy|ota|(?:before-build|after-build|after-docker|after-deploy|after-ota)(?:-\d+)?)$/;
|
|
114
|
+
static readonly #actionStepNames = { build: "akan build", docker: "image", deploy: "deploy", ota: "ota" } as const;
|
|
115
|
+
|
|
116
|
+
static compile(input: PipelineCompileInput): PipelinePlanResult {
|
|
117
|
+
return new PipelineCompiler(input).#compile();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
readonly #input: PipelineCompileInput;
|
|
121
|
+
readonly #diagnostics: PipelineDiagnostic[];
|
|
122
|
+
readonly #secrets = new Set<string>();
|
|
123
|
+
|
|
124
|
+
constructor(input: PipelineCompileInput) {
|
|
125
|
+
this.#input = input;
|
|
126
|
+
this.#diagnostics = [...(input.diagnostics ?? [])];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#compile(): PipelinePlanResult {
|
|
130
|
+
const { context, workspaceApps } = this.#input;
|
|
131
|
+
this.#checkContext();
|
|
132
|
+
const pipelineContext: PipelineContext = {
|
|
133
|
+
branch: context.branch,
|
|
134
|
+
env: context.env,
|
|
135
|
+
apps: [...context.apps],
|
|
136
|
+
workspaceApps: [...workspaceApps],
|
|
137
|
+
trigger: context.trigger,
|
|
138
|
+
buildNum: context.buildNum,
|
|
139
|
+
commit: context.commit,
|
|
140
|
+
};
|
|
141
|
+
const workspace = this.#readWorkspace(pipelineContext);
|
|
142
|
+
const lanes = new Map<string, AppLane>();
|
|
143
|
+
for (const app of new Set(context.apps)) {
|
|
144
|
+
const lane = this.#readApp(app, pipelineContext);
|
|
145
|
+
if (lane) lanes.set(app, lane);
|
|
146
|
+
}
|
|
147
|
+
const appOrder = this.#appOrder(lanes);
|
|
148
|
+
if (this.#hasErrors()) return this.#failed();
|
|
149
|
+
const workspaceEnv = workspace?.env ?? {};
|
|
150
|
+
const phases = this.#phases(workspace, lanes, appOrder, workspaceEnv);
|
|
151
|
+
const post = this.#post(workspace?.post, workspaceEnv);
|
|
152
|
+
if (this.#hasErrors()) return this.#failed();
|
|
153
|
+
const plan: PipelinePlan = {
|
|
154
|
+
schemaVersion: pipelinePlanVersion,
|
|
155
|
+
sources: this.#sources(),
|
|
156
|
+
planner: { akanVersion: this.#input.akanVersion },
|
|
157
|
+
context: { ...context, apps: [...context.apps] },
|
|
158
|
+
appOrder,
|
|
159
|
+
timeoutSeconds: workspace?.timeoutSeconds ?? null,
|
|
160
|
+
requiredSecrets: [...this.#secrets].sort(),
|
|
161
|
+
phases,
|
|
162
|
+
post,
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
PipelinePlanSchema.parse(plan);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
this.#error(message.includes("at most") ? "pipeline.limit.exceeded" : "pipeline.internal", message, null, null);
|
|
169
|
+
return this.#failed();
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, plan, diagnostics: this.#diagnostics };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#checkContext() {
|
|
175
|
+
const { context, workspaceApps } = this.#input;
|
|
176
|
+
const seen = new Set<string>();
|
|
177
|
+
for (const app of context.apps) {
|
|
178
|
+
if (seen.has(app)) this.#error("pipeline.context.duplicateApp", `"${app}" is given twice`, null, "apps");
|
|
179
|
+
seen.add(app);
|
|
180
|
+
if (!workspaceApps.includes(app))
|
|
181
|
+
this.#error("pipeline.context.unknownApp", `"${app}" is not an app of this workspace`, null, "apps");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#readWorkspace(context: PipelineContext): PipelineWorkspaceRule | null {
|
|
186
|
+
const source = this.#input.workspace;
|
|
187
|
+
if (!source?.pipeline) return null;
|
|
188
|
+
const value = this.#evaluate(source.pipeline, context, source.file);
|
|
189
|
+
if (value === undefined) return null;
|
|
190
|
+
const reader = new PipelineInputReader(source.file);
|
|
191
|
+
const rule = reader.workspace(value);
|
|
192
|
+
this.#diagnostics.push(...reader.diagnostics);
|
|
193
|
+
return rule;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#readApp(app: string, context: PipelineContext): AppLane | null {
|
|
197
|
+
const source = this.#input.apps.get(app);
|
|
198
|
+
if (!source) return null;
|
|
199
|
+
const appContext: AppPipelineContext = { ...context, app };
|
|
200
|
+
const value = source.pipeline ? this.#evaluate(source.pipeline, appContext, source.file) : defaultAppPipeline;
|
|
201
|
+
if (value === undefined) return null;
|
|
202
|
+
const reader = new PipelineInputReader(source.file);
|
|
203
|
+
const rule = reader.app(value);
|
|
204
|
+
this.#diagnostics.push(...reader.diagnostics);
|
|
205
|
+
return rule ? this.#lane(app, rule, source.file) : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#evaluate<C>(config: unknown, context: C, file: string): unknown {
|
|
209
|
+
if (typeof config !== "function") return config;
|
|
210
|
+
if (this.#input.sampled)
|
|
211
|
+
this.#warning(
|
|
212
|
+
"pipeline.function.sampled",
|
|
213
|
+
"a function form was checked against one sample context; other branches, triggers or apps may differ",
|
|
214
|
+
file,
|
|
215
|
+
"pipeline",
|
|
216
|
+
);
|
|
217
|
+
try {
|
|
218
|
+
return (config as (context: C) => unknown)(context);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this.#error(
|
|
221
|
+
"pipeline.load.failed",
|
|
222
|
+
`the pipeline function threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
223
|
+
file,
|
|
224
|
+
"pipeline",
|
|
225
|
+
);
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#lane(app: string, rule: PipelineAppRule, file: string): AppLane {
|
|
231
|
+
const lane: AppLane = {
|
|
232
|
+
file,
|
|
233
|
+
env: rule.env,
|
|
234
|
+
after: rule.after,
|
|
235
|
+
actions: new Map(),
|
|
236
|
+
skips: new Map(),
|
|
237
|
+
slots: { "before-build": [], "after-build": [], "after-docker": [], "after-deploy": [], "after-ota": [] },
|
|
238
|
+
ota: null,
|
|
239
|
+
};
|
|
240
|
+
let last: PipelineActionName | null = null;
|
|
241
|
+
let lastMilestone = -1;
|
|
242
|
+
for (const stage of rule.stages) {
|
|
243
|
+
const milestone =
|
|
244
|
+
stage.milestone ??
|
|
245
|
+
(stage.kind === "action"
|
|
246
|
+
? pipelineActions[stage.action].milestone
|
|
247
|
+
: PipelineCompiler.#slotMilestones[last ? PipelineCompiler.#slotAfter[last] : "before-build"]);
|
|
248
|
+
const rank = pipelineMilestones.indexOf(milestone);
|
|
249
|
+
if (stage.milestone && rank < lastMilestone)
|
|
250
|
+
this.#warning(
|
|
251
|
+
"pipeline.milestone.backwards",
|
|
252
|
+
`"${milestone}" comes after a later milestone, so progress would move backwards`,
|
|
253
|
+
file,
|
|
254
|
+
`${stage.path}.milestone`,
|
|
255
|
+
);
|
|
256
|
+
lastMilestone = Math.max(lastMilestone, rank);
|
|
257
|
+
if (stage.kind === "sh") {
|
|
258
|
+
lane.slots[last ? PipelineCompiler.#slotAfter[last] : "before-build"].push(stage);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (lane.actions.has(stage.action)) {
|
|
262
|
+
this.#error("pipeline.action.duplicate", `\`${stage.action}\` is declared twice`, file, stage.path);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (last && pipelineActionNames.indexOf(stage.action) < pipelineActionNames.indexOf(last))
|
|
266
|
+
this.#error(
|
|
267
|
+
"pipeline.action.order",
|
|
268
|
+
`\`${stage.action}\` comes after \`${last}\` here, but the actions run ${pipelineActionNames.join(", ")}`,
|
|
269
|
+
file,
|
|
270
|
+
stage.path,
|
|
271
|
+
);
|
|
272
|
+
lane.actions.set(stage.action, stage);
|
|
273
|
+
last = stage.action;
|
|
274
|
+
}
|
|
275
|
+
const docker = lane.actions.get("docker");
|
|
276
|
+
if (docker && !lane.actions.has("build"))
|
|
277
|
+
this.#error(
|
|
278
|
+
"pipeline.action.dockerWithoutBuild",
|
|
279
|
+
`\`docker\` builds the image from this run's \`akan build\` of ${app}, so it needs a \`build\` before it`,
|
|
280
|
+
file,
|
|
281
|
+
docker.path,
|
|
282
|
+
);
|
|
283
|
+
const deploy = lane.actions.get("deploy");
|
|
284
|
+
if (deploy && !lane.actions.has("docker"))
|
|
285
|
+
this.#warning(
|
|
286
|
+
"pipeline.action.deployWithoutDocker",
|
|
287
|
+
`\`deploy\` without \`docker\` deploys the image last pushed for ${app}`,
|
|
288
|
+
file,
|
|
289
|
+
deploy.path,
|
|
290
|
+
);
|
|
291
|
+
const ota = lane.actions.get("ota");
|
|
292
|
+
if (ota) lane.ota = this.#otaTargets(app, ota, file);
|
|
293
|
+
if (!lane.actions.size)
|
|
294
|
+
this.#warning(
|
|
295
|
+
"pipeline.app.noActions",
|
|
296
|
+
`the pipeline of ${app} takes no built-in action, so this run neither builds nor deploys it`,
|
|
297
|
+
file,
|
|
298
|
+
"pipeline.stages",
|
|
299
|
+
);
|
|
300
|
+
let previous: PipelineActionName | null = null;
|
|
301
|
+
for (const action of pipelineActionNames) {
|
|
302
|
+
const stage = lane.actions.get(action);
|
|
303
|
+
if (!stage) continue;
|
|
304
|
+
const inherited = previous && lane.skips.get(previous) ? { reason: `${previous} of ${app} is skipped` } : null;
|
|
305
|
+
lane.skips.set(action, this.#skipOf(stage.when) ?? inherited);
|
|
306
|
+
previous = action;
|
|
307
|
+
}
|
|
308
|
+
return lane;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
//? Checked here, not by the executor: a pack for a target without updates fails in the build pod after its CSR build.
|
|
312
|
+
#otaTargets(app: string, stage: PipelineActionStageRule, file: string): AppLane["ota"] {
|
|
313
|
+
const platforms = stage.platforms ?? [...pipelineOtaPlatforms];
|
|
314
|
+
const native = this.#input.nativeTargets?.get(app);
|
|
315
|
+
if (!native || "error" in native) {
|
|
316
|
+
this.#error(
|
|
317
|
+
"pipeline.ota.targets",
|
|
318
|
+
`\`ota\` needs the native targets of ${app}, which could not be read${native ? `: ${native.error}` : ""}`,
|
|
319
|
+
file,
|
|
320
|
+
stage.path,
|
|
321
|
+
);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
const known = new Map(native.targets.map((target) => [target.name, target.updates]));
|
|
325
|
+
if (!stage.targets) {
|
|
326
|
+
const targets = native.targets.filter((target) => target.updates).map((target) => target.name);
|
|
327
|
+
if (targets.length) return { targets, platforms };
|
|
328
|
+
this.#error(
|
|
329
|
+
"pipeline.ota.noUpdates",
|
|
330
|
+
`\`ota\` packs updates for targets that declare native.updates, and no native target of ${app} does`,
|
|
331
|
+
file,
|
|
332
|
+
stage.path,
|
|
333
|
+
);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const unknown = stage.targets.filter((name) => !known.has(name));
|
|
337
|
+
const withoutUpdates = stage.targets.filter((name) => known.get(name) === false);
|
|
338
|
+
if (unknown.length)
|
|
339
|
+
this.#error(
|
|
340
|
+
"pipeline.ota.unknownTarget",
|
|
341
|
+
`${unknown.map((name) => `"${name}"`).join(", ")} ${unknown.length > 1 ? "are not native targets" : "is not a native target"} of ${app}: ${[...known.keys()].join(", ")}`,
|
|
342
|
+
file,
|
|
343
|
+
`${stage.path}.targets`,
|
|
344
|
+
);
|
|
345
|
+
if (withoutUpdates.length)
|
|
346
|
+
this.#error(
|
|
347
|
+
"pipeline.ota.noUpdates",
|
|
348
|
+
`\`ota\` packs updates for targets that declare native.updates, and ${withoutUpdates.map((name) => `"${name}"`).join(", ")} of ${app} ${withoutUpdates.length > 1 ? "do" : "does"} not`,
|
|
349
|
+
file,
|
|
350
|
+
`${stage.path}.targets`,
|
|
351
|
+
);
|
|
352
|
+
return unknown.length || withoutUpdates.length ? null : { targets: stage.targets, platforms };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#appOrder(lanes: ReadonlyMap<string, AppLane>): string[] {
|
|
356
|
+
const apps = [...new Set(this.#input.context.apps)];
|
|
357
|
+
const known = new Set(apps);
|
|
358
|
+
const after = new Map(
|
|
359
|
+
apps.map((app) => [app, new Set((lanes.get(app)?.after ?? []).filter((dep) => known.has(dep) && dep !== app))]),
|
|
360
|
+
);
|
|
361
|
+
const order: string[] = [];
|
|
362
|
+
const done = new Set<string>();
|
|
363
|
+
while (order.length < apps.length) {
|
|
364
|
+
const next = apps.find((app) => !done.has(app) && [...(after.get(app) ?? [])].every((dep) => done.has(dep)));
|
|
365
|
+
if (!next) {
|
|
366
|
+
const waiting = apps.filter((app) => !done.has(app));
|
|
367
|
+
this.#error(
|
|
368
|
+
"pipeline.app.afterCycle",
|
|
369
|
+
`\`after\` makes a cycle among ${waiting.join(", ")}`,
|
|
370
|
+
lanes.get(waiting[0] ?? "")?.file ?? null,
|
|
371
|
+
"pipeline.after",
|
|
372
|
+
);
|
|
373
|
+
return apps;
|
|
374
|
+
}
|
|
375
|
+
order.push(next);
|
|
376
|
+
done.add(next);
|
|
377
|
+
}
|
|
378
|
+
return order;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#phases(
|
|
382
|
+
workspace: PipelineWorkspaceRule | null,
|
|
383
|
+
lanes: ReadonlyMap<string, AppLane>,
|
|
384
|
+
appOrder: string[],
|
|
385
|
+
workspaceEnv: Record<string, string>,
|
|
386
|
+
): PipelinePlanPhase[] {
|
|
387
|
+
const phases: PipelinePlanPhase[] = [];
|
|
388
|
+
for (const stage of workspace?.before ?? [])
|
|
389
|
+
phases.push(...this.#workspacePhase(stage, "before", appOrder, workspaceEnv));
|
|
390
|
+
phases.push(...this.#customPhases("before-build", lanes, appOrder, workspaceEnv));
|
|
391
|
+
for (const action of pipelineActionNames) {
|
|
392
|
+
const phase = this.#actionPhase(action, lanes, appOrder);
|
|
393
|
+
if (phase) phases.push(phase);
|
|
394
|
+
phases.push(...this.#customPhases(PipelineCompiler.#slotAfter[action], lanes, appOrder, workspaceEnv));
|
|
395
|
+
}
|
|
396
|
+
for (const stage of workspace?.after ?? [])
|
|
397
|
+
phases.push(...this.#workspacePhase(stage, "after", appOrder, workspaceEnv));
|
|
398
|
+
return phases;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#actionPhase(
|
|
402
|
+
action: PipelineActionName,
|
|
403
|
+
lanes: ReadonlyMap<string, AppLane>,
|
|
404
|
+
appOrder: string[],
|
|
405
|
+
): PipelinePlanPhase | null {
|
|
406
|
+
const stages = appOrder.flatMap((app): PipelinePlanStage[] => {
|
|
407
|
+
const lane = lanes.get(app);
|
|
408
|
+
const rule = lane?.actions.get(action);
|
|
409
|
+
if (!lane || !rule) return [];
|
|
410
|
+
const id = `${app}:${rule.id}`;
|
|
411
|
+
return [
|
|
412
|
+
{
|
|
413
|
+
id,
|
|
414
|
+
name: rule.named ? rule.name : app,
|
|
415
|
+
description: rule.description,
|
|
416
|
+
app,
|
|
417
|
+
milestone: rule.milestone ?? pipelineActions[action].milestone,
|
|
418
|
+
skip: lane.skips.get(action) ?? null,
|
|
419
|
+
runtimeWhen: rule.when.changed ? { changed: rule.when.changed } : null,
|
|
420
|
+
allowFailure: rule.allowFailure,
|
|
421
|
+
timeoutSeconds: rule.timeoutSeconds,
|
|
422
|
+
steps: [
|
|
423
|
+
{
|
|
424
|
+
id: `${id}/${action}`,
|
|
425
|
+
name: `${PipelineCompiler.#actionStepNames[action]} ${app}`,
|
|
426
|
+
timeoutSeconds: null,
|
|
427
|
+
retry: rule.retry,
|
|
428
|
+
allowFailure: false,
|
|
429
|
+
kind: "action",
|
|
430
|
+
actionVersion: pipelineActions[action].version,
|
|
431
|
+
...(action === "ota"
|
|
432
|
+
? { action, with: { app, targets: lane.ota?.targets ?? [], platforms: lane.ota?.platforms ?? [] } }
|
|
433
|
+
: { action, with: { app } }),
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
];
|
|
438
|
+
});
|
|
439
|
+
if (!stages.length) return null;
|
|
440
|
+
return {
|
|
441
|
+
id: action,
|
|
442
|
+
name: pipelineActions[action].name,
|
|
443
|
+
description: null,
|
|
444
|
+
slot: action,
|
|
445
|
+
milestone: pipelineActions[action].milestone,
|
|
446
|
+
action,
|
|
447
|
+
order: action === "deploy" ? "sequential" : "parallel",
|
|
448
|
+
maxParallel: null,
|
|
449
|
+
failFast: false,
|
|
450
|
+
timeoutSeconds: null,
|
|
451
|
+
stages,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#customPhases(
|
|
456
|
+
slot: CustomSlot,
|
|
457
|
+
lanes: ReadonlyMap<string, AppLane>,
|
|
458
|
+
appOrder: string[],
|
|
459
|
+
workspaceEnv: Record<string, string>,
|
|
460
|
+
): PipelinePlanPhase[] {
|
|
461
|
+
const depth = Math.max(0, ...appOrder.map((app) => lanes.get(app)?.slots[slot].length ?? 0));
|
|
462
|
+
const milestone = PipelineCompiler.#slotMilestones[slot];
|
|
463
|
+
return Array.from({ length: depth }, (_, idx): PipelinePlanPhase => {
|
|
464
|
+
const stages = appOrder.flatMap((app) => {
|
|
465
|
+
const lane = lanes.get(app);
|
|
466
|
+
const rule = lane?.slots[slot][idx];
|
|
467
|
+
if (!lane || !rule) return [];
|
|
468
|
+
return [
|
|
469
|
+
this.#shellStage(rule, {
|
|
470
|
+
id: `${app}:${rule.id}`,
|
|
471
|
+
app,
|
|
472
|
+
milestone: rule.milestone ?? milestone,
|
|
473
|
+
env: { ...workspaceEnv, ...lane.env },
|
|
474
|
+
}),
|
|
475
|
+
];
|
|
476
|
+
});
|
|
477
|
+
return {
|
|
478
|
+
id: idx === 0 ? slot : `${slot}-${idx + 1}`,
|
|
479
|
+
name: idx === 0 ? PipelineCompiler.#slotNames[slot] : `${PipelineCompiler.#slotNames[slot]} ${idx + 1}`,
|
|
480
|
+
description: null,
|
|
481
|
+
slot,
|
|
482
|
+
milestone,
|
|
483
|
+
action: null,
|
|
484
|
+
order: "parallel",
|
|
485
|
+
maxParallel: null,
|
|
486
|
+
failFast: false,
|
|
487
|
+
timeoutSeconds: null,
|
|
488
|
+
stages,
|
|
489
|
+
};
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#workspacePhase(
|
|
494
|
+
stage: PipelineWorkspaceStageRule,
|
|
495
|
+
position: "before" | "after",
|
|
496
|
+
appOrder: string[],
|
|
497
|
+
env: Record<string, string>,
|
|
498
|
+
): PipelinePlanPhase[] {
|
|
499
|
+
if (PipelineCompiler.#reservedPhaseId.test(stage.id))
|
|
500
|
+
this.#error(
|
|
501
|
+
"pipeline.id.reserved",
|
|
502
|
+
`"${stage.id}" is the id of a phase the planner makes; give this stage another \`id\``,
|
|
503
|
+
this.#input.workspace?.file ?? null,
|
|
504
|
+
stage.path,
|
|
505
|
+
);
|
|
506
|
+
const milestone = stage.milestone ?? (position === "before" ? "prepare" : "finish");
|
|
507
|
+
const phase = {
|
|
508
|
+
id: stage.id,
|
|
509
|
+
name: stage.name,
|
|
510
|
+
description: stage.description,
|
|
511
|
+
slot: position,
|
|
512
|
+
milestone,
|
|
513
|
+
action: null,
|
|
514
|
+
order: "parallel",
|
|
515
|
+
} as const;
|
|
516
|
+
if (stage.kind === "sh")
|
|
517
|
+
return [
|
|
518
|
+
{
|
|
519
|
+
...phase,
|
|
520
|
+
maxParallel: null,
|
|
521
|
+
failFast: false,
|
|
522
|
+
timeoutSeconds: null,
|
|
523
|
+
stages: [this.#shellStage(stage, { id: stage.id, milestone, env })],
|
|
524
|
+
},
|
|
525
|
+
];
|
|
526
|
+
const parentSkip = this.#skipOf(stage.when);
|
|
527
|
+
if (stage.kind === "parallel")
|
|
528
|
+
return [
|
|
529
|
+
{
|
|
530
|
+
...phase,
|
|
531
|
+
maxParallel: stage.maxParallel,
|
|
532
|
+
failFast: stage.failFast,
|
|
533
|
+
timeoutSeconds: stage.timeoutSeconds,
|
|
534
|
+
stages: stage.branches.map((branch) =>
|
|
535
|
+
this.#shellStage(branch, {
|
|
536
|
+
id: `${stage.id}.${branch.id}`,
|
|
537
|
+
milestone: branch.milestone ?? milestone,
|
|
538
|
+
env,
|
|
539
|
+
inheritedSkip: parentSkip,
|
|
540
|
+
allowFailure: stage.allowFailure,
|
|
541
|
+
changed: stage.when.changed,
|
|
542
|
+
}),
|
|
543
|
+
),
|
|
544
|
+
},
|
|
545
|
+
];
|
|
546
|
+
const apps = appOrder.filter((app) => !stage.when.app || PipelinePattern.matches(stage.when.app, app));
|
|
547
|
+
if (!apps.length) return [];
|
|
548
|
+
const copy: PipelineShellStageRule = { ...stage, kind: "sh", timeoutSeconds: null };
|
|
549
|
+
return [
|
|
550
|
+
{
|
|
551
|
+
...phase,
|
|
552
|
+
maxParallel: stage.maxParallel,
|
|
553
|
+
failFast: false,
|
|
554
|
+
timeoutSeconds: stage.timeoutSeconds,
|
|
555
|
+
stages: apps.map((app) => this.#shellStage(copy, { id: `${stage.id}:${app}`, name: app, app, milestone, env })),
|
|
556
|
+
},
|
|
557
|
+
];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#shellStage(rule: PipelineShellStageRule, options: StageOptions): PipelinePlanStage {
|
|
561
|
+
const changed = options.changed ?? rule.when.changed;
|
|
562
|
+
return {
|
|
563
|
+
id: options.id,
|
|
564
|
+
name: options.name ?? rule.name,
|
|
565
|
+
description: rule.description,
|
|
566
|
+
app: options.app ?? null,
|
|
567
|
+
milestone: options.milestone,
|
|
568
|
+
skip: options.inheritedSkip ?? this.#skipOf(rule.when),
|
|
569
|
+
runtimeWhen: changed ? { changed } : null,
|
|
570
|
+
allowFailure: (options.allowFailure ?? false) || rule.allowFailure,
|
|
571
|
+
timeoutSeconds: options.timeoutSeconds === undefined ? rule.timeoutSeconds : options.timeoutSeconds,
|
|
572
|
+
steps: rule.steps.map((step) => this.#shellStep(step, options.id, options.env)),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
#shellStep(step: PipelineStepRule, stageId: string, env: Record<string, string>): PipelinePlanShellStep {
|
|
577
|
+
for (const name of step.secrets) this.#secrets.add(name);
|
|
578
|
+
return {
|
|
579
|
+
id: `${stageId}/${step.localId}`,
|
|
580
|
+
name: step.name,
|
|
581
|
+
timeoutSeconds: step.timeoutSeconds,
|
|
582
|
+
retry: step.retry,
|
|
583
|
+
allowFailure: step.allowFailure,
|
|
584
|
+
kind: "sh",
|
|
585
|
+
run: step.run,
|
|
586
|
+
cwd: step.cwd,
|
|
587
|
+
env: { ...env, ...step.env },
|
|
588
|
+
secrets: step.secrets,
|
|
589
|
+
skipExitCodes: step.skipExitCodes,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#post(rule: PipelinePostRule | undefined, env: Record<string, string>): PipelinePlanPost {
|
|
594
|
+
const steps = (result: keyof PipelinePlanPost) =>
|
|
595
|
+
(rule?.[result] ?? []).map((step) => this.#shellStep(step, `post.${result}`, env));
|
|
596
|
+
return {
|
|
597
|
+
success: steps("success"),
|
|
598
|
+
failure: steps("failure"),
|
|
599
|
+
canceled: steps("canceled"),
|
|
600
|
+
always: steps("always"),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
#skipOf(when: PipelineWhenRule): PipelineSkip | null {
|
|
605
|
+
const { branch, trigger } = this.#input.context;
|
|
606
|
+
if (when.branch && !PipelinePattern.matches(when.branch, branch))
|
|
607
|
+
return { reason: `branch "${branch}" does not match ${when.branch.map((glob) => `"${glob}"`).join(", ")}` };
|
|
608
|
+
if (when.trigger && !when.trigger.includes(trigger))
|
|
609
|
+
return { reason: `trigger "${trigger}" is not ${when.trigger.map((name) => `"${name}"`).join(" or ")}` };
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#sources(): PipelinePlan["sources"] {
|
|
614
|
+
const { workspace, apps, context } = this.#input;
|
|
615
|
+
const sourceOf = (source: PipelineSourceFile<unknown>): PipelinePlanSource => ({
|
|
616
|
+
file: source.file,
|
|
617
|
+
sha256: source.sha256,
|
|
618
|
+
declared: source.pipeline !== null,
|
|
619
|
+
});
|
|
620
|
+
return {
|
|
621
|
+
workspace: workspace ? sourceOf(workspace) : null,
|
|
622
|
+
apps: Object.fromEntries(
|
|
623
|
+
context.apps.flatMap((app) => {
|
|
624
|
+
const source = apps.get(app);
|
|
625
|
+
return source ? [[app, sourceOf(source)]] : [];
|
|
626
|
+
}),
|
|
627
|
+
),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#hasErrors() {
|
|
632
|
+
return this.#diagnostics.some((diagnostic) => diagnostic.severity === "error");
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#failed(): PipelinePlanResult {
|
|
636
|
+
return { ok: false, plan: null, diagnostics: this.#diagnostics };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
#error(code: string, message: string, file: string | null, path: string | null) {
|
|
640
|
+
this.#diagnostics.push({ severity: "error", code, message, file, path });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
#warning(code: string, message: string, file: string | null, path: string | null) {
|
|
644
|
+
this.#diagnostics.push({ severity: "warning", code, message, file, path });
|
|
645
|
+
}
|
|
646
|
+
}
|