@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
|
@@ -16,34 +16,126 @@ const fakeBucket = (held: string[] = []) => {
|
|
|
16
16
|
return { bucket, writes };
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
const sha = (char: string) => char.repeat(64);
|
|
20
|
+
|
|
21
|
+
const inUpdatesDir = async (files: Record<string, string>, run: (updatesDir: string) => Promise<void>) => {
|
|
22
|
+
const updatesDir = await mkdtemp(path.join(tmpdir(), "akan-update-upload-"));
|
|
23
|
+
try {
|
|
24
|
+
for (const [file, content] of Object.entries(files)) {
|
|
25
|
+
await mkdir(path.dirname(path.join(updatesDir, file)), { recursive: true });
|
|
26
|
+
await writeFile(path.join(updatesDir, file), content);
|
|
27
|
+
}
|
|
28
|
+
await run(updatesDir);
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(updatesDir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const envelope = (manifest: object) =>
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
manifest: Buffer.from(JSON.stringify(manifest)).toString("base64"),
|
|
37
|
+
keyId: "0011223344556677",
|
|
38
|
+
signature: "c2lnbmF0dXJl",
|
|
39
|
+
});
|
|
40
|
+
|
|
19
41
|
describe("UpdateUploader", () => {
|
|
20
42
|
test("reads s3://bucket/prefix and refuses anything else", () => {
|
|
21
|
-
expect(UpdateUploader.parseTarget("s3://releases/
|
|
43
|
+
expect(UpdateUploader.parseTarget("s3://releases/acme/kiosk/")).toEqual({
|
|
22
44
|
bucket: "releases",
|
|
23
|
-
prefix: "
|
|
45
|
+
prefix: "acme/kiosk",
|
|
24
46
|
});
|
|
25
47
|
expect(UpdateUploader.parseTarget("s3://releases")).toEqual({ bucket: "releases", prefix: "" });
|
|
26
|
-
expect(() => UpdateUploader.parseTarget("https://releases.example/
|
|
48
|
+
expect(() => UpdateUploader.parseTarget("https://releases.example/acme")).toThrow("s3://<bucket>/<prefix>");
|
|
27
49
|
});
|
|
28
50
|
|
|
29
|
-
test("uploads
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
test("uploads a desktop release's archives the bucket lacks first, then the signature before the manifest", async () => {
|
|
52
|
+
const manifest = {
|
|
53
|
+
bundle: "1.2.0-2000",
|
|
54
|
+
archive: { url: `app/${sha("b")}.tar.gz` },
|
|
55
|
+
patches: [{ url: `app/${sha("a")}-${sha("b")}.delta.gz` }],
|
|
56
|
+
};
|
|
57
|
+
await inUpdatesDir(
|
|
58
|
+
{
|
|
59
|
+
[`windows-x64/app/${sha("a")}.tar.gz`]: "previous release",
|
|
60
|
+
[`windows-x64/app/${sha("b")}.tar.gz`]: "tar",
|
|
61
|
+
[`windows-x64/app/${sha("a")}-${sha("b")}.delta.gz`]: "delta",
|
|
62
|
+
"windows-x64/main.json": JSON.stringify(manifest),
|
|
63
|
+
"windows-x64/main.json.sig": "sig",
|
|
64
|
+
"windows-x64/main.json.tmp": "{}",
|
|
65
|
+
"windows-x64/develop.json": JSON.stringify({ ...manifest, bundle: "1.1.0-1000" }),
|
|
66
|
+
"windows-x64/develop.json.sig": "sig",
|
|
67
|
+
},
|
|
68
|
+
async (updatesDir) => {
|
|
69
|
+
const { bucket, writes } = fakeBucket([`acme/windows-x64/app/${sha("a")}-${sha("b")}.delta.gz`]);
|
|
70
|
+
const release = { dir: path.join(updatesDir, "windows-x64"), channel: "main", bundle: "1.2.0-2000" };
|
|
71
|
+
|
|
72
|
+
expect(await new UpdateUploader(bucket, "acme").upload(updatesDir, release)).toEqual({
|
|
73
|
+
uploaded: 3,
|
|
74
|
+
skipped: 1,
|
|
75
|
+
});
|
|
76
|
+
expect(writes).toEqual([
|
|
77
|
+
{ key: `acme/windows-x64/app/${sha("b")}.tar.gz` },
|
|
78
|
+
{ key: "acme/windows-x64/main.json.sig", type: "text/plain" },
|
|
79
|
+
{ key: "acme/windows-x64/main.json", type: "application/json" },
|
|
80
|
+
]);
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("uploads a phone release's own files and envelope, never pack records or another manifest beside them", async () => {
|
|
86
|
+
await inUpdatesDir(
|
|
87
|
+
{
|
|
88
|
+
"android/pack.json": JSON.stringify({ out: "/Users/someone/work/dist/native/demo/default/updates/android" }),
|
|
89
|
+
"android/manifest.template.json": "{}",
|
|
90
|
+
"android/bundle.json": "{}",
|
|
91
|
+
"android/compat.json": "{}",
|
|
92
|
+
[`android/files/${sha("a")}`]: "index.html",
|
|
93
|
+
[`android/files/${sha("b")}`]: "env.runtime.json",
|
|
94
|
+
[`android/files/${sha("c")}`]: "an earlier pack's file",
|
|
95
|
+
"android/main/api2.json": envelope({
|
|
96
|
+
bundle: "3000-aaaaaaaa",
|
|
97
|
+
files: [{ sha256: sha("a") }, { sha256: sha("b") }, { sha256: sha("a") }],
|
|
98
|
+
}),
|
|
99
|
+
"android/main/api1.json": envelope({ bundle: "1000-cccccccc", files: [{ sha256: sha("c") }] }),
|
|
100
|
+
"android/develop/api2.json": envelope({ bundle: "2000-bbbbbbbb", files: [{ sha256: sha("b") }] }),
|
|
101
|
+
},
|
|
102
|
+
async (updatesDir) => {
|
|
103
|
+
const { bucket, writes } = fakeBucket();
|
|
104
|
+
const release = { dir: path.join(updatesDir, "android"), channel: "main", bundle: "3000-aaaaaaaa" };
|
|
105
|
+
|
|
106
|
+
expect(await new UpdateUploader(bucket, "").upload(updatesDir, release)).toEqual({ uploaded: 3, skipped: 0 });
|
|
107
|
+
expect(writes).toEqual([
|
|
108
|
+
{ key: `android/files/${sha("a")}` },
|
|
109
|
+
{ key: `android/files/${sha("b")}` },
|
|
110
|
+
{ key: "android/main/api2.json", type: "application/json" },
|
|
111
|
+
]);
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("uploads nothing when the release is not there or names a file outside its folder", async () => {
|
|
117
|
+
await inUpdatesDir(
|
|
118
|
+
{
|
|
119
|
+
"linux-x64/main.json": JSON.stringify({ bundle: "1.0.0-10", archive: { url: "../../.ssh/id_ed25519" } }),
|
|
120
|
+
"linux-x64/main.json.sig": "sig",
|
|
121
|
+
"ios/main/api1.json": envelope({ bundle: "10-aaaaaaaa", files: [{ sha256: "../pack.json" }] }),
|
|
122
|
+
},
|
|
123
|
+
async (updatesDir) => {
|
|
124
|
+
const { bucket, writes } = fakeBucket();
|
|
125
|
+
const uploader = new UpdateUploader(bucket, "acme");
|
|
126
|
+
const linux = path.join(updatesDir, "linux-x64");
|
|
127
|
+
|
|
128
|
+
await expect(uploader.upload(updatesDir, { dir: linux, channel: "main", bundle: "1.0.0-9" })).rejects.toThrow(
|
|
129
|
+
"holds no main manifest of release 1.0.0-9",
|
|
130
|
+
);
|
|
131
|
+
await expect(uploader.upload(updatesDir, { dir: linux, channel: "main", bundle: "1.0.0-10" })).rejects.toThrow(
|
|
132
|
+
"which is not inside that folder",
|
|
133
|
+
);
|
|
134
|
+
await expect(
|
|
135
|
+
uploader.upload(updatesDir, { dir: path.join(updatesDir, "ios"), channel: "main", bundle: "10-aaaaaaaa" }),
|
|
136
|
+
).rejects.toThrow("not 64 hex digits");
|
|
137
|
+
expect(writes).toEqual([]);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
48
140
|
});
|
|
49
141
|
});
|
package/mobile/updateUploader.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
3
|
+
import type { UpdateEnvelope, UpdateManifest } from "@akanjs/native/api";
|
|
4
|
+
import { isRecord } from "akanjs/common";
|
|
5
|
+
import { UpdatePackFolder } from "./updatePackFolder";
|
|
2
6
|
|
|
3
7
|
/** What the uploader needs of an S3 client; Bun.S3Client is one. */
|
|
4
8
|
export interface UpdateBucket {
|
|
@@ -11,6 +15,15 @@ export interface UpdateUploadResult {
|
|
|
11
15
|
skipped: number;
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
/** The release one `publish-update` wrote, as it reports it. */
|
|
19
|
+
export interface PublishedUpdate {
|
|
20
|
+
/** `<updates>/<os>-<arch>` for a desktop app, `<updates>/<platform>` for a phone's web bundle. */
|
|
21
|
+
dir: string;
|
|
22
|
+
channel: string;
|
|
23
|
+
/** Its manifest's `bundle`, which tells it from the other manifests of the channel there. */
|
|
24
|
+
bundle: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
//* A manifest names files that must already be there, so they go first and the manifests last, each signature before
|
|
15
28
|
//* its manifest as `publish-update` writes them. A release file never changes once published (its name carries its
|
|
16
29
|
//* hash), so one the bucket holds is skipped; a manifest is always written.
|
|
@@ -34,6 +47,64 @@ export class UpdateUploader {
|
|
|
34
47
|
};
|
|
35
48
|
}
|
|
36
49
|
|
|
50
|
+
//* Never the whole folder: pack-update's records sit there too, and so do manifests of other channels and native
|
|
51
|
+
//* APIs, which a later publish from another machine may have replaced.
|
|
52
|
+
static async releaseFiles(updatesDir: string, { dir, channel, bundle }: PublishedUpdate): Promise<string[]> {
|
|
53
|
+
const keyOf = (file: unknown) => UpdateUploader.#keyOf(updatesDir, dir, file);
|
|
54
|
+
const desktop = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
|
|
55
|
+
if (desktop?.bundle === bundle) {
|
|
56
|
+
const archive = desktop.archive?.url;
|
|
57
|
+
const patches = UpdateUploader.#field(desktop.patches, "url");
|
|
58
|
+
return [archive, ...patches, `${channel}.json.sig`, `${channel}.json`].map(keyOf);
|
|
59
|
+
}
|
|
60
|
+
const names = await readdir(path.join(dir, channel)).catch(() => [] as string[]);
|
|
61
|
+
for (const name of names.filter((name) => name.endsWith(".json"))) {
|
|
62
|
+
const envelope = await UpdateUploader.#read<UpdateEnvelope>(path.join(dir, channel, name));
|
|
63
|
+
const manifest =
|
|
64
|
+
typeof envelope?.manifest === "string"
|
|
65
|
+
? UpdateUploader.#parse<UpdateManifest>(Buffer.from(envelope.manifest, "base64").toString("utf8"))
|
|
66
|
+
: null;
|
|
67
|
+
if (manifest?.bundle !== bundle) continue;
|
|
68
|
+
const hashes = [...new Set(UpdateUploader.#field(manifest.files, "sha256"))];
|
|
69
|
+
if (!hashes.every((hash): hash is string => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)))
|
|
70
|
+
throw new Error(
|
|
71
|
+
`${path.join(dir, channel, name)} lists a file whose sha256 is not 64 hex digits; nothing was uploaded.`,
|
|
72
|
+
);
|
|
73
|
+
return [...hashes.map((hash) => `${UpdatePackFolder.filesDir}/${hash}`), `${channel}/${name}`].map(keyOf);
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`${dir} holds no ${channel} manifest of release ${bundle}; nothing was uploaded.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static #keyOf(updatesDir: string, dir: string, file: unknown) {
|
|
79
|
+
const resolved = typeof file === "string" ? path.resolve(dir, file) : "";
|
|
80
|
+
const key = path.relative(updatesDir, resolved);
|
|
81
|
+
if (!resolved.startsWith(`${path.resolve(dir)}${path.sep}`) || key.startsWith("..") || path.isAbsolute(key))
|
|
82
|
+
throw new Error(
|
|
83
|
+
`The release in ${dir} names ${typeof file === "string" ? JSON.stringify(file) : "a file with no path"}, which is not inside that folder; nothing was uploaded.`,
|
|
84
|
+
);
|
|
85
|
+
return key.split(path.sep).join("/");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static #field(list: unknown, key: string): unknown[] {
|
|
89
|
+
return Array.isArray(list) ? list.map((item) => (isRecord(item) ? item[key] : undefined)) : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static async #read<T>(file: string) {
|
|
93
|
+
const text = await Bun.file(file)
|
|
94
|
+
.text()
|
|
95
|
+
.catch(() => "");
|
|
96
|
+
return UpdateUploader.#parse<T>(text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static #parse<T>(text: string): Partial<T> | null {
|
|
100
|
+
try {
|
|
101
|
+
const value: unknown = JSON.parse(text);
|
|
102
|
+
return isRecord(value) ? (value as Partial<T>) : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
37
108
|
constructor(
|
|
38
109
|
readonly bucket: UpdateBucket,
|
|
39
110
|
readonly prefix: string,
|
|
@@ -44,12 +115,8 @@ export class UpdateUploader {
|
|
|
44
115
|
return new UpdateUploader(new Bun.S3Client({ bucket }), prefix);
|
|
45
116
|
}
|
|
46
117
|
|
|
47
|
-
async upload(
|
|
48
|
-
const
|
|
49
|
-
for await (const file of new Bun.Glob("**/*").scan({ cwd: dir, onlyFiles: true })) {
|
|
50
|
-
if (!file.endsWith(".tmp")) files.push(file.split(path.sep).join("/"));
|
|
51
|
-
}
|
|
52
|
-
const { releases, manifests } = UpdateUploader.order(files);
|
|
118
|
+
async upload(updatesDir: string, release: PublishedUpdate): Promise<UpdateUploadResult> {
|
|
119
|
+
const { releases, manifests } = UpdateUploader.order(await UpdateUploader.releaseFiles(updatesDir, release));
|
|
53
120
|
const keyOf = (file: string) => (this.prefix ? `${this.prefix}/${file}` : file);
|
|
54
121
|
let uploaded = 0;
|
|
55
122
|
let skipped = 0;
|
|
@@ -58,12 +125,12 @@ export class UpdateUploader {
|
|
|
58
125
|
skipped++;
|
|
59
126
|
continue;
|
|
60
127
|
}
|
|
61
|
-
await this.bucket.write(keyOf(file), Bun.file(path.join(
|
|
128
|
+
await this.bucket.write(keyOf(file), Bun.file(path.join(updatesDir, file)));
|
|
62
129
|
uploaded++;
|
|
63
130
|
}
|
|
64
131
|
for (const file of manifests) {
|
|
65
132
|
const type = file.endsWith(".json") ? "application/json" : "text/plain";
|
|
66
|
-
await this.bucket.write(keyOf(file), Bun.file(path.join(
|
|
133
|
+
await this.bucket.write(keyOf(file), Bun.file(path.join(updatesDir, file)), { type });
|
|
67
134
|
uploaded++;
|
|
68
135
|
}
|
|
69
136
|
return { uploaded, skipped };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.0-rc.
|
|
3
|
+
"version": "3.1.0-rc.3",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "0.86.1",
|
|
49
49
|
"@inquirer/prompts": "^8.4.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.0",
|
|
51
|
-
"akanjs": "3.1.0-rc.
|
|
51
|
+
"akanjs": "3.1.0-rc.3",
|
|
52
52
|
"chalk": "^5.6.2",
|
|
53
53
|
"commander": "^14.0.3",
|
|
54
54
|
"dayjs": "^1.11.20",
|
|
@@ -337,7 +337,28 @@ describe("PipelineCompiler", () => {
|
|
|
337
337
|
]);
|
|
338
338
|
const e2e = plan.phases.at(-1);
|
|
339
339
|
expect(e2e).toMatchObject({ id: "e2e", timeoutSeconds: 3600, milestone: "finish" });
|
|
340
|
-
expect(e2e?.stages.map((stage) => [stage.id, stage.name, stage.app])).toEqual([["e2e
|
|
340
|
+
expect(e2e?.stages.map((stage) => [stage.id, stage.name, stage.app])).toEqual([["e2e.apps.api", "api", "api"]]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("a matrix copy's id never collides with an app's own stage or a post step", () => {
|
|
344
|
+
const stageIds = (plan: PipelinePlan) => plan.phases.flatMap((phase) => phase.stages.map((stage) => stage.id));
|
|
345
|
+
const plan = planOf(
|
|
346
|
+
compile({
|
|
347
|
+
workspace: { after: [{ name: "Web", matrix: "apps", sh: "bun run e2e $AKAN_CI_APP" }] },
|
|
348
|
+
apps: { web: { stages: [{ use: "build" }, { name: "API", sh: "bun run test:api" }] } },
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
expect(stageIds(plan)).toEqual(expect.arrayContaining(["web:api", "web.apps.api", "web.apps.web"]));
|
|
352
|
+
expect(new Set(stageIds(plan)).size).toBe(stageIds(plan).length);
|
|
353
|
+
const post = planOf(
|
|
354
|
+
compile({
|
|
355
|
+
ctx: context({ apps: ["success"] }),
|
|
356
|
+
workspaceApps: ["success"],
|
|
357
|
+
workspace: { after: [{ name: "Post", matrix: "apps", sh: "x" }], post: { success: ["y"] } },
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
expect(stageIds(post)).toContain("post.apps.success");
|
|
361
|
+
expect(post.post.success.map((step) => step.id)).toEqual(["post.success/1"]);
|
|
341
362
|
});
|
|
342
363
|
|
|
343
364
|
test("env is workspace, then app, then stage, then step; secrets are each step's union", () => {
|
|
@@ -571,6 +592,10 @@ describe("PipelineCompiler", () => {
|
|
|
571
592
|
expect(codes(appResult({ stages: [{ name: "x", sh: "x", secrets: ["lower"] }] }))).toContain(
|
|
572
593
|
"pipeline.secret.name",
|
|
573
594
|
);
|
|
595
|
+
const pasted = appResult({ stages: [{ name: "x", sh: "x", secrets: ["NPM_TOKEN", "npm_live_value"] }] });
|
|
596
|
+
const secretName = pasted.diagnostics.find((diagnostic) => diagnostic.code === "pipeline.secret.name");
|
|
597
|
+
expect(secretName?.path).toEndWith(".secrets[1]");
|
|
598
|
+
expect(secretName?.message).not.toContain("npm_live_value");
|
|
574
599
|
expect(codes(appResult({ stages: [{ name: "x", steps: [{ sh: "x", cwd: "../up" }] }] }))).toContain(
|
|
575
600
|
"pipeline.cwd.outside",
|
|
576
601
|
);
|
|
@@ -604,6 +629,30 @@ describe("PipelineCompiler", () => {
|
|
|
604
629
|
);
|
|
605
630
|
});
|
|
606
631
|
|
|
632
|
+
test("a parallel branch may not take the id of a post list's steps", () => {
|
|
633
|
+
const refused = compile({
|
|
634
|
+
workspace: { before: [{ name: "Post", parallel: [{ name: "Success", sh: "x" }] }], post: { success: ["y"] } },
|
|
635
|
+
});
|
|
636
|
+
expect(refused.ok).toBe(false);
|
|
637
|
+
expect(refused.diagnostics).toEqual([
|
|
638
|
+
expect.objectContaining({
|
|
639
|
+
code: "pipeline.id.reserved",
|
|
640
|
+
file: "akan.config.ts",
|
|
641
|
+
path: "pipeline.before[0].parallel[0].name",
|
|
642
|
+
}),
|
|
643
|
+
]);
|
|
644
|
+
expect(
|
|
645
|
+
compile({
|
|
646
|
+
workspace: { after: [{ id: "post", name: "Checks", parallel: [{ id: "always", name: "A", sh: "x" }] }] },
|
|
647
|
+
}).diagnostics,
|
|
648
|
+
).toEqual([expect.objectContaining({ code: "pipeline.id.reserved", path: "pipeline.after[0].parallel[0].id" })]);
|
|
649
|
+
const otherBranch = compile({
|
|
650
|
+
workspace: { before: [{ name: "Post", parallel: [{ name: "Lint", sh: "x" }] }], post: { success: ["y"] } },
|
|
651
|
+
});
|
|
652
|
+
expect(otherBranch.ok).toBe(true);
|
|
653
|
+
expect(compile({ workspace: { after: [{ name: "Post", sh: "x" }], post: { success: ["y"] } } }).ok).toBe(true);
|
|
654
|
+
});
|
|
655
|
+
|
|
607
656
|
test("loading and sampling", () => {
|
|
608
657
|
const thrown = appResult(() => {
|
|
609
658
|
throw new Error("boom");
|
|
@@ -620,6 +669,113 @@ describe("PipelineCompiler", () => {
|
|
|
620
669
|
expect(codes(sampled)).toContain("pipeline.function.sampled");
|
|
621
670
|
});
|
|
622
671
|
|
|
672
|
+
test("a function returning nothing, an async one, and a falsy pipeline are errors, never a default", async () => {
|
|
673
|
+
const refused = (result: PipelinePlanResult, file: string) => {
|
|
674
|
+
expect(result.ok).toBe(false);
|
|
675
|
+
expect(result.diagnostics).toContainEqual(
|
|
676
|
+
expect.objectContaining({ severity: "error", code: "pipeline.schema.invalid", file, path: "pipeline" }),
|
|
677
|
+
);
|
|
678
|
+
return result.diagnostics.map((diagnostic) => diagnostic.message).join("\n");
|
|
679
|
+
};
|
|
680
|
+
const onMainOnly = ((ctx: { branch: string }) =>
|
|
681
|
+
ctx.branch === "main" ? { stages: [{ use: "build" }] } : undefined) as unknown as AppPipelineConfig;
|
|
682
|
+
expect(appResult(onMainOnly).ok).toBe(true);
|
|
683
|
+
const develop = compile({ ctx: context({ branch: "develop", apps: ["web"] }), apps: { web: onMainOnly } });
|
|
684
|
+
expect(refused(develop, "apps/web/akan.config.ts")).toContain("returned nothing");
|
|
685
|
+
expect(refused(compile({ workspace: (() => undefined) as never }), "akan.config.ts")).toContain(
|
|
686
|
+
"returned nothing",
|
|
687
|
+
);
|
|
688
|
+
expect(refused(compile({ workspace: (async () => ({ before: [] })) as never }), "akan.config.ts")).toContain(
|
|
689
|
+
"Promise",
|
|
690
|
+
);
|
|
691
|
+
const rejecting = (async () => {
|
|
692
|
+
throw new Error("boom");
|
|
693
|
+
}) as unknown as AppPipelineConfig;
|
|
694
|
+
expect(refused(appResult(rejecting), "apps/web/akan.config.ts")).toContain("Promise");
|
|
695
|
+
await Bun.sleep(1);
|
|
696
|
+
expect(refused(appResult(false as never), "apps/web/akan.config.ts")).toContain("must be an object");
|
|
697
|
+
expect(refused(compile({ workspace: 0 as never }), "akan.config.ts")).toContain("must be an object");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("a plan larger than an executor reads, as written, is refused", () => {
|
|
701
|
+
const steps = Array.from({ length: 20 }, () => "x".repeat(60_000));
|
|
702
|
+
const result = appResult({ stages: [{ use: "build" }, { name: "Big", steps }] });
|
|
703
|
+
expect(result.ok).toBe(false);
|
|
704
|
+
expect(result.diagnostics).toContainEqual(
|
|
705
|
+
expect.objectContaining({ code: "pipeline.limit.exceeded", file: null, path: null }),
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("conditions", () => {
|
|
710
|
+
expect(appResult({ stages: [{ use: "build", when: { trigger: [] } }] }).diagnostics).toContainEqual(
|
|
711
|
+
expect.objectContaining({ code: "pipeline.schema.invalid", path: "pipeline.stages[0].when.trigger" }),
|
|
712
|
+
);
|
|
713
|
+
const branch = Array.from({ length: 6 }, (_, idx) => `release/${String(idx).repeat(240)}`);
|
|
714
|
+
const plan = planOf(
|
|
715
|
+
compile({
|
|
716
|
+
ctx: context({ branch: "develop", env: "develop", apps: ["web"] }),
|
|
717
|
+
apps: { web: { stages: [{ use: "build", when: { branch } }] } },
|
|
718
|
+
}),
|
|
719
|
+
);
|
|
720
|
+
const reason = plan.phases[0]?.stages[0]?.skip?.reason ?? "";
|
|
721
|
+
expect(reason.startsWith('branch "develop" does not match "release/')).toBe(true);
|
|
722
|
+
expect(reason.endsWith("…")).toBe(true);
|
|
723
|
+
expect(reason.length).toBeLessThanOrEqual(1024);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("a value the plan would refuse is reported where it was written", () => {
|
|
727
|
+
expect(
|
|
728
|
+
appResult({ stages: [{ use: "build" }, { name: "x", sh: "x", env: { BIG: "x".repeat(64 * 1024 + 1) } }] })
|
|
729
|
+
.diagnostics,
|
|
730
|
+
).toContainEqual(
|
|
731
|
+
expect.objectContaining({ code: "pipeline.limit.exceeded", path: "pipeline.stages[1].env.BIG" }),
|
|
732
|
+
);
|
|
733
|
+
const unset = compile({ workspace: { before: [{ name: "Checks", parallel: undefined as never }] } });
|
|
734
|
+
expect(unset.ok).toBe(false);
|
|
735
|
+
expect(unset.diagnostics).toEqual([
|
|
736
|
+
expect.objectContaining({
|
|
737
|
+
code: "pipeline.schema.invalid",
|
|
738
|
+
file: "akan.config.ts",
|
|
739
|
+
path: "pipeline.before[0].parallel",
|
|
740
|
+
}),
|
|
741
|
+
]);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("a key set to undefined is the key left out", () => {
|
|
745
|
+
const stage = { name: "Test", sh: "bun test", use: undefined, parallel: undefined, matrix: undefined };
|
|
746
|
+
const plan = planOf(
|
|
747
|
+
compile({
|
|
748
|
+
workspace: {
|
|
749
|
+
before: [stage as never, { name: "Checks", parallel: [{ name: "Lint", sh: "x", use: undefined }] }],
|
|
750
|
+
},
|
|
751
|
+
apps: { web: { stages: [{ use: "build", targets: undefined } as never, stage as never] } },
|
|
752
|
+
}),
|
|
753
|
+
);
|
|
754
|
+
expect(phaseIds(plan)).toEqual(["test", "checks", "build", "after-build", "docker", "deploy"]);
|
|
755
|
+
expect(appResult({ stages: [{ use: undefined } as never] }).diagnostics).toContainEqual(
|
|
756
|
+
expect.objectContaining({ code: "pipeline.schema.invalid", path: "pipeline.stages[0].use" }),
|
|
757
|
+
);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("a value JSON cannot hold is reported, never thrown", () => {
|
|
761
|
+
const cyclic: Record<string, unknown> = {};
|
|
762
|
+
cyclic.self = cyclic;
|
|
763
|
+
const result = appResult({ stages: [{ use: "build", timeout: 10n as never }, { use: cyclic as never }] });
|
|
764
|
+
expect(result.ok).toBe(false);
|
|
765
|
+
expect(result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.message.split(" ")[0]])).toEqual([
|
|
766
|
+
["pipeline.duration.invalid", "10n"],
|
|
767
|
+
["pipeline.schema.invalid", "[object"],
|
|
768
|
+
]);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("a long first line names its step without splitting a surrogate pair", () => {
|
|
772
|
+
const plan = planOf(
|
|
773
|
+
appResult({ stages: [{ use: "build" }, { name: "Emoji", sh: `${"a".repeat(78)}${"😀".repeat(5)}` }] }),
|
|
774
|
+
);
|
|
775
|
+
const name = plan.phases.find((phase) => phase.id === "after-build")?.stages[0]?.steps[0]?.name;
|
|
776
|
+
expect(name).toBe(`${"a".repeat(78)}…`);
|
|
777
|
+
});
|
|
778
|
+
|
|
623
779
|
test("milestones that move backwards are a warning", () => {
|
|
624
780
|
const result = appResult({
|
|
625
781
|
stages: [{ use: "build" }, { use: "docker" }, { name: "Late", sh: "x", milestone: "verify" }],
|