@kungfu-tech/buildchain 2.0.14-alpha.4 → 2.0.14-alpha.5

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/README.md CHANGED
@@ -34,11 +34,11 @@ protocol for non-Node projects.
34
34
 
35
35
  Buildchain release automation is branch-driven:
36
36
 
37
- | Merge path | Meaning | Exact tag | Floating tags and branches |
38
- | --- | --- | --- | --- |
39
- | `dev/vX/vX.Y -> alpha/vX/vX.Y` | publish the next testable alpha for a minor line | `vX.Y.Z-alpha.N` | `vX.Y-alpha`, `alpha/vX/vX.Y`, `dev/vX/vX.Y` |
40
- | `alpha/vX/vX.Y -> release/vX/vX.Y` | publish production for that minor line | `vX.Y.Z` | `vX.Y`, usually `vX`, `release/vX/vX.Y` |
41
- | `release/vX/vX.Y -> publish-gate/major` | publish the next major from a reviewed production line | `v(X+1).0.0` | `v(X+1)`, `v(X+1).0`, `release/v(X+1)/v(X+1).0` |
37
+ | Merge path | Meaning | Exact tag | Floating tags and branches |
38
+ | --------------------------------------- | ------------------------------------------------------ | ---------------- | ----------------------------------------------- |
39
+ | `dev/vX/vX.Y -> alpha/vX/vX.Y` | publish the next testable alpha for a minor line | `vX.Y.Z-alpha.N` | `vX.Y-alpha`, `alpha/vX/vX.Y`, `dev/vX/vX.Y` |
40
+ | `alpha/vX/vX.Y -> release/vX/vX.Y` | publish production for that minor line | `vX.Y.Z` | `vX.Y`, usually `vX`, `release/vX/vX.Y` |
41
+ | `release/vX/vX.Y -> publish-gate/major` | publish the next major from a reviewed production line | `v(X+1).0.0` | `v(X+1)`, `v(X+1).0`, `release/v(X+1)/v(X+1).0` |
42
42
 
43
43
  After a production release, Buildchain prepares the next alpha source commit for
44
44
  the same minor line and moves `dev/vX/vX.Y`, `alpha/vX/vX.Y`, and
@@ -102,8 +102,9 @@ Buildchain v2 ships these active surfaces:
102
102
  - `.github/workflows/.build.yml` as the reusable build surface for
103
103
  tri-platform runner presets, custom runner matrices, caller-provided lifecycle
104
104
  commands, trusted event gating, artifact name templates, expected artifact
105
- checks, deterministic artifact manifests, publish-gate source locks, resolved
106
- release manifests, and aggregate build summaries;
105
+ checks, optional digest-pinned Linux job containers, deterministic artifact
106
+ manifests, publish-gate source locks, resolved release manifests, and
107
+ aggregate build summaries;
107
108
  - `.github/workflows/.web-surface.yml` as the reusable site/app surface for PR
108
109
  preview plans, closed-PR preview cleanup plans, push-main staging promotion
109
110
  plans, and explicit production environment gates;
@@ -17,6 +17,7 @@ jobs:
17
17
  working-directory: .
18
18
  artifact-name: libnode
19
19
  runner-preset: kungfu-v4-self-hosted
20
+ linux-container-preset: kungfu-verify
20
21
  artifact-name-template: "{artifact}-{platform}-{sha}"
21
22
  artifact-paths: |
22
23
  dist
@@ -29,20 +30,20 @@ jobs:
29
30
 
30
31
  `runner-preset` is the stable first-class surface for known runner fleets:
31
32
 
32
- | Preset | Platforms |
33
- | --- | --- |
34
- | `github-hosted` | `ubuntu-24.04`, `macos-latest`, `windows-2022` |
33
+ | Preset | Platforms |
34
+ | ----------------------- | ------------------------------------------------------------------------ |
35
+ | `github-hosted` | `ubuntu-24.04`, `macos-latest`, `windows-2022` |
35
36
  | `kungfu-v4-self-hosted` | Kungfu Linux x64, macOS ARM64, and Windows x64 self-hosted runner labels |
36
- | `custom` | Requires `platforms-json` |
37
+ | `custom` | Requires `platforms-json` |
37
38
 
38
39
  Callers can still provide a custom matrix with `platforms-json`. Each platform
39
40
  object has:
40
41
 
41
- | Field | Meaning |
42
- | --- | --- |
43
- | `id` | Stable artifact/platform key, such as `linux-x64` |
44
- | `name` | Human-readable job name |
45
- | `runner` | JSON string passed to `runs-on` after `fromJSON` |
42
+ | Field | Meaning |
43
+ | -------- | ------------------------------------------------- |
44
+ | `id` | Stable artifact/platform key, such as `linux-x64` |
45
+ | `name` | Human-readable job name |
46
+ | `runner` | JSON string passed to `runs-on` after `fromJSON` |
46
47
 
47
48
  The runner field is intentionally a JSON string so callers can pass either
48
49
  GitHub-hosted runners or multi-label self-hosted runners without Buildchain
@@ -52,28 +53,85 @@ Only include platforms that should run. GitHub schedules matrix jobs before
52
53
  steps execute, so a disabled entry with unavailable runner labels can still
53
54
  block the workflow queue.
54
55
 
56
+ ## Linux Job Containers
57
+
58
+ Linux build platforms can run inside a digest-pinned job container while macOS
59
+ and Windows keep using native runners. This is the recommended way to remove
60
+ moving Linux runner prerequisites from Buildchain consumers: the Linux host only
61
+ needs a GitHub Actions runner, Docker, and network access; common verification
62
+ tools come from the image contract.
63
+
64
+ Use the Kungfu verification image for lifecycle stages that need Git, jq,
65
+ Python, uv, and fnm, but do not need native compilation:
66
+
67
+ ```yaml
68
+ jobs:
69
+ build:
70
+ uses: kungfu-systems/buildchain/.github/workflows/.build.yml@v2
71
+ with:
72
+ runner-preset: kungfu-v4-self-hosted
73
+ linux-container-preset: kungfu-verify
74
+ ```
75
+
76
+ `kungfu-verify` resolves to:
77
+
78
+ ```text
79
+ ghcr.io/kungfu-systems/build-images/kungfu-verify@sha256:11f0ba64267ce88174a4f73a9bf833ff4e9c59cd16ec3d08a6432a06c2be6fb1
80
+ ```
81
+
82
+ Callers that own a different Linux image can pass it explicitly:
83
+
84
+ ```yaml
85
+ with:
86
+ linux-container-preset: custom
87
+ linux-container-image: ghcr.io/example/project-build@sha256:<digest>
88
+ ```
89
+
90
+ `linux-container-image` should be pinned by digest. A floating tag makes the
91
+ runner surface mutable and weakens Buildchain's release evidence.
92
+
93
+ The workflow splits the matrix into two build jobs:
94
+
95
+ - Linux platforms go to `build-linux-container` when a Linux container is
96
+ configured.
97
+ - All other platforms go to `build-native`.
98
+
99
+ Artifact names, manifest paths, expected artifact checks, publish-source locks,
100
+ and aggregate summaries are the same in both jobs. The split is an execution
101
+ detail, not a different artifact contract.
102
+
103
+ The container image provides `fnm` but does not preinstall Node. Buildchain uses
104
+ `fnm` inside the container to install the requested `node-version` before it
105
+ runs Buildchain runtime scripts or lifecycle actions.
106
+
107
+ Do not use `kungfu-verify` for stages that need CMake, Ninja, ccache, Conan, or
108
+ Docker image publishing. Those should use a heavier native-build image or remain
109
+ on a host runner until their image contract is explicit.
110
+
55
111
  ## Workflow Outputs
56
112
 
57
113
  The reusable workflow exposes the resolved contract:
58
114
 
59
- | Output | Meaning |
60
- | --- | --- |
61
- | `runner-preset` | Resolved preset, or `custom` when `platforms-json` was provided |
62
- | `platforms-json` | Exact matrix JSON used by the build job |
63
- | `platform-count` | Number of matrix platforms |
64
- | `build-summary-artifact` | Uploaded aggregate summary artifact name |
65
- | `build-summary-json` | Compact aggregate JSON with platform count, file count, and byte total |
66
- | `trusted-event` | `true` when the event is trusted enough to reach build runners |
67
- | `publish-channel` | Resolved publish channel requested by the caller |
68
- | `publish-allowed` | `true` only when this event/ref may publish after verification |
69
- | `publish-reason` | Human-readable reason for the publish gate decision |
70
- | `publish-source-ref` | Gate source ref that was resolved before checkout |
71
- | `publish-source-sha` | Exact source commit used by checkout, build, verify, and artifacts |
72
- | `publish-source-locked` | `true` when a `publish-gate/*` source ref was explicitly locked |
73
- | `publish-source-channel` | `alpha`, `release`, `anchor`, or `major` parsed from the source ref |
74
- | `publish-source-line` | Product line parsed from source refs such as `v22/v22.22` |
75
- | `publish-source-consumer-version` | Consumer package version parsed from source refs |
76
- | `release-manifest-json` | Resolved release manifest including source lock, version state, and anchor data |
115
+ | Output | Meaning |
116
+ | --------------------------------- | ------------------------------------------------------------------------------- |
117
+ | `runner-preset` | Resolved preset, or `custom` when `platforms-json` was provided |
118
+ | `platforms-json` | Exact matrix JSON used by the build job |
119
+ | `platform-count` | Number of matrix platforms |
120
+ | `linux-container-enabled` | `true` when Linux platforms are routed through a job container |
121
+ | `linux-container-image` | Resolved digest-pinned Linux job container image |
122
+ | `build-summary-artifact` | Uploaded aggregate summary artifact name |
123
+ | `build-summary-json` | Compact aggregate JSON with platform count, file count, and byte total |
124
+ | `trusted-event` | `true` when the event is trusted enough to reach build runners |
125
+ | `publish-channel` | Resolved publish channel requested by the caller |
126
+ | `publish-allowed` | `true` only when this event/ref may publish after verification |
127
+ | `publish-reason` | Human-readable reason for the publish gate decision |
128
+ | `publish-source-ref` | Gate source ref that was resolved before checkout |
129
+ | `publish-source-sha` | Exact source commit used by checkout, build, verify, and artifacts |
130
+ | `publish-source-locked` | `true` when a `publish-gate/*` source ref was explicitly locked |
131
+ | `publish-source-channel` | `alpha`, `release`, `anchor`, or `major` parsed from the source ref |
132
+ | `publish-source-line` | Product line parsed from source refs such as `v22/v22.22` |
133
+ | `publish-source-consumer-version` | Consumer package version parsed from source refs |
134
+ | `release-manifest-json` | Resolved release manifest including source lock, version state, and anchor data |
77
135
 
78
136
  The aggregate summary is intentionally an artifact as well as an output. GitHub
79
137
  Actions matrix outputs are not a reliable place to carry every platform's full
@@ -107,12 +165,12 @@ jobs:
107
165
 
108
166
  Default channels are:
109
167
 
110
- | Channel | Allowed refs |
111
- | --- | --- |
112
- | `none` | Never publishes; this is the default |
113
- | `alpha` | `alpha/vN/vN.M` branches or exact `vN.M.P-alpha.K` tags |
114
- | `release` | `release/vN/vN.M` branches or release tags such as `vN.M.P`, `vN.M`, `vN` |
115
- | `major` | `publish-gate/major`, legacy `major-gate`, or next-major release tags such as `vN.0.0`, `vN.0`, `vN` |
168
+ | Channel | Allowed refs |
169
+ | --------- | ---------------------------------------------------------------------------------------------------- |
170
+ | `none` | Never publishes; this is the default |
171
+ | `alpha` | `alpha/vN/vN.M` branches or exact `vN.M.P-alpha.K` tags |
172
+ | `release` | `release/vN/vN.M` branches or release tags such as `vN.M.P`, `vN.M`, `vN` |
173
+ | `major` | `publish-gate/major`, legacy `major-gate`, or next-major release tags such as `vN.0.0`, `vN.0`, `vN` |
116
174
 
117
175
  Pull request events always produce `publish-allowed=false`, even when the PR is
118
176
  from the same repository. Untrusted fork events also produce
@@ -138,13 +196,13 @@ why it was or was not eligible to publish.
138
196
  source tree is the publish decision about?" A caller can pass `publish-source-ref`
139
197
  to bind a publish run to a reviewed gate branch before any checkout happens:
140
198
 
141
- | Ref | Meaning |
142
- | --- | --- |
143
- | `publish-gate/alpha/<line>/<consumer-version>` | Build and publish an alpha candidate for a consumer line |
144
- | `publish-gate/release/<line>/<consumer-version>` | Build and publish a production candidate for a consumer line |
145
- | `publish-gate/anchor` | Resolve an explicit anchor request; it does not publish artifacts by itself |
146
- | `publish-gate/major` | Gate the next major source state |
147
- | `major-gate` | Legacy compatibility alias for the major gate |
199
+ | Ref | Meaning |
200
+ | ------------------------------------------------ | --------------------------------------------------------------------------- |
201
+ | `publish-gate/alpha/<line>/<consumer-version>` | Build and publish an alpha candidate for a consumer line |
202
+ | `publish-gate/release/<line>/<consumer-version>` | Build and publish a production candidate for a consumer line |
203
+ | `publish-gate/anchor` | Resolve an explicit anchor request; it does not publish artifacts by itself |
204
+ | `publish-gate/major` | Gate the next major source state |
205
+ | `major-gate` | Legacy compatibility alias for the major gate |
148
206
 
149
207
  For alpha and release refs, `<line>` is intentionally allowed to contain `/`, so
150
208
  Kungfu-style lines such as `v22/v22.22` stay readable. The final path segment is
@@ -310,11 +368,11 @@ produce a new GitHub Actions run but keep the same source SHA/platform contract.
310
368
  `expected-artifacts-json` fails the build before upload when the artifact does
311
369
  not match the caller's declared contract. Supported checks are:
312
370
 
313
- | Field | Meaning |
314
- | --- | --- |
315
- | `minFiles` | Minimum number of manifest files |
316
- | `maxFiles` | Maximum number of manifest files |
317
- | `minTotalBytes` | Minimum total byte count |
371
+ | Field | Meaning |
372
+ | --------------- | ------------------------------------ |
373
+ | `minFiles` | Minimum number of manifest files |
374
+ | `maxFiles` | Maximum number of manifest files |
375
+ | `minTotalBytes` | Minimum total byte count |
318
376
  | `requiredPaths` | Exact manifest paths that must exist |
319
377
 
320
378
  ## Trusted Event Gate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.0.14-alpha.4",
3
+ "version": "2.0.14-alpha.5",
4
4
  "private": false,
5
5
  "description": "Kungfu Buildchain reusable workflows, release governance, and CLI tooling.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -4,29 +4,36 @@ import path from "node:path";
4
4
 
5
5
  export const RUNNER_PRESETS = Object.freeze({
6
6
  "github-hosted": [
7
- { id: "linux-x64", name: "Linux x64", runner: "[\"ubuntu-24.04\"]" },
8
- { id: "macos", name: "macOS", runner: "[\"macos-latest\"]" },
9
- { id: "windows-x64", name: "Windows x64", runner: "[\"windows-2022\"]" },
7
+ { id: "linux-x64", name: "Linux x64", runner: '["ubuntu-24.04"]' },
8
+ { id: "macos", name: "macOS", runner: '["macos-latest"]' },
9
+ { id: "windows-x64", name: "Windows x64", runner: '["windows-2022"]' },
10
10
  ],
11
11
  "kungfu-v4-self-hosted": [
12
12
  {
13
13
  id: "linux-x64",
14
14
  name: "Linux x64",
15
- runner: "[\"self-hosted\",\"Linux\",\"X64\",\"kungfu-build-v4-linux-x64\"]",
15
+ runner: '["self-hosted","Linux","X64","kungfu-build-v4-linux-x64"]',
16
16
  },
17
17
  {
18
18
  id: "macos-arm64",
19
19
  name: "macOS ARM64",
20
- runner: "[\"self-hosted\",\"macOS\",\"ARM64\",\"kungfu-build-v4-macos-arm64\"]",
20
+ runner: '["self-hosted","macOS","ARM64","kungfu-build-v4-macos-arm64"]',
21
21
  },
22
22
  {
23
23
  id: "windows-x64",
24
24
  name: "Windows x64",
25
- runner: "[\"self-hosted\",\"Windows\",\"X64\",\"kungfu-build-v4-windows-x64\"]",
25
+ runner: '["self-hosted","Windows","X64","kungfu-build-v4-windows-x64"]',
26
26
  },
27
27
  ],
28
28
  });
29
29
 
30
+ export const LINUX_CONTAINER_PRESETS = Object.freeze({
31
+ "kungfu-verify": {
32
+ image:
33
+ "ghcr.io/kungfu-systems/build-images/kungfu-verify@sha256:11f0ba64267ce88174a4f73a9bf833ff4e9c59cd16ec3d08a6432a06c2be6fb1",
34
+ },
35
+ });
36
+
30
37
  const RUNNER_PRESET_ALIASES = Object.freeze({
31
38
  github: "github-hosted",
32
39
  "github-hosted-default": "github-hosted",
@@ -35,6 +42,14 @@ const RUNNER_PRESET_ALIASES = Object.freeze({
35
42
  "kungfu-v4": "kungfu-v4-self-hosted",
36
43
  });
37
44
 
45
+ const LINUX_CONTAINER_PRESET_ALIASES = Object.freeze({
46
+ "": "",
47
+ none: "",
48
+ off: "",
49
+ false: "",
50
+ verify: "kungfu-verify",
51
+ });
52
+
38
53
  export const DEFAULT_ARTIFACT_NAME_TEMPLATE = "{artifact}-{platform}-{sha}";
39
54
  export const DEFAULT_PUBLISH_REFS = Object.freeze({
40
55
  alpha: [
@@ -138,7 +153,10 @@ export function parsePublishSourceRef(value = "") {
138
153
  if (sourceRef === "publish-gate/major" || sourceRef === "major-gate") {
139
154
  return {
140
155
  sourceRef,
141
- fullRef: sourceRef === "major-gate" ? "refs/heads/major-gate" : "refs/heads/publish-gate/major",
156
+ fullRef:
157
+ sourceRef === "major-gate"
158
+ ? "refs/heads/major-gate"
159
+ : "refs/heads/publish-gate/major",
142
160
  enabled: true,
143
161
  channel: "major",
144
162
  line: "",
@@ -147,7 +165,9 @@ export function parsePublishSourceRef(value = "") {
147
165
  legacyAlias: sourceRef === "major-gate",
148
166
  };
149
167
  }
150
- const match = sourceRef.match(/^publish-gate\/(alpha|release)\/(.+)\/([^/]+)$/);
168
+ const match = sourceRef.match(
169
+ /^publish-gate\/(alpha|release)\/(.+)\/([^/]+)$/,
170
+ );
151
171
  if (!match) {
152
172
  throw new Error(
153
173
  `unsupported publish source ref: ${sourceRef}; expected publish-gate/alpha/<line>/<version>, publish-gate/release/<line>/<version>, publish-gate/anchor, publish-gate/major, or major-gate`,
@@ -155,10 +175,14 @@ export function parsePublishSourceRef(value = "") {
155
175
  }
156
176
  const [, channel, line, consumerVersion] = match;
157
177
  if (!line.includes("/")) {
158
- throw new Error(`publish source line must include a major/minor path: ${sourceRef}`);
178
+ throw new Error(
179
+ `publish source line must include a major/minor path: ${sourceRef}`,
180
+ );
159
181
  }
160
182
  if (!/^[A-Za-z0-9._+~-]+$/.test(consumerVersion)) {
161
- throw new Error(`publish source consumer version contains unsupported characters: ${consumerVersion}`);
183
+ throw new Error(
184
+ `publish source consumer version contains unsupported characters: ${consumerVersion}`,
185
+ );
162
186
  }
163
187
  return {
164
188
  sourceRef,
@@ -242,7 +266,9 @@ export async function createResolvedReleaseManifest({
242
266
  }));
243
267
  if (lock.consumerVersion) {
244
268
  if (resolvedVersionFiles.length === 0) {
245
- throw new Error("publish source consumer version requires configured version.files");
269
+ throw new Error(
270
+ "publish source consumer version requires configured version.files",
271
+ );
246
272
  }
247
273
  for (const file of resolvedVersionFiles) {
248
274
  if (file.version !== lock.consumerVersion) {
@@ -254,7 +280,11 @@ export async function createResolvedReleaseManifest({
254
280
  }
255
281
 
256
282
  const anchorManifest = loadConfiguredAnchorManifest(cwd, loadedConfig);
257
- if (lock.consumerVersion && anchorManifest?.fields?.npmVersion && anchorManifest.fields.npmVersion !== lock.consumerVersion) {
283
+ if (
284
+ lock.consumerVersion &&
285
+ anchorManifest?.fields?.npmVersion &&
286
+ anchorManifest.fields.npmVersion !== lock.consumerVersion
287
+ ) {
258
288
  throw new Error(
259
289
  `anchor manifest npmVersion ${anchorManifest.fields.npmVersion} does not match ${lock.consumerVersion}`,
260
290
  );
@@ -287,17 +317,29 @@ export async function createResolvedReleaseManifest({
287
317
  anchorRequest,
288
318
  publish: {
289
319
  registry: publishRegistry,
290
- distTag: distTag || (lock.channel === "release" ? "latest" : lock.channel === "alpha" ? "alpha" : ""),
320
+ distTag:
321
+ distTag ||
322
+ (lock.channel === "release"
323
+ ? "latest"
324
+ : lock.channel === "alpha"
325
+ ? "alpha"
326
+ : ""),
291
327
  visibilityGate,
292
328
  },
293
329
  };
294
330
  }
295
331
 
296
- export function verifyPublishSourceLock({ sourceRef = "", expectedSha = "", currentSha = "" } = {}) {
332
+ export function verifyPublishSourceLock({
333
+ sourceRef = "",
334
+ expectedSha = "",
335
+ currentSha = "",
336
+ } = {}) {
297
337
  const expected = assertSha(expectedSha, "expectedSha");
298
338
  const current = assertSha(currentSha, "currentSha");
299
339
  if (expected !== current) {
300
- throw new Error(`publish source ref moved: ${sourceRef || "<unknown>"} expected ${expected}, got ${current}`);
340
+ throw new Error(
341
+ `publish source ref moved: ${sourceRef || "<unknown>"} expected ${expected}, got ${current}`,
342
+ );
301
343
  }
302
344
  return {
303
345
  ok: true,
@@ -356,8 +398,14 @@ export function planPackageSetPublish({
356
398
  for (const pkg of normalized) {
357
399
  const already = existing.get(existingPackageKey(pkg));
358
400
  if (already) {
359
- if (pkg.integrity && already.integrity && pkg.integrity !== already.integrity) {
360
- throw new Error(`existing package integrity mismatch: ${pkg.name}@${pkg.version}`);
401
+ if (
402
+ pkg.integrity &&
403
+ already.integrity &&
404
+ pkg.integrity !== already.integrity
405
+ ) {
406
+ throw new Error(
407
+ `existing package integrity mismatch: ${pkg.name}@${pkg.version}`,
408
+ );
361
409
  }
362
410
  steps.push({ action: "accept-existing", package: pkg });
363
411
  } else {
@@ -406,7 +454,9 @@ function normalizePublishRefs(value = "") {
406
454
  try {
407
455
  new RegExp(value);
408
456
  } catch (error) {
409
- throw new Error(`publish-refs-json.${key}[${index}] is invalid: ${error.message}`);
457
+ throw new Error(
458
+ `publish-refs-json.${key}[${index}] is invalid: ${error.message}`,
459
+ );
410
460
  }
411
461
  return value;
412
462
  });
@@ -434,6 +484,65 @@ function normalizeRunnerPreset(value) {
434
484
  return RUNNER_PRESET_ALIASES[preset] || preset;
435
485
  }
436
486
 
487
+ function normalizeLinuxContainerPreset(value) {
488
+ const preset = String(value || "").trim();
489
+ return LINUX_CONTAINER_PRESET_ALIASES[preset] ?? preset;
490
+ }
491
+
492
+ function resolveLinuxContainer({
493
+ linuxContainerPreset = "",
494
+ linuxContainerImage = "",
495
+ } = {}) {
496
+ const explicitImage = String(linuxContainerImage || "").trim();
497
+ const preset = normalizeLinuxContainerPreset(linuxContainerPreset);
498
+ if (explicitImage) {
499
+ if (preset && preset !== "custom") {
500
+ throw new Error(
501
+ "linux-container-image cannot be combined with a named linux-container-preset",
502
+ );
503
+ }
504
+ return {
505
+ enabled: true,
506
+ preset: preset || "custom",
507
+ image: explicitImage,
508
+ source: "linux-container-image",
509
+ };
510
+ }
511
+ if (!preset) {
512
+ return {
513
+ enabled: false,
514
+ preset: "",
515
+ image: "",
516
+ source: "none",
517
+ };
518
+ }
519
+ const resolved = LINUX_CONTAINER_PRESETS[preset];
520
+ if (!resolved) {
521
+ throw new Error(`unsupported linux-container-preset: ${preset}`);
522
+ }
523
+ return {
524
+ enabled: true,
525
+ preset,
526
+ image: resolved.image,
527
+ source: "linux-container-preset",
528
+ };
529
+ }
530
+
531
+ function platformIsLinux(platform) {
532
+ const id = String(platform?.id || "").toLowerCase();
533
+ const name = String(platform?.name || "").toLowerCase();
534
+ if (id.includes("linux") || name.includes("linux")) {
535
+ return true;
536
+ }
537
+ const runnerLabels = parseJsonArray(
538
+ String(platform?.runner || "[]"),
539
+ "platform.runner",
540
+ ).map((label) => String(label || "").toLowerCase());
541
+ return runnerLabels.some(
542
+ (label) => label.includes("linux") || label.includes("ubuntu"),
543
+ );
544
+ }
545
+
437
546
  function normalizePlatform(platform, index) {
438
547
  const id = String(platform?.id || "").trim();
439
548
  const name = String(platform?.name || id).trim();
@@ -451,19 +560,43 @@ function normalizePlatform(platform, index) {
451
560
  return { id, name, runner };
452
561
  }
453
562
 
454
- export function resolveRunnerMatrix({ runnerPreset = "github-hosted", platformsJson = "" } = {}) {
563
+ export function resolveRunnerMatrix({
564
+ runnerPreset = "github-hosted",
565
+ platformsJson = "",
566
+ linuxContainerPreset = "",
567
+ linuxContainerImage = "",
568
+ } = {}) {
455
569
  const customPlatformsJson = String(platformsJson || "").trim();
570
+ const linuxContainer = resolveLinuxContainer({
571
+ linuxContainerPreset,
572
+ linuxContainerImage,
573
+ });
456
574
  if (customPlatformsJson) {
457
- const platforms = parseJsonArray(customPlatformsJson, "platforms-json").map(normalizePlatform);
575
+ const platforms = parseJsonArray(customPlatformsJson, "platforms-json").map(
576
+ normalizePlatform,
577
+ );
458
578
  if (platforms.length === 0) {
459
579
  throw new Error("platforms-json must include at least one platform");
460
580
  }
581
+ const containerPlatforms = linuxContainer.enabled
582
+ ? platforms.filter(platformIsLinux)
583
+ : [];
584
+ const nativePlatforms = linuxContainer.enabled
585
+ ? platforms.filter((platform) => !platformIsLinux(platform))
586
+ : platforms;
461
587
  return {
462
588
  source: "platforms-json",
463
589
  runnerPreset: "custom",
464
590
  platforms,
465
591
  platformsJson: JSON.stringify(platforms),
466
592
  platformCount: platforms.length,
593
+ nativePlatforms,
594
+ nativePlatformsJson: JSON.stringify(nativePlatforms),
595
+ nativePlatformCount: nativePlatforms.length,
596
+ containerPlatforms,
597
+ containerPlatformsJson: JSON.stringify(containerPlatforms),
598
+ containerPlatformCount: containerPlatforms.length,
599
+ linuxContainer,
467
600
  };
468
601
  }
469
602
 
@@ -475,12 +608,25 @@ export function resolveRunnerMatrix({ runnerPreset = "github-hosted", platformsJ
475
608
  if (!platforms) {
476
609
  throw new Error(`unsupported runner-preset: ${preset}`);
477
610
  }
611
+ const containerPlatforms = linuxContainer.enabled
612
+ ? platforms.filter(platformIsLinux)
613
+ : [];
614
+ const nativePlatforms = linuxContainer.enabled
615
+ ? platforms.filter((platform) => !platformIsLinux(platform))
616
+ : platforms;
478
617
  return {
479
618
  source: "runner-preset",
480
619
  runnerPreset: preset,
481
620
  platforms,
482
621
  platformsJson: JSON.stringify(platforms),
483
622
  platformCount: platforms.length,
623
+ nativePlatforms,
624
+ nativePlatformsJson: JSON.stringify(nativePlatforms),
625
+ nativePlatformCount: nativePlatforms.length,
626
+ containerPlatforms,
627
+ containerPlatformsJson: JSON.stringify(containerPlatforms),
628
+ containerPlatformCount: containerPlatforms.length,
629
+ linuxContainer,
484
630
  };
485
631
  }
486
632
 
@@ -506,7 +652,8 @@ export function resolvePublishGate({
506
652
  trusted: isTrusted,
507
653
  publishChannel: channel,
508
654
  publishAllowed: false,
509
- publishReason: "anchor gates resolve source state but do not publish artifacts",
655
+ publishReason:
656
+ "anchor gates resolve source state but do not publish artifacts",
510
657
  };
511
658
  }
512
659
  if (!isTrusted) {
@@ -537,7 +684,9 @@ export function resolvePublishGate({
537
684
  };
538
685
  }
539
686
  const refValue = String(ref || "");
540
- const matchedPattern = patterns.find((pattern) => new RegExp(pattern).test(refValue));
687
+ const matchedPattern = patterns.find((pattern) =>
688
+ new RegExp(pattern).test(refValue),
689
+ );
541
690
  if (!matchedPattern) {
542
691
  return {
543
692
  trusted: true,
@@ -573,7 +722,9 @@ export function resolveArtifactContract({
573
722
  runId = "",
574
723
  runAttempt = "",
575
724
  } = {}) {
576
- const baseName = String(artifactName || "buildchain-artifact").trim() || "buildchain-artifact";
725
+ const baseName =
726
+ String(artifactName || "buildchain-artifact").trim() ||
727
+ "buildchain-artifact";
577
728
  const template =
578
729
  String(artifactNameTemplate || "").trim() || DEFAULT_ARTIFACT_NAME_TEMPLATE;
579
730
  const replacements = {
@@ -588,15 +739,22 @@ export function resolveArtifactContract({
588
739
  runId,
589
740
  runAttempt,
590
741
  };
591
- const resolved = template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (match, key) => {
592
- if (!Object.hasOwn(replacements, key)) {
593
- throw new Error(`unsupported artifact-name-template placeholder: ${match}`);
594
- }
595
- return replacements[key] || "";
596
- });
742
+ const resolved = template.replace(
743
+ /\{([A-Za-z][A-Za-z0-9]*)\}/g,
744
+ (match, key) => {
745
+ if (!Object.hasOwn(replacements, key)) {
746
+ throw new Error(
747
+ `unsupported artifact-name-template placeholder: ${match}`,
748
+ );
749
+ }
750
+ return replacements[key] || "";
751
+ },
752
+ );
597
753
  const safeName = sanitizeArtifactName(resolved);
598
754
  if (!safeName) {
599
- throw new Error("artifact-name-template resolved to an empty artifact name");
755
+ throw new Error(
756
+ "artifact-name-template resolved to an empty artifact name",
757
+ );
600
758
  }
601
759
  return {
602
760
  artifactName: safeName,
@@ -619,19 +777,28 @@ export function parseExpectedArtifactsJson(value = "") {
619
777
  if (expected.minFiles !== undefined) {
620
778
  normalized.minFiles = Number(expected.minFiles);
621
779
  if (!Number.isInteger(normalized.minFiles) || normalized.minFiles < 0) {
622
- throw new Error("expected-artifacts-json.minFiles must be a non-negative integer");
780
+ throw new Error(
781
+ "expected-artifacts-json.minFiles must be a non-negative integer",
782
+ );
623
783
  }
624
784
  }
625
785
  if (expected.maxFiles !== undefined) {
626
786
  normalized.maxFiles = Number(expected.maxFiles);
627
787
  if (!Number.isInteger(normalized.maxFiles) || normalized.maxFiles < 0) {
628
- throw new Error("expected-artifacts-json.maxFiles must be a non-negative integer");
788
+ throw new Error(
789
+ "expected-artifacts-json.maxFiles must be a non-negative integer",
790
+ );
629
791
  }
630
792
  }
631
793
  if (expected.minTotalBytes !== undefined) {
632
794
  normalized.minTotalBytes = Number(expected.minTotalBytes);
633
- if (!Number.isInteger(normalized.minTotalBytes) || normalized.minTotalBytes < 0) {
634
- throw new Error("expected-artifacts-json.minTotalBytes must be a non-negative integer");
795
+ if (
796
+ !Number.isInteger(normalized.minTotalBytes) ||
797
+ normalized.minTotalBytes < 0
798
+ ) {
799
+ throw new Error(
800
+ "expected-artifacts-json.minTotalBytes must be a non-negative integer",
801
+ );
635
802
  }
636
803
  }
637
804
  if (expected.requiredPaths !== undefined) {
@@ -639,9 +806,13 @@ export function parseExpectedArtifactsJson(value = "") {
639
806
  throw new Error("expected-artifacts-json.requiredPaths must be an array");
640
807
  }
641
808
  normalized.requiredPaths = expected.requiredPaths.map((entry, index) => {
642
- const pathValue = String(entry || "").replace(/\\/g, "/").trim();
809
+ const pathValue = String(entry || "")
810
+ .replace(/\\/g, "/")
811
+ .trim();
643
812
  if (!pathValue) {
644
- throw new Error(`expected-artifacts-json.requiredPaths[${index}] must be non-empty`);
813
+ throw new Error(
814
+ `expected-artifacts-json.requiredPaths[${index}] must be non-empty`,
815
+ );
645
816
  }
646
817
  return pathValue;
647
818
  });
@@ -650,7 +821,10 @@ export function parseExpectedArtifactsJson(value = "") {
650
821
  }
651
822
 
652
823
  export function createArtifactSummary({ artifactName, platform, files }) {
653
- const totalBytes = files.reduce((sum, file) => sum + Number(file.size || 0), 0);
824
+ const totalBytes = files.reduce(
825
+ (sum, file) => sum + Number(file.size || 0),
826
+ 0,
827
+ );
654
828
  const digest = crypto.createHash("sha256");
655
829
  for (const file of files) {
656
830
  digest.update(`${file.path}\0${file.size}\0${file.sha256}\n`);
@@ -713,7 +887,9 @@ export function writeGitHubOutputs(outputs) {
713
887
  }
714
888
  return;
715
889
  }
716
- const lines = Object.entries(outputs).map(([key, value]) => `${key}=${value}`);
890
+ const lines = Object.entries(outputs).map(
891
+ ([key, value]) => `${key}=${value}`,
892
+ );
717
893
  fs.appendFileSync(outputPath, `${lines.join("\n")}\n`);
718
894
  }
719
895
 
@@ -15,25 +15,40 @@ function readArg(name, fallback = "") {
15
15
  }
16
16
 
17
17
  export function resolveBuildContractCli() {
18
- const mode = readArg("mode", process.env.BUILDCHAIN_CONTRACT_MODE || "artifact");
18
+ const mode = readArg(
19
+ "mode",
20
+ process.env.BUILDCHAIN_CONTRACT_MODE || "artifact",
21
+ );
19
22
  if (mode === "runners") {
20
23
  const resolved = resolveRunnerMatrix({
21
24
  runnerPreset: process.env.BUILDCHAIN_RUNNER_PRESET || "github-hosted",
22
25
  platformsJson: process.env.BUILDCHAIN_PLATFORMS_JSON || "",
26
+ linuxContainerPreset: process.env.BUILDCHAIN_LINUX_CONTAINER_PRESET || "",
27
+ linuxContainerImage: process.env.BUILDCHAIN_LINUX_CONTAINER_IMAGE || "",
23
28
  });
24
29
  writeGitHubOutputs({
25
30
  "runner-preset": resolved.runnerPreset,
26
31
  "platforms-json": resolved.platformsJson,
27
32
  "platform-count": String(resolved.platformCount),
28
33
  "platform-source": resolved.source,
34
+ "native-platforms-json": resolved.nativePlatformsJson,
35
+ "native-platform-count": String(resolved.nativePlatformCount),
36
+ "container-platforms-json": resolved.containerPlatformsJson,
37
+ "container-platform-count": String(resolved.containerPlatformCount),
38
+ "linux-container-enabled": String(resolved.linuxContainer.enabled),
39
+ "linux-container-preset": resolved.linuxContainer.preset,
40
+ "linux-container-image": resolved.linuxContainer.image,
41
+ "linux-container-source": resolved.linuxContainer.source,
29
42
  });
30
43
  return resolved;
31
44
  }
32
45
  if (mode === "artifact") {
33
46
  const resolved = resolveArtifactContract({
34
- artifactName: process.env.BUILDCHAIN_ARTIFACT_NAME || "buildchain-artifact",
47
+ artifactName:
48
+ process.env.BUILDCHAIN_ARTIFACT_NAME || "buildchain-artifact",
35
49
  artifactNameTemplate:
36
- process.env.BUILDCHAIN_ARTIFACT_NAME_TEMPLATE || "{artifact}-{platform}-{sha}",
50
+ process.env.BUILDCHAIN_ARTIFACT_NAME_TEMPLATE ||
51
+ "{artifact}-{platform}-{sha}",
37
52
  platformId: process.env.BUILDCHAIN_PLATFORM_ID || "",
38
53
  platformName: process.env.BUILDCHAIN_PLATFORM_NAME || "",
39
54
  sha: process.env.BUILDCHAIN_SOURCE_SHA || process.env.GITHUB_SHA || "",
@@ -53,11 +68,16 @@ export function resolveBuildContractCli() {
53
68
  throw new Error(`unsupported build contract mode: ${mode}`);
54
69
  }
55
70
 
56
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
71
+ if (
72
+ process.argv[1] &&
73
+ import.meta.url === pathToFileURL(process.argv[1]).href
74
+ ) {
57
75
  try {
58
76
  resolveBuildContractCli();
59
77
  } catch (error) {
60
- console.error(`::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`);
78
+ console.error(
79
+ `::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`,
80
+ );
61
81
  process.exitCode = 1;
62
82
  }
63
83
  }