@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.3
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/akanConfig/akanConfig.test.ts +51 -5
- package/akanConfig/akanConfig.ts +31 -12
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/applicationBuildRunner.test.ts +33 -1
- package/applicationBuildRunner.ts +4 -5
- package/cloud/cloudApi.ts +22 -15
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +8 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +40 -6
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/executors.test.ts +69 -0
- package/executors.ts +30 -27
- package/frontendBuild/csrArtifactBuilder.ts +18 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -33
- package/mobile/nativeApp.ts +109 -73
- package/mobile/nativeConfig.ts +2 -1
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +114 -22
- package/mobile/updateUploader.ts +75 -8
- package/package.json +2 -2
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/qualityScanner.test.ts +5 -3
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +361 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/typecheck/projectTypecheck.ts +6 -5
|
@@ -25,19 +25,21 @@ import {
|
|
|
25
25
|
pipelineActions,
|
|
26
26
|
pipelineMilestones,
|
|
27
27
|
pipelineOtaPlatforms,
|
|
28
|
+
pipelinePlanLimits,
|
|
28
29
|
pipelinePlanVersion,
|
|
30
|
+
toError,
|
|
29
31
|
} from "akanjs/common";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} from "./
|
|
32
|
+
import { PipelineInputReader } from "./PipelineInputReader";
|
|
33
|
+
import type {
|
|
34
|
+
PipelineActionStageRule,
|
|
35
|
+
PipelineAppRule,
|
|
36
|
+
PipelinePostRule,
|
|
37
|
+
PipelineShellStageRule,
|
|
38
|
+
PipelineStepRule,
|
|
39
|
+
PipelineWhenRule,
|
|
40
|
+
PipelineWorkspaceRule,
|
|
41
|
+
PipelineWorkspaceStageRule,
|
|
42
|
+
} from "./PipelineRules";
|
|
41
43
|
|
|
42
44
|
export interface PipelineSourceFile<T> {
|
|
43
45
|
/** Relative to the workspace root, with `/`. */
|
|
@@ -59,7 +61,6 @@ export interface PipelineCompileInput {
|
|
|
59
61
|
akanVersion: string;
|
|
60
62
|
/** Function forms run against a sample context (`validate`), which is said once per file. */
|
|
61
63
|
sampled?: boolean;
|
|
62
|
-
/** What loading the files already found. */
|
|
63
64
|
diagnostics?: readonly PipelineDiagnostic[];
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -87,7 +88,6 @@ interface StageOptions {
|
|
|
87
88
|
timeoutSeconds?: number | null;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
/** Composes the workspace's and each app's rules into one plan for one run. */
|
|
91
91
|
export class PipelineCompiler {
|
|
92
92
|
static readonly #slotAfter = {
|
|
93
93
|
build: "after-build",
|
|
@@ -112,6 +112,8 @@ export class PipelineCompiler {
|
|
|
112
112
|
static readonly #reservedPhaseId =
|
|
113
113
|
/^(?:build|docker|deploy|ota|(?:before-build|after-build|after-docker|after-deploy|after-ota)(?:-\d+)?)$/;
|
|
114
114
|
static readonly #actionStepNames = { build: "akan build", docker: "image", deploy: "deploy", ota: "ota" } as const;
|
|
115
|
+
//? PipelinePlanSchema reads at most 1024 characters of a skip reason.
|
|
116
|
+
static readonly #reasonChars = 1024;
|
|
115
117
|
|
|
116
118
|
static compile(input: PipelineCompileInput): PipelinePlanResult {
|
|
117
119
|
return new PipelineCompiler(input).#compile();
|
|
@@ -130,13 +132,9 @@ export class PipelineCompiler {
|
|
|
130
132
|
const { context, workspaceApps } = this.#input;
|
|
131
133
|
this.#checkContext();
|
|
132
134
|
const pipelineContext: PipelineContext = {
|
|
133
|
-
|
|
134
|
-
env: context.env,
|
|
135
|
+
...context,
|
|
135
136
|
apps: [...context.apps],
|
|
136
137
|
workspaceApps: [...workspaceApps],
|
|
137
|
-
trigger: context.trigger,
|
|
138
|
-
buildNum: context.buildNum,
|
|
139
|
-
commit: context.commit,
|
|
140
138
|
};
|
|
141
139
|
const workspace = this.#readWorkspace(pipelineContext);
|
|
142
140
|
const lanes = new Map<string, AppLane>();
|
|
@@ -164,11 +162,23 @@ export class PipelineCompiler {
|
|
|
164
162
|
try {
|
|
165
163
|
PipelinePlanSchema.parse(plan);
|
|
166
164
|
} catch (error) {
|
|
167
|
-
const message = error
|
|
165
|
+
const message = toError(error).message;
|
|
168
166
|
this.#error(message.includes("at most") ? "pipeline.limit.exceeded" : "pipeline.internal", message, null, null);
|
|
169
167
|
return this.#failed();
|
|
170
168
|
}
|
|
171
|
-
|
|
169
|
+
const result: PipelinePlanResult = { ok: true, plan, diagnostics: this.#diagnostics };
|
|
170
|
+
//? Sized as `akan pipeline plan --out` writes it, since that text is what an executor's parseText bounds.
|
|
171
|
+
const bytes = new TextEncoder().encode(`${JSON.stringify(result, null, 2)}\n`).length;
|
|
172
|
+
if (bytes > pipelinePlanLimits.planBytes) {
|
|
173
|
+
this.#error(
|
|
174
|
+
"pipeline.limit.exceeded",
|
|
175
|
+
`the plan is ${bytes} bytes as written, and an executor reads at most ${pipelinePlanLimits.planBytes}`,
|
|
176
|
+
null,
|
|
177
|
+
null,
|
|
178
|
+
);
|
|
179
|
+
return this.#failed();
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
#checkContext() {
|
|
@@ -184,11 +194,11 @@ export class PipelineCompiler {
|
|
|
184
194
|
|
|
185
195
|
#readWorkspace(context: PipelineContext): PipelineWorkspaceRule | null {
|
|
186
196
|
const source = this.#input.workspace;
|
|
187
|
-
if (
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
197
|
+
if (source?.pipeline == null) return null;
|
|
198
|
+
const evaluated = this.#evaluate(source.pipeline, context, source.file);
|
|
199
|
+
if (!evaluated) return null;
|
|
190
200
|
const reader = new PipelineInputReader(source.file);
|
|
191
|
-
const rule = reader.workspace(value);
|
|
201
|
+
const rule = reader.workspace(evaluated.value);
|
|
192
202
|
this.#diagnostics.push(...reader.diagnostics);
|
|
193
203
|
return rule;
|
|
194
204
|
}
|
|
@@ -197,34 +207,55 @@ export class PipelineCompiler {
|
|
|
197
207
|
const source = this.#input.apps.get(app);
|
|
198
208
|
if (!source) return null;
|
|
199
209
|
const appContext: AppPipelineContext = { ...context, app };
|
|
200
|
-
const
|
|
201
|
-
|
|
210
|
+
const evaluated =
|
|
211
|
+
source.pipeline == null
|
|
212
|
+
? { value: defaultAppPipeline }
|
|
213
|
+
: this.#evaluate(source.pipeline, appContext, source.file);
|
|
214
|
+
if (!evaluated) return null;
|
|
202
215
|
const reader = new PipelineInputReader(source.file);
|
|
203
|
-
const rule = reader.app(value);
|
|
216
|
+
const rule = reader.app(evaluated.value);
|
|
204
217
|
this.#diagnostics.push(...reader.diagnostics);
|
|
205
218
|
return rule ? this.#lane(app, rule, source.file) : null;
|
|
206
219
|
}
|
|
207
220
|
|
|
208
|
-
#evaluate<C>(config: unknown, context: C, file: string): unknown {
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
this.#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
221
|
+
#evaluate<C>(config: unknown, context: C, file: string): { value: unknown } | null {
|
|
222
|
+
let value = config;
|
|
223
|
+
if (typeof config === "function") {
|
|
224
|
+
if (this.#input.sampled)
|
|
225
|
+
this.#warning(
|
|
226
|
+
"pipeline.function.sampled",
|
|
227
|
+
"a function form was checked against one sample context; other branches, triggers or apps may differ",
|
|
228
|
+
file,
|
|
229
|
+
"pipeline",
|
|
230
|
+
);
|
|
231
|
+
try {
|
|
232
|
+
value = (config as (context: C) => unknown)(context);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
this.#error("pipeline.load.failed", `the pipeline function threw: ${toError(error).message}`, file, "pipeline");
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
if (value === undefined) {
|
|
238
|
+
this.#error(
|
|
239
|
+
"pipeline.schema.invalid",
|
|
240
|
+
"the pipeline function returned nothing for this run: return a pipeline on every path",
|
|
241
|
+
file,
|
|
242
|
+
"pipeline",
|
|
243
|
+
);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (typeof (value as PromiseLike<unknown> | null)?.then === "function") {
|
|
248
|
+
//? Handled so a rejection cannot end the CLI before it writes the envelope.
|
|
249
|
+
void Promise.resolve(value).catch(() => undefined);
|
|
220
250
|
this.#error(
|
|
221
|
-
"pipeline.
|
|
222
|
-
|
|
251
|
+
"pipeline.schema.invalid",
|
|
252
|
+
"is a Promise, which the planner cannot wait for: return the pipeline itself, not from an async function",
|
|
223
253
|
file,
|
|
224
254
|
"pipeline",
|
|
225
255
|
);
|
|
226
|
-
return
|
|
256
|
+
return null;
|
|
227
257
|
}
|
|
258
|
+
return { value };
|
|
228
259
|
}
|
|
229
260
|
|
|
230
261
|
#lane(app: string, rule: PipelineAppRule, file: string): AppLane {
|
|
@@ -552,7 +583,9 @@ export class PipelineCompiler {
|
|
|
552
583
|
maxParallel: stage.maxParallel,
|
|
553
584
|
failFast: false,
|
|
554
585
|
timeoutSeconds: stage.timeoutSeconds,
|
|
555
|
-
stages: apps.map((app) =>
|
|
586
|
+
stages: apps.map((app) =>
|
|
587
|
+
this.#shellStage(copy, { id: `${stage.id}.apps.${app}`, name: app, app, milestone, env }),
|
|
588
|
+
),
|
|
556
589
|
},
|
|
557
590
|
];
|
|
558
591
|
}
|
|
@@ -603,10 +636,11 @@ export class PipelineCompiler {
|
|
|
603
636
|
|
|
604
637
|
#skipOf(when: PipelineWhenRule): PipelineSkip | null {
|
|
605
638
|
const { branch, trigger } = this.#input.context;
|
|
639
|
+
const skip = (reason: string) => ({ reason: PipelineInputReader.clip(reason, PipelineCompiler.#reasonChars) });
|
|
606
640
|
if (when.branch && !PipelinePattern.matches(when.branch, branch))
|
|
607
|
-
return
|
|
641
|
+
return skip(`branch "${branch}" does not match ${when.branch.map((glob) => `"${glob}"`).join(", ")}`);
|
|
608
642
|
if (when.trigger && !when.trigger.includes(trigger))
|
|
609
|
-
return
|
|
643
|
+
return skip(`trigger "${trigger}" is not ${when.trigger.map((name) => `"${name}"`).join(" or ")}`);
|
|
610
644
|
return null;
|
|
611
645
|
}
|
|
612
646
|
|
|
@@ -615,7 +649,7 @@ export class PipelineCompiler {
|
|
|
615
649
|
const sourceOf = (source: PipelineSourceFile<unknown>): PipelinePlanSource => ({
|
|
616
650
|
file: source.file,
|
|
617
651
|
sha256: source.sha256,
|
|
618
|
-
declared: source.pipeline
|
|
652
|
+
declared: source.pipeline != null,
|
|
619
653
|
});
|
|
620
654
|
return {
|
|
621
655
|
workspace: workspace ? sourceOf(workspace) : null,
|
|
@@ -70,6 +70,15 @@ describe("PipelineDefinition", () => {
|
|
|
70
70
|
);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
test("the workspace's apps are in code-point order, whatever the machine's locale", async () => {
|
|
74
|
+
await withWorkspace(
|
|
75
|
+
{ "apps/api/akan.config.ts": "export default {};\n", "apps/Web/akan.config.ts": "export default {};\n" },
|
|
76
|
+
async (workspace) => {
|
|
77
|
+
expect((await PipelineDefinition.load(workspace, [])).workspaceApps).toEqual(["Web", "api"]);
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
73
82
|
test("a config that cannot load is an error naming the file, never a silent default", async () => {
|
|
74
83
|
await withWorkspace(
|
|
75
84
|
{
|
|
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { AppPipelineConfig, WorkspacePipelineConfig } from "akanjs";
|
|
4
4
|
import type { PipelineDiagnostic, PipelinePlanContext, PipelinePlanResult } from "akanjs/common";
|
|
5
|
+
import { toError } from "akanjs/common";
|
|
5
6
|
import { AkanAppConfig, AkanWorkspaceConfig } from "../akanConfig";
|
|
6
7
|
import { AppExecutor, type WorkspaceExecutor } from "../executors";
|
|
7
8
|
import { FileSys } from "../fileSys";
|
|
@@ -44,7 +45,7 @@ export class PipelineDefinition {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
async #load(apps: readonly string[]) {
|
|
47
|
-
this.workspaceApps = (await this.#workspace.getApps()).sort(
|
|
48
|
+
this.workspaceApps = (await this.#workspace.getApps()).sort();
|
|
48
49
|
this.workspaceSource = await this.#loadWorkspace();
|
|
49
50
|
for (const app of new Set(apps)) {
|
|
50
51
|
if (!this.workspaceApps.includes(app)) continue;
|
|
@@ -68,7 +69,7 @@ export class PipelineDefinition {
|
|
|
68
69
|
const targets = await resolveMobileTargets(AppExecutor.from(this.#workspace, app), "all");
|
|
69
70
|
return { targets: targets.map(({ name, config }) => ({ name, updates: !!config.updates })) };
|
|
70
71
|
} catch (error) {
|
|
71
|
-
return { error: error
|
|
72
|
+
return { error: toError(error).message };
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -105,7 +106,7 @@ export class PipelineDefinition {
|
|
|
105
106
|
this.diagnostics.push({
|
|
106
107
|
severity: "error",
|
|
107
108
|
code: "pipeline.load.failed",
|
|
108
|
-
message: `could not load: ${error
|
|
109
|
+
message: `could not load: ${toError(error).message}`,
|
|
109
110
|
file,
|
|
110
111
|
path: null,
|
|
111
112
|
});
|
|
@@ -76,6 +76,33 @@ describe("PipelineDryRun", () => {
|
|
|
76
76
|
);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test("a phase of one stage still names its app", () => {
|
|
80
|
+
const result = PipelineCompiler.compile({
|
|
81
|
+
context: { ...context, apps: ["web"] },
|
|
82
|
+
workspaceApps: ["web"],
|
|
83
|
+
workspace: {
|
|
84
|
+
file: "akan.config.ts",
|
|
85
|
+
sha256: sha,
|
|
86
|
+
pipeline: { after: [{ name: "E2E", matrix: "apps", sh: "bun run e2e" }] },
|
|
87
|
+
},
|
|
88
|
+
apps: new Map([["web", { file: "apps/web/akan.config.ts", sha256: sha, pipeline: null }]]),
|
|
89
|
+
akanVersion: "3.0.3",
|
|
90
|
+
});
|
|
91
|
+
expect(PipelineDryRun.render(result)).toBe(
|
|
92
|
+
[
|
|
93
|
+
"plan v1 · branch develop · env develop · trigger push · apps web",
|
|
94
|
+
"from akan.config.ts, apps/web/akan.config.ts (default)",
|
|
95
|
+
"",
|
|
96
|
+
"prepare (executor) checkout · env · install · plan",
|
|
97
|
+
"build build Build web action build",
|
|
98
|
+
"package docker Docker web action docker",
|
|
99
|
+
"deploy deploy Deploy → web (one at a time)",
|
|
100
|
+
"finish e2e E2E web bun run e2e",
|
|
101
|
+
"finish (executor) cleanup",
|
|
102
|
+
].join("\n"),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
79
106
|
test("a failed plan prints its diagnostics", () => {
|
|
80
107
|
const rendered = PipelineDryRun.render({
|
|
81
108
|
ok: false,
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
PipelinePlanStage,
|
|
7
7
|
} from "akanjs/common";
|
|
8
8
|
|
|
9
|
-
/** A plan as a person reads it: one line per phase in run order, the executor's own work included.
|
|
9
|
+
/** A plan as a person reads it: one line per phase in run order, the executor's own work included. */
|
|
10
10
|
export class PipelineDryRun {
|
|
11
11
|
static render(result: PipelinePlanResult) {
|
|
12
12
|
const lines = result.ok ? PipelineDryRun.#plan(result.plan) : ["no plan: the definition has errors"];
|
|
@@ -61,7 +61,7 @@ export class PipelineDryRun {
|
|
|
61
61
|
const command = stage?.steps
|
|
62
62
|
.map((step) => (step.kind === "sh" ? step.name : `action ${step.action}`))
|
|
63
63
|
.join(" · ");
|
|
64
|
-
return [labels[0], command].filter((part) => part && part !==
|
|
64
|
+
return [labels[0], command].filter((part) => part && part !== phase.name).join(" ");
|
|
65
65
|
}
|
|
66
66
|
return `‖ ${labels.join(" · ")}`;
|
|
67
67
|
}
|