@platforma-sdk/block-tools 2.11.10 → 2.12.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/dist/cli.js +13 -13
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +845 -625
- package/dist/cli.mjs.map +1 -1
- package/dist/cmd/software/build.d.ts +2 -0
- package/dist/cmd/software/ecr-login.d.ts +3 -0
- package/dist/cmd/software/env.d.ts +5 -0
- package/dist/cmd/software/index.d.ts +2 -0
- package/dist/cmd/software/knobs.d.ts +26 -0
- package/dist/cmd/software/run-build.d.ts +19 -0
- package/dist/structure/engine/version.d.ts +5 -4
- package/dist/structure/rules/root-package-json.d.ts +1 -2
- package/dist/structure/rules/root-pnpm-workspace.d.ts +7 -1
- package/package.json +7 -5
- package/src/cli.test.ts +20 -1
- package/src/cli.ts +2 -0
- package/src/cmd/software/build.ts +175 -0
- package/src/cmd/software/ecr-login.test.ts +92 -0
- package/src/cmd/software/ecr-login.ts +75 -0
- package/src/cmd/software/env.test.ts +17 -0
- package/src/cmd/software/env.ts +22 -0
- package/src/cmd/software/index.ts +9 -0
- package/src/cmd/software/knobs.test.ts +52 -0
- package/src/cmd/software/knobs.ts +61 -0
- package/src/cmd/software/run-build.test.ts +291 -0
- package/src/cmd/software/run-build.ts +156 -0
- package/src/structure/__tests__/floor-version.test.ts +2 -2
- package/src/structure/engine/__tests__/version.test.ts +14 -17
- package/src/structure/engine/discovery-fs.ts +4 -13
- package/src/structure/engine/runner.ts +4 -4
- package/src/structure/engine/version.ts +11 -13
- package/src/structure/rules/migrations.ts +1 -1
- package/src/structure/rules/root-package-json.ts +38 -14
- package/src/structure/rules/root-pnpm-workspace.ts +8 -1
- package/src/structure/rules/root.ts +1 -1
- package/src/structure/rules/software-package-json.ts +23 -10
- package/src/structure/templates/static/root/turbo.json +15 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { util } from "@platforma-sdk/package-builder-lib";
|
|
2
|
+
|
|
3
|
+
export const channels = ["dev", "release"] as const;
|
|
4
|
+
export const variants = ["docker", "binary", "all", "none"] as const;
|
|
5
|
+
export const locations = ["local", "remote"] as const;
|
|
6
|
+
|
|
7
|
+
export type Channel = (typeof channels)[number];
|
|
8
|
+
export type Variant = (typeof variants)[number];
|
|
9
|
+
export type Location = (typeof locations)[number];
|
|
10
|
+
|
|
11
|
+
// Raw CLI flags, each independently optional; parseScenario narrows them to a supported Scenario.
|
|
12
|
+
export type Knobs = {
|
|
13
|
+
channel?: Channel;
|
|
14
|
+
variant?: Variant;
|
|
15
|
+
location?: Location;
|
|
16
|
+
usePublished?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// The variant a resolved target actually builds — "none" is its own scenario, never a target.
|
|
20
|
+
export type TargetVariant = Exclude<Variant, "none">;
|
|
21
|
+
|
|
22
|
+
// The closed set of scenarios runBuild executes; parseScenario rejects anything else. `target`
|
|
23
|
+
// carries the resolved knobs as enums (not pre-derived booleans) — runBuild derives the flags.
|
|
24
|
+
export type Scenario =
|
|
25
|
+
// `build:dev-binary-existing` (PL_BUILD_USE_PUBLISHED): reference the published binary; no build/push.
|
|
26
|
+
| { kind: "binary-existing" }
|
|
27
|
+
// `build:dev-no-software` (PL_BUILD_VARIANT=none): placeholder descriptors; nothing built or pushed.
|
|
28
|
+
| { kind: "no-software" }
|
|
29
|
+
// no build:* script — the scriptless default (direct call / `do-pack`); drop-in `pl-pkg build`.
|
|
30
|
+
| { kind: "bare" }
|
|
31
|
+
// `build:dev-local` / `build:dev-remote` / `build:release` / `test`: build for a target.
|
|
32
|
+
| { kind: "target"; channel: Channel; variant: TargetVariant; location: Location };
|
|
33
|
+
|
|
34
|
+
// Collapse the raw knobs to a supported Scenario; the one place bad combinations are rejected.
|
|
35
|
+
export function parseScenario(knobs: Knobs): Scenario {
|
|
36
|
+
if (knobs.usePublished) {
|
|
37
|
+
// A published binary can be referenced; docker (and `all`, which includes it) cannot.
|
|
38
|
+
if (knobs.variant === "docker" || knobs.variant === "all") {
|
|
39
|
+
throw util.CLIError(
|
|
40
|
+
`--use-published is binary-only and cannot be combined with --variant ${knobs.variant}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return { kind: "binary-existing" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (knobs.variant === "none") {
|
|
47
|
+
return { kind: "no-software" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (knobs.location === undefined) {
|
|
51
|
+
return { kind: "bare" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `all` (the default) builds every declared variant; the engine skips the kinds the software omits.
|
|
55
|
+
return {
|
|
56
|
+
kind: "target",
|
|
57
|
+
channel: knobs.channel ?? "release",
|
|
58
|
+
variant: knobs.variant ?? "all",
|
|
59
|
+
location: knobs.location,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { runBuild } from "./run-build";
|
|
3
|
+
import { parseScenario, type Knobs } from "./knobs";
|
|
4
|
+
import { defaults, type Builder } from "@platforma-sdk/package-builder-lib";
|
|
5
|
+
import { ensureEcrLogin, ensureAwsProfile } from "./ecr-login";
|
|
6
|
+
|
|
7
|
+
// Neutralize the real docker/aws shelling; assert only that the orchestration calls it.
|
|
8
|
+
vi.mock("./ecr-login", () => ({ ensureEcrLogin: vi.fn(), ensureAwsProfile: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
// A Builder stub that records which engine methods ran, in order.
|
|
11
|
+
function fakeBuilder() {
|
|
12
|
+
const calls: string[] = [];
|
|
13
|
+
const rec = (name: string) => (_opts?: unknown) => {
|
|
14
|
+
calls.push(name);
|
|
15
|
+
};
|
|
16
|
+
const core = {
|
|
17
|
+
buildMode: "release",
|
|
18
|
+
isPrivate: false,
|
|
19
|
+
buildDockerImages: vi.fn(rec("buildDockerImages")),
|
|
20
|
+
writePublishedArtifactInfo: vi.fn(rec("writePublishedArtifactInfo")),
|
|
21
|
+
buildSoftwareArchives: vi.fn(async () => void calls.push("buildSoftwareArchives")),
|
|
22
|
+
publishDockerImages: vi.fn(rec("publishDockerImages")),
|
|
23
|
+
publishPackages: vi.fn(async () => void calls.push("publishPackages")),
|
|
24
|
+
buildSwJsonFiles: vi.fn(rec("buildSwJsonFiles")),
|
|
25
|
+
};
|
|
26
|
+
return { core: core as unknown as Builder, calls, spies: core };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const run = (knobs: Knobs, b = fakeBuilder()) =>
|
|
30
|
+
runBuild({ core: b.core, scenario: parseScenario(knobs) }).then(() => b);
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const k of [
|
|
34
|
+
"CI",
|
|
35
|
+
"PL_DEV_DOCKER_PUSH_URL",
|
|
36
|
+
"PL_DEV_DOCKER_PULL_URL",
|
|
37
|
+
"PL_RELEASE_DOCKER_PUSH_URL",
|
|
38
|
+
"PL_RELEASE_DOCKER_PULL_URL",
|
|
39
|
+
]) {
|
|
40
|
+
delete process.env[k];
|
|
41
|
+
}
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// What registry/pushTo the engine was called with for a docker target.
|
|
46
|
+
async function dockerAddresses(knobs: Knobs) {
|
|
47
|
+
const b = await run(knobs);
|
|
48
|
+
const reg = (fn: { mock: { calls: unknown[][] } }) =>
|
|
49
|
+
(fn.mock.calls[0]?.[0] as { registry?: string; pushTo?: string }) ?? {};
|
|
50
|
+
return {
|
|
51
|
+
pull: reg(b.spies.buildDockerImages).registry,
|
|
52
|
+
push: reg(b.spies.publishDockerImages).pushTo,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("runBuild orchestration", () => {
|
|
57
|
+
it("always writes the descriptor last", async () => {
|
|
58
|
+
for (const knobs of [
|
|
59
|
+
{},
|
|
60
|
+
{ channel: "dev", variant: "binary", location: "local" },
|
|
61
|
+
{ channel: "dev", variant: "binary", location: "remote" },
|
|
62
|
+
{ channel: "release", location: "remote" },
|
|
63
|
+
{ usePublished: true },
|
|
64
|
+
] as Knobs[]) {
|
|
65
|
+
const { calls } = await run(knobs);
|
|
66
|
+
expect(calls.at(-1)).toBe("buildSwJsonFiles");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("dev-binary-remote builds then uploads the binary, no docker", async () => {
|
|
71
|
+
const { calls } = await run({ channel: "dev", variant: "binary", location: "remote" });
|
|
72
|
+
expect(calls).toEqual(["buildSoftwareArchives", "publishPackages", "buildSwJsonFiles"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("dev-binary-local builds the binary but does not upload", async () => {
|
|
76
|
+
const { calls } = await run({ channel: "dev", variant: "binary", location: "local" });
|
|
77
|
+
expect(calls).toEqual(["buildSoftwareArchives", "buildSwJsonFiles"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("use-published writes the descriptor only — no build, no push", async () => {
|
|
81
|
+
const { calls } = await run({ usePublished: true });
|
|
82
|
+
expect(calls).toEqual(["writePublishedArtifactInfo", "buildSwJsonFiles"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("no-software writes a placeholder descriptor only — no build, no push", async () => {
|
|
86
|
+
const b = await run({ channel: "dev", variant: "none", location: "local" });
|
|
87
|
+
expect(b.calls).toEqual(["buildSwJsonFiles"]);
|
|
88
|
+
expect(b.spies.buildSwJsonFiles).toHaveBeenCalledWith(
|
|
89
|
+
expect.objectContaining({ noSoftware: true }),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("sets the scenario-derived build mode on the engine", async () => {
|
|
94
|
+
expect((await run({})).core.buildMode).toBe("release");
|
|
95
|
+
expect(
|
|
96
|
+
(await run({ channel: "dev", variant: "binary", location: "local" })).core.buildMode,
|
|
97
|
+
).toBe("dev-local");
|
|
98
|
+
expect(
|
|
99
|
+
(await run({ channel: "dev", variant: "binary", location: "remote" })).core.buildMode,
|
|
100
|
+
).toBe("dev-remote");
|
|
101
|
+
expect((await run({ usePublished: true })).core.buildMode).toBe("release");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("dev-docker-remote pushes docker (push before descriptor)", async () => {
|
|
105
|
+
const { calls } = await run({ channel: "dev", variant: "docker", location: "remote" });
|
|
106
|
+
expect(calls).toEqual(["buildDockerImages", "publishDockerImages", "buildSwJsonFiles"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("docker-local builds docker but does not push", async () => {
|
|
110
|
+
const { calls } = await run({ channel: "dev", variant: "docker", location: "local" });
|
|
111
|
+
expect(calls).toEqual(["buildDockerImages", "buildSwJsonFiles"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("no variant builds both kinds; local => no push", async () => {
|
|
115
|
+
const { calls } = await run({ channel: "dev", location: "local" });
|
|
116
|
+
expect(calls).toEqual(["buildDockerImages", "buildSoftwareArchives", "buildSwJsonFiles"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("release + remote builds and pushes both kinds in one pass, descriptor last", async () => {
|
|
120
|
+
const { calls } = await run({ channel: "release", location: "remote" });
|
|
121
|
+
expect(calls).toEqual([
|
|
122
|
+
"buildDockerImages",
|
|
123
|
+
"buildSoftwareArchives",
|
|
124
|
+
"publishDockerImages",
|
|
125
|
+
"publishPackages",
|
|
126
|
+
"buildSwJsonFiles",
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("dev docker build targets the built-in dev registry; release does not", async () => {
|
|
131
|
+
const dev = await run({ channel: "dev", variant: "docker", location: "remote" });
|
|
132
|
+
expect(dev.spies.buildDockerImages).toHaveBeenCalledWith(
|
|
133
|
+
expect.objectContaining({ registry: defaults.DEV_DOCKER_REGISTRY }),
|
|
134
|
+
);
|
|
135
|
+
const rel = await run({ channel: "release", variant: "docker", location: "remote" });
|
|
136
|
+
expect(rel.spies.buildDockerImages).toHaveBeenCalledWith(
|
|
137
|
+
expect.objectContaining({ registry: undefined }),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("docker channel address overrides", () => {
|
|
142
|
+
const devRemote: Knobs = { channel: "dev", variant: "docker", location: "remote" };
|
|
143
|
+
const releaseRemote: Knobs = { channel: "release", variant: "docker", location: "remote" };
|
|
144
|
+
|
|
145
|
+
it("dev defaults: push and embedded pull both target the built-in dev registry", async () => {
|
|
146
|
+
expect(await dockerAddresses(devRemote)).toEqual({
|
|
147
|
+
pull: defaults.DEV_DOCKER_REGISTRY,
|
|
148
|
+
push: defaults.DEV_DOCKER_REGISTRY,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("dev PUSH override: pull follows push when no pull override is set", async () => {
|
|
153
|
+
process.env.PL_DEV_DOCKER_PUSH_URL = "ecr.example/dev";
|
|
154
|
+
expect(await dockerAddresses(devRemote)).toEqual({
|
|
155
|
+
pull: "ecr.example/dev",
|
|
156
|
+
push: "ecr.example/dev",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("dev PUSH + PULL overrides are independent (push there, embed this)", async () => {
|
|
161
|
+
process.env.PL_DEV_DOCKER_PUSH_URL = "ecr.example/dev";
|
|
162
|
+
process.env.PL_DEV_DOCKER_PULL_URL = "cdn.example/dev";
|
|
163
|
+
expect(await dockerAddresses(devRemote)).toEqual({
|
|
164
|
+
pull: "cdn.example/dev",
|
|
165
|
+
push: "ecr.example/dev",
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("release override targets the configured release registry, no built-in default", async () => {
|
|
170
|
+
expect(await dockerAddresses(releaseRemote)).toEqual({ pull: undefined, push: undefined });
|
|
171
|
+
process.env.PL_RELEASE_DOCKER_PUSH_URL = "quay.io/org/repo";
|
|
172
|
+
expect(await dockerAddresses(releaseRemote)).toEqual({
|
|
173
|
+
pull: "quay.io/org/repo",
|
|
174
|
+
push: "quay.io/org/repo",
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("bare (pl-pkg parity) ignores channel overrides", async () => {
|
|
179
|
+
process.env.CI = "true";
|
|
180
|
+
process.env.PL_RELEASE_DOCKER_PUSH_URL = "quay.io/org/repo";
|
|
181
|
+
// bare invocation: docker addresses stay unset (engine pushes to the embedded tag).
|
|
182
|
+
expect(await dockerAddresses({})).toEqual({ pull: undefined, push: undefined });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("dev default (ecr:// push target) auto-logs-in for the stripped host", async () => {
|
|
186
|
+
await run(devRemote);
|
|
187
|
+
expect(ensureEcrLogin).toHaveBeenCalledWith(defaults.DEV_DOCKER_REGISTRY, undefined);
|
|
188
|
+
expect(ensureAwsProfile).toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("a plain (non-ecr) push override opts out of auto-login", async () => {
|
|
192
|
+
process.env.PL_DEV_DOCKER_PUSH_URL = "ecr.example/dev";
|
|
193
|
+
await run(devRemote);
|
|
194
|
+
expect(ensureEcrLogin).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("local docker build never logs in", async () => {
|
|
198
|
+
await run({ channel: "dev", variant: "docker", location: "local" });
|
|
199
|
+
expect(ensureEcrLogin).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("explicit --docker-push-to wins over the channel override", async () => {
|
|
203
|
+
process.env.PL_DEV_DOCKER_PUSH_URL = "ecr.example/dev";
|
|
204
|
+
const b = await run(devRemote);
|
|
205
|
+
await runBuild({
|
|
206
|
+
core: b.core,
|
|
207
|
+
scenario: parseScenario(devRemote),
|
|
208
|
+
options: { dockerPushTo: "flag.example/repo" },
|
|
209
|
+
});
|
|
210
|
+
const lastPush = b.spies.publishDockerImages.mock.calls.at(-1)?.[0] as { pushTo?: string };
|
|
211
|
+
expect(lastPush.pushTo).toBe("flag.example/repo");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("bare honors the resolved docker decisions (resolution lives in build.ts)", () => {
|
|
216
|
+
it("buildDocker=false: binary only, no docker", async () => {
|
|
217
|
+
const b = fakeBuilder();
|
|
218
|
+
await runBuild({
|
|
219
|
+
core: b.core,
|
|
220
|
+
scenario: parseScenario({}),
|
|
221
|
+
options: { buildDocker: false },
|
|
222
|
+
});
|
|
223
|
+
expect(b.calls).toEqual(["buildSoftwareArchives", "buildSwJsonFiles"]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("buildDocker + pushDocker: build then push docker around the binary", async () => {
|
|
227
|
+
const b = fakeBuilder();
|
|
228
|
+
await runBuild({
|
|
229
|
+
core: b.core,
|
|
230
|
+
scenario: parseScenario({}),
|
|
231
|
+
options: { buildDocker: true, pushDocker: true },
|
|
232
|
+
});
|
|
233
|
+
expect(b.calls).toEqual([
|
|
234
|
+
"buildDockerImages",
|
|
235
|
+
"buildSoftwareArchives",
|
|
236
|
+
"publishDockerImages",
|
|
237
|
+
"buildSwJsonFiles",
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("runBuild option pass-through", () => {
|
|
244
|
+
let b: ReturnType<typeof fakeBuilder>;
|
|
245
|
+
beforeEach(() => (b = fakeBuilder()));
|
|
246
|
+
|
|
247
|
+
it("forwards package ids and storage URL to the engine", async () => {
|
|
248
|
+
await runBuild({
|
|
249
|
+
core: b.core,
|
|
250
|
+
scenario: parseScenario({ channel: "dev", variant: "binary", location: "remote" }),
|
|
251
|
+
options: { ids: ["pkg-a"], storageUrl: "s3://bucket/dev" },
|
|
252
|
+
});
|
|
253
|
+
expect(b.spies.buildSoftwareArchives).toHaveBeenCalledWith(
|
|
254
|
+
expect.objectContaining({ ids: ["pkg-a"], skipIfEmpty: false }),
|
|
255
|
+
);
|
|
256
|
+
expect(b.spies.publishPackages).toHaveBeenCalledWith({
|
|
257
|
+
ids: ["pkg-a"],
|
|
258
|
+
storageURL: "s3://bucket/dev",
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// The core safety invariant: "ready ⟺ the descriptor exists". A failed build or push must leave no
|
|
264
|
+
// descriptor, so nothing downstream ever sees a broken half-build.
|
|
265
|
+
describe("descriptor-last: not written when a step fails", () => {
|
|
266
|
+
it("skips the descriptor when the binary build throws", async () => {
|
|
267
|
+
const b = fakeBuilder();
|
|
268
|
+
b.spies.buildSoftwareArchives.mockRejectedValueOnce(new Error("build boom"));
|
|
269
|
+
await expect(
|
|
270
|
+
runBuild({
|
|
271
|
+
core: b.core,
|
|
272
|
+
scenario: parseScenario({ channel: "dev", variant: "binary", location: "remote" }),
|
|
273
|
+
}),
|
|
274
|
+
).rejects.toThrow("build boom");
|
|
275
|
+
expect(b.spies.buildSwJsonFiles).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("skips the descriptor when the docker push throws", async () => {
|
|
279
|
+
const b = fakeBuilder();
|
|
280
|
+
b.spies.publishDockerImages.mockImplementationOnce(() => {
|
|
281
|
+
throw new Error("push boom");
|
|
282
|
+
});
|
|
283
|
+
await expect(
|
|
284
|
+
runBuild({
|
|
285
|
+
core: b.core,
|
|
286
|
+
scenario: parseScenario({ channel: "dev", variant: "docker", location: "remote" }),
|
|
287
|
+
}),
|
|
288
|
+
).rejects.toThrow("push boom");
|
|
289
|
+
expect(b.spies.buildSwJsonFiles).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
util,
|
|
3
|
+
envs,
|
|
4
|
+
defaults,
|
|
5
|
+
type Builder,
|
|
6
|
+
type Logger,
|
|
7
|
+
} from "@platforma-sdk/package-builder-lib";
|
|
8
|
+
import type { Channel, Scenario } from "./knobs";
|
|
9
|
+
import { ensureAwsProfile, ensureEcrLogin } from "./ecr-login";
|
|
10
|
+
|
|
11
|
+
// Pass-through controls mirroring `pl-pkg build`. Empty means "engine default". The --docker-/
|
|
12
|
+
// --conda- flag pairs are already collapsed to decisions at the CLI boundary (build.ts): only the
|
|
13
|
+
// `bare` scenario reads buildDocker/pushDocker; condaBuild applies to every binary build.
|
|
14
|
+
export type BuildOptions = {
|
|
15
|
+
ids?: string[];
|
|
16
|
+
force?: boolean;
|
|
17
|
+
contentRoot?: string;
|
|
18
|
+
storageUrl?: string;
|
|
19
|
+
dockerRegistry?: string;
|
|
20
|
+
dockerPushTo?: string;
|
|
21
|
+
buildDocker?: boolean;
|
|
22
|
+
pushDocker?: boolean;
|
|
23
|
+
condaBuild?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Build a scenario in one pass: build, push when remote, then write the descriptor last —
|
|
27
|
+
// "ready ⟺ the descriptor exists". The pre-configured builder is injected; runBuild sets the
|
|
28
|
+
// scenario's build mode and drives it.
|
|
29
|
+
export async function runBuild(params: {
|
|
30
|
+
core: Builder;
|
|
31
|
+
scenario: Scenario;
|
|
32
|
+
options?: BuildOptions;
|
|
33
|
+
logger?: Logger;
|
|
34
|
+
}): Promise<void> {
|
|
35
|
+
const { core, scenario, options: opts = {}, logger } = params;
|
|
36
|
+
const ids = opts.ids;
|
|
37
|
+
core.buildMode = buildModeForScenario(scenario);
|
|
38
|
+
|
|
39
|
+
const buildBinary = () =>
|
|
40
|
+
core.buildSoftwareArchives({
|
|
41
|
+
ids,
|
|
42
|
+
forceBuild: Boolean(opts.force),
|
|
43
|
+
contentRoot: opts.contentRoot,
|
|
44
|
+
// Tolerate a package with no archives only on a build-all run; when specific ids are
|
|
45
|
+
// requested, an empty build is a real error the caller wants to hear about.
|
|
46
|
+
skipIfEmpty: !ids,
|
|
47
|
+
condaBuild: opts.condaBuild,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
switch (scenario.kind) {
|
|
51
|
+
case "binary-existing":
|
|
52
|
+
// Descriptor points at an already-published release artifact; nothing is built or pushed.
|
|
53
|
+
core.writePublishedArtifactInfo({ ids });
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case "no-software":
|
|
57
|
+
// Nothing built or pushed; the placeholder descriptor is written below.
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case "bare": {
|
|
61
|
+
// Bare pl-pkg-parity invocation; buildDocker/pushDocker were resolved at the CLI boundary.
|
|
62
|
+
if (opts.buildDocker) {
|
|
63
|
+
core.buildDockerImages({
|
|
64
|
+
ids,
|
|
65
|
+
registry: opts.dockerRegistry,
|
|
66
|
+
strictPlatformMatching: envs.isCI(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
await buildBinary();
|
|
70
|
+
if (opts.pushDocker) {
|
|
71
|
+
core.publishDockerImages({
|
|
72
|
+
ids,
|
|
73
|
+
pushTo: opts.dockerPushTo,
|
|
74
|
+
strictPlatformMatching: envs.isCI(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case "target": {
|
|
81
|
+
const { channel, variant, location } = scenario;
|
|
82
|
+
const docker = variant !== "binary";
|
|
83
|
+
const binary = variant !== "docker";
|
|
84
|
+
const remote = location === "remote";
|
|
85
|
+
const addr = resolveDockerAddresses(channel, opts);
|
|
86
|
+
|
|
87
|
+
// BUILD (descriptor not written yet).
|
|
88
|
+
if (docker) {
|
|
89
|
+
core.buildDockerImages({ ids, registry: addr.pull, strictPlatformMatching: envs.isCI() });
|
|
90
|
+
}
|
|
91
|
+
if (binary) await buildBinary();
|
|
92
|
+
|
|
93
|
+
// PUSH when remote. Pin the AWS credential chain once (docker login + S3 upload share it), but
|
|
94
|
+
// only for dev — the fallback profile is the dev SSO one.
|
|
95
|
+
if (remote && channel === "dev" && (docker || binary)) ensureAwsProfile(logger);
|
|
96
|
+
if (docker && remote) {
|
|
97
|
+
if (addr.autoLogin && addr.push) await ensureEcrLogin(addr.push, logger);
|
|
98
|
+
core.publishDockerImages({ ids, pushTo: addr.push, strictPlatformMatching: envs.isCI() });
|
|
99
|
+
}
|
|
100
|
+
if (binary && remote) {
|
|
101
|
+
// storageURL falls through to the engine, which resolves the dev/release endpoint.
|
|
102
|
+
await core.publishPackages({ ids, storageURL: opts.storageUrl });
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
default:
|
|
108
|
+
util.assertNever(scenario);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// DESCRIPTOR LAST — only after build + push have succeeded.
|
|
112
|
+
core.buildSwJsonFiles({ packageIds: ids, noSoftware: scenario.kind === "no-software" });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildModeForScenario(scenario: Scenario): util.BuildMode {
|
|
116
|
+
if (scenario.kind !== "target") return "release";
|
|
117
|
+
if (scenario.channel === "release") return "release";
|
|
118
|
+
// Only a remote binary build needs dev-remote (it uploads an archive). Docker-only, or any
|
|
119
|
+
// local build, uses dev-local — docker naming is content-addressed by image ID regardless.
|
|
120
|
+
const binaryRemote = scenario.variant !== "docker" && scenario.location === "remote";
|
|
121
|
+
return binaryRemote ? "dev-remote" : "dev-local";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Bare registry ref (host[:port]/path) with any URL scheme removed.
|
|
125
|
+
function stripScheme(raw: string): string {
|
|
126
|
+
if (!raw.includes("://")) return raw; // already bare
|
|
127
|
+
const url = new URL(raw);
|
|
128
|
+
return url.host + url.pathname;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A push target may carry a scheme selecting auto docker-login: `ecr://` opts in, anything else out.
|
|
132
|
+
function parsePushTarget(raw: string): { registry: string; autoLogin: boolean } {
|
|
133
|
+
return { registry: stripScheme(raw), autoLogin: raw.startsWith("ecr://") };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Docker push target + the pull address embedded in the descriptor. Precedence flag > channel env >
|
|
137
|
+
// dev default (release has none); pull defaults to push; autoLogin follows the push target's ecr://.
|
|
138
|
+
function resolveDockerAddresses(
|
|
139
|
+
channel: Channel,
|
|
140
|
+
opts: BuildOptions,
|
|
141
|
+
): { pull?: string; push?: string; autoLogin: boolean } {
|
|
142
|
+
const isDev = channel === "dev";
|
|
143
|
+
const channelPush =
|
|
144
|
+
process.env[isDev ? envs.PL_DEV_DOCKER_PUSH_URL : envs.PL_RELEASE_DOCKER_PUSH_URL];
|
|
145
|
+
const channelPull =
|
|
146
|
+
process.env[isDev ? envs.PL_DEV_DOCKER_PULL_URL : envs.PL_RELEASE_DOCKER_PULL_URL];
|
|
147
|
+
|
|
148
|
+
const rawPush =
|
|
149
|
+
opts.dockerPushTo ?? channelPush ?? (isDev ? defaults.DEV_DOCKER_PUSH_TARGET : undefined);
|
|
150
|
+
const parsed = rawPush ? parsePushTarget(rawPush) : { registry: undefined, autoLogin: false };
|
|
151
|
+
|
|
152
|
+
const rawPull = opts.dockerRegistry ?? channelPull ?? rawPush;
|
|
153
|
+
const pull = rawPull ? stripScheme(rawPull) : undefined;
|
|
154
|
+
|
|
155
|
+
return { pull, push: parsed.registry, autoLogin: parsed.autoLogin };
|
|
156
|
+
}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
STRUCTURE_MIN_SUPPORTED,
|
|
8
8
|
StructureVersionFloorError,
|
|
9
9
|
assertVersionAboveFloor,
|
|
10
|
-
|
|
10
|
+
readStructureMeta,
|
|
11
11
|
} from "../engine/version";
|
|
12
12
|
import { MemoryFileSystem } from "../engine/fs/memory";
|
|
13
13
|
|
|
@@ -45,7 +45,7 @@ describe("floor-version refusal", () => {
|
|
|
45
45
|
const fs = new MemoryFileSystem({
|
|
46
46
|
".structure": JSON.stringify({ version: 0 }) + "\n",
|
|
47
47
|
});
|
|
48
|
-
const version =
|
|
48
|
+
const version = readStructureMeta(fs).version;
|
|
49
49
|
expect(version).toBe(0);
|
|
50
50
|
expect(() => assertVersionAboveFloor(version, 1)).toThrow(StructureVersionFloorError);
|
|
51
51
|
});
|
|
@@ -4,31 +4,24 @@
|
|
|
4
4
|
import { describe, test, expect } from "vitest";
|
|
5
5
|
import { MemoryFileSystem } from "../fs/memory";
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
readStructureMeta,
|
|
8
|
+
writeStructureMeta,
|
|
9
9
|
assertVersionAboveFloor,
|
|
10
10
|
STRUCTURE_VERSION,
|
|
11
11
|
StructureVersionFloorError,
|
|
12
12
|
} from "../version";
|
|
13
13
|
|
|
14
14
|
describe(".structure metadata file", () => {
|
|
15
|
-
test("missing file reads as 0", async () => {
|
|
15
|
+
test("missing file reads as version 0", async () => {
|
|
16
16
|
const fs = new MemoryFileSystem();
|
|
17
|
-
expect(
|
|
17
|
+
expect(readStructureMeta(fs)).toEqual({ version: 0 });
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
test("write then read round-trips", async () => {
|
|
20
|
+
test("write then read round-trips the version", async () => {
|
|
21
21
|
const fs = new MemoryFileSystem();
|
|
22
|
-
|
|
23
|
-
expect(
|
|
24
|
-
|
|
25
|
-
expect(JSON.parse(written)).toEqual({ version: 3 });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("default write uses STRUCTURE_VERSION", async () => {
|
|
29
|
-
const fs = new MemoryFileSystem();
|
|
30
|
-
await writeStructureVersion(fs);
|
|
31
|
-
expect(await readStructureVersion(fs)).toBe(STRUCTURE_VERSION);
|
|
22
|
+
writeStructureMeta(fs, { version: 3 });
|
|
23
|
+
expect(readStructureMeta(fs).version).toBe(3);
|
|
24
|
+
expect(JSON.parse(fs.read(".structure"))).toEqual({ version: 3 });
|
|
32
25
|
});
|
|
33
26
|
|
|
34
27
|
test("floor check throws below floor", () => {
|
|
@@ -37,8 +30,12 @@ describe(".structure metadata file", () => {
|
|
|
37
30
|
expect(() => assertVersionAboveFloor(0, 0)).not.toThrow();
|
|
38
31
|
});
|
|
39
32
|
|
|
40
|
-
test("invalid JSON reads as 0", async () => {
|
|
33
|
+
test("invalid JSON reads as version 0", async () => {
|
|
41
34
|
const fs = new MemoryFileSystem({ ".structure": "not-json" });
|
|
42
|
-
expect(
|
|
35
|
+
expect(readStructureMeta(fs)).toEqual({ version: 0 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("STRUCTURE_VERSION is a positive integer", () => {
|
|
39
|
+
expect(Number.isInteger(STRUCTURE_VERSION) && STRUCTURE_VERSION >= 1).toBe(true);
|
|
43
40
|
});
|
|
44
41
|
});
|
|
@@ -31,7 +31,7 @@ import type { BlockVars, Module, RunContext } from "./api";
|
|
|
31
31
|
import type { FileSystem } from "./fs/api";
|
|
32
32
|
import { discoverModules, type WorkspacePackage, type PackageJsonLike } from "./discovery";
|
|
33
33
|
import { createRunContext } from "./ctx";
|
|
34
|
-
import {
|
|
34
|
+
import { STRUCTURE_MIN_SUPPORTED, assertVersionAboveFloor, readStructureMeta } from "./version";
|
|
35
35
|
|
|
36
36
|
export class StructureCliError extends Error {
|
|
37
37
|
constructor(msg: string) {
|
|
@@ -147,15 +147,6 @@ export function parseBlockVars(facadeName: string): BlockVars {
|
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
function readVersion(fs: FileSystem): number {
|
|
151
|
-
try {
|
|
152
|
-
const parsed = JSON.parse(fs.read(STRUCTURE_META_FILE)) as { version?: number };
|
|
153
|
-
return typeof parsed.version === "number" ? parsed.version : 0;
|
|
154
|
-
} catch {
|
|
155
|
-
return 0;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
150
|
export type DiscoverInput = {
|
|
160
151
|
/** Block-rooted filesystem (NodeFileSystem for the CLI, MemoryFileSystem
|
|
161
152
|
* for the Layer-2 round-trip). */
|
|
@@ -184,8 +175,8 @@ export function discoverRunContext(input: DiscoverInput): RunContext {
|
|
|
184
175
|
|
|
185
176
|
const modules: Module[] = discoverModules(packages);
|
|
186
177
|
|
|
187
|
-
const
|
|
188
|
-
assertVersionAboveFloor(version, STRUCTURE_MIN_SUPPORTED);
|
|
178
|
+
const meta = readStructureMeta(fs);
|
|
179
|
+
assertVersionAboveFloor(meta.version, STRUCTURE_MIN_SUPPORTED);
|
|
189
180
|
|
|
190
181
|
// BlockVars from the block-scope module's name (facade). Fall back to
|
|
191
182
|
// the root module if a block has the root-as-block shape.
|
|
@@ -211,7 +202,7 @@ export function discoverRunContext(input: DiscoverInput): RunContext {
|
|
|
211
202
|
updateDepsOnly: input.updateDepsOnly ?? false,
|
|
212
203
|
modules,
|
|
213
204
|
blockVars,
|
|
214
|
-
version,
|
|
205
|
+
version: meta.version,
|
|
215
206
|
dryRun: input.dryRun ?? false,
|
|
216
207
|
});
|
|
217
208
|
}
|
|
@@ -48,7 +48,7 @@ import { parseJson, stringifyJson } from "./parsers/json";
|
|
|
48
48
|
import type { JsonObject } from "./parsers/json";
|
|
49
49
|
import { parseYaml, stringifyYaml } from "./parsers/yaml";
|
|
50
50
|
import { parseLines, serializeLines } from "./parsers/lines";
|
|
51
|
-
import {
|
|
51
|
+
import { writeStructureMeta, STRUCTURE_VERSION, STRUCTURE_META_FILE } from "./version";
|
|
52
52
|
|
|
53
53
|
export type ChangeAction = "create" | "update" | "delete" | "rename" | "noop";
|
|
54
54
|
|
|
@@ -499,7 +499,7 @@ export function run(
|
|
|
499
499
|
|
|
500
500
|
if (isRefreshDefault) {
|
|
501
501
|
// version bump
|
|
502
|
-
|
|
502
|
+
writeStructureMeta(fs, { version: STRUCTURE_VERSION });
|
|
503
503
|
|
|
504
504
|
// post-run recheck. Fresh discovery → fresh flatten →
|
|
505
505
|
// dry-run. Any change list emits RecheckError on the first item.
|
|
@@ -517,7 +517,7 @@ export function run(
|
|
|
517
517
|
opts.derivedPinLookup,
|
|
518
518
|
);
|
|
519
519
|
// Ignore .structure self-write (already up to date after
|
|
520
|
-
//
|
|
520
|
+
// writeStructureMeta above).
|
|
521
521
|
const filtered = recheckChanges.filter((c) => c.path !== STRUCTURE_META_FILE);
|
|
522
522
|
if (filtered.length > 0) {
|
|
523
523
|
throw new RecheckError(filtered[0]!);
|
|
@@ -527,7 +527,7 @@ export function run(
|
|
|
527
527
|
// Init mode: write .structure so the resulting tree carries the
|
|
528
528
|
// current version (matches what `init` ships to disk).
|
|
529
529
|
if (initMode && !ctx.dryRun) {
|
|
530
|
-
|
|
530
|
+
writeStructureMeta(fs, { version: STRUCTURE_VERSION });
|
|
531
531
|
}
|
|
532
532
|
|
|
533
533
|
return { changes };
|