@kungfu-tech/buildchain 4.1.3-alpha.2 → 4.1.3-alpha.4

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.
Files changed (80) hide show
  1. package/architecture/action-taxonomy.json +8 -0
  2. package/architecture/agent-change-map.md +23 -0
  3. package/architecture/ci-lane-change-budget.json +85 -0
  4. package/architecture/decisions/0009-pipeline-version-preparation.md +186 -0
  5. package/architecture/internal-capabilities.json +32 -2
  6. package/architecture/maintainability-debt.json +10 -4
  7. package/architecture/maintainability-policy.json +7 -7
  8. package/architecture/minimal-consumer-migration.json +9 -0
  9. package/architecture/product-upstreams.json +20 -0
  10. package/architecture/release-topology.json +63 -25
  11. package/architecture/universal-workflow-bootstrap.json +2 -0
  12. package/architecture/universal-workflow-capability-policy.json +1 -1
  13. package/architecture/workflow-taxonomy.json +11 -0
  14. package/dist/readers/business-attempt.cjs +6 -2
  15. package/dist/site/buildchain-contract.json +4 -4
  16. package/dist/site/buildchain-site.json +9 -9
  17. package/dist/site/capability-registry.json +2 -2
  18. package/dist/site/kfd-claims.json +82 -8
  19. package/dist/site/kfd-upstream-aggregate.json +1 -1
  20. package/dist/site/manual-registry.json +1 -1
  21. package/dist/site/node-api-registry.json +18 -18
  22. package/dist/site/page-registry.json +4 -4
  23. package/dist/site/public-surface-audit.json +64 -7
  24. package/dist/site/publication-authority-registry.json +21 -1
  25. package/dist/site/publication-registry.json +4 -4
  26. package/dist/site/site-manifest.json +5 -5
  27. package/dist/site/workflow-registry.json +113 -7
  28. package/docs/node-api-reference.md +34 -34
  29. package/docs/workflow-catalog.md +1 -0
  30. package/package.json +1 -1
  31. package/packages/core/build/standalone/build.js +7 -1
  32. package/packages/core/consumer/buildchain-config.js +2 -21
  33. package/packages/core/consumer/contract/plan.js +53 -3
  34. package/packages/core/consumer/contract/products.js +20 -2
  35. package/packages/core/consumer/contract/reader.js +47 -0
  36. package/packages/core/dev-delivery/commands/project-cut-merge-queue-admission.mjs +47 -20
  37. package/packages/core/governance/buildchain-publication-authority.js +1 -0
  38. package/packages/core/providers/github/discussions/materials.js +12 -4
  39. package/packages/core/providers/github/pipeline-entry-runs.js +203 -0
  40. package/packages/core/providers/github/pipeline-policy.js +44 -17
  41. package/packages/core/providers/github/pipeline-release-evidence.js +111 -0
  42. package/packages/core/providers/github/pipeline-stable-source.js +354 -0
  43. package/packages/core/providers/github/pipeline-version-artifacts.js +91 -0
  44. package/packages/core/providers/github/pipeline-version-readback.js +117 -0
  45. package/packages/core/providers/github/pipeline-version.js +10 -12
  46. package/packages/core/publication/pipeline/actions.js +17 -1
  47. package/packages/core/publication/pipeline/apply.js +23 -17
  48. package/packages/core/publication/pipeline/development-pr.js +28 -1
  49. package/packages/core/publication/pipeline/documents.js +37 -1
  50. package/packages/core/publication/pipeline/next-development.js +1 -0
  51. package/packages/core/publication/pipeline/pack.js +7 -1
  52. package/packages/core/publication/pipeline/plan.js +22 -0
  53. package/packages/core/publication/pipeline/prepare.js +44 -56
  54. package/packages/core/publication/pipeline/qualification.js +5 -0
  55. package/packages/core/publication/pipeline/stable-entry.js +215 -0
  56. package/packages/core/publication/pipeline/stable-products.js +317 -0
  57. package/packages/core/publication/pipeline/stable-wait.js +170 -0
  58. package/packages/core/publication/pipeline/stable.js +82 -0
  59. package/packages/core/publication/pipeline/version-actions.js +93 -0
  60. package/packages/core/publication/pipeline/version-build.js +130 -0
  61. package/packages/core/publication/pipeline/version-context.js +143 -0
  62. package/packages/core/publication/pipeline/version-material.js +36 -0
  63. package/packages/core/publication/pipeline/version-preparation.js +69 -0
  64. package/packages/core/publication/pipeline/version-regeneration.js +162 -0
  65. package/packages/core/publication/pipeline/version-source-guard.js +69 -0
  66. package/packages/core/publication/pipeline/worker.js +72 -0
  67. package/packages/core/release/stable-release-gate.js +11 -0
  68. package/packages/core/workflow/attempt/identity.js +7 -2
  69. package/packages/core/workflow/pipeline/actions.js +14 -1
  70. package/packages/core/workflow/pipeline/build-result.js +8 -0
  71. package/packages/core/workflow/pipeline/build.js +49 -2
  72. package/packages/core/workflow/pipeline/channel-control.js +10 -1
  73. package/packages/core/workflow/pipeline/channel-source.js +53 -0
  74. package/packages/core/workflow/pipeline/group-control.js +2 -0
  75. package/packages/core/workflow/pipeline/host.js +24 -16
  76. package/packages/core/workflow/pipeline/selection.js +9 -4
  77. package/scripts/check-pipeline-publication-topology.mjs +12 -0
  78. package/scripts/check-release-topology.mjs +1 -0
  79. package/scripts/generate-site-bundle.mjs +1 -1
  80. package/scripts/verify-standalone-product.mjs +42 -0
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { recordDigest } from "../../release/discussion/envelope.js";
5
+ import {
6
+ inspectPipelineSource,
7
+ buildPipelineProducts,
8
+ } from "../../workflow/pipeline/build.js";
9
+ import { verifyPipelineVersionPreparation } from "./version-preparation.js";
10
+ import { guardPipelineVersionSource } from "./version-source-guard.js";
11
+ import { materializePipelineVersion } from "./version.js";
12
+ import {
13
+ pipelineVersionMaterialPaths,
14
+ pipelineVersionRegenerationResult,
15
+ collectPipelineVersionMaterial,
16
+ } from "./version-regeneration.js";
17
+
18
+ function materialFiles(cwd, paths) {
19
+ return Object.fromEntries(
20
+ paths.map((file) => {
21
+ const absolute = path.resolve(cwd, file);
22
+ const stat = fs.lstatSync(absolute);
23
+ if (
24
+ !stat.isFile() ||
25
+ stat.size > 4 * 1024 * 1024 ||
26
+ fs.realpathSync(absolute) !== absolute
27
+ )
28
+ throw new Error(
29
+ "Version preparation requires bounded regular source files without symlinks",
30
+ );
31
+ const bytes = fs.readFileSync(absolute);
32
+ const content = bytes.toString("utf8");
33
+ if (!Buffer.from(content).equals(bytes))
34
+ throw new Error("Version preparation cannot rewrite non-UTF8 material");
35
+ return [file, content];
36
+ }),
37
+ );
38
+ }
39
+
40
+ function trackedModes(cwd, paths) {
41
+ const entries = execFileSync(
42
+ "git",
43
+ ["-C", cwd, "ls-files", "--stage", "-z", "--", ...paths],
44
+ { encoding: "utf8" },
45
+ )
46
+ .split("\0")
47
+ .filter(Boolean);
48
+ const modes = Object.fromEntries(
49
+ entries.map((entry) => {
50
+ const match = /^(100644|100755) [0-9a-f]{40} 0\t(.+)$/u.exec(entry);
51
+ if (!match)
52
+ throw new Error("Version preparation requires tracked regular files");
53
+ return [match[2], match[1]];
54
+ }),
55
+ );
56
+ if (recordDigest(Object.keys(modes).sort()) !== recordDigest(paths))
57
+ throw new Error(
58
+ "Version preparation material is absent from the source tree",
59
+ );
60
+ return modes;
61
+ }
62
+
63
+ function assertPreparedDiff(cwd, paths, modes) {
64
+ const changes = execFileSync(
65
+ "git",
66
+ ["-C", cwd, "diff", "HEAD", "--name-status", "-z"],
67
+ { encoding: "utf8" },
68
+ )
69
+ .split("\0")
70
+ .filter(Boolean);
71
+ for (let index = 0; index < changes.length; index += 2)
72
+ if (changes[index] !== "M" || !paths.includes(changes[index + 1]))
73
+ throw new Error(
74
+ "Version product commands changed undeclared source paths",
75
+ );
76
+ if (recordDigest(trackedModes(cwd, paths)) !== recordDigest(modes))
77
+ throw new Error("Version product commands changed tracked file modes");
78
+ const raw = execFileSync("git", ["-C", cwd, "diff", "HEAD", "--summary"], {
79
+ encoding: "utf8",
80
+ });
81
+ if (raw.trim())
82
+ throw new Error(
83
+ "Version product commands changed source file kinds or modes",
84
+ );
85
+ }
86
+
87
+ export async function buildPipelineVersionMaterial({
88
+ cwd,
89
+ preparation,
90
+ platform,
91
+ environment = process.env,
92
+ }) {
93
+ cwd = fs.realpathSync(cwd);
94
+ const contract = inspectPipelineSource(cwd, preparation.source);
95
+ verifyPipelineVersionPreparation(preparation, contract);
96
+ if (environment.BUILDCHAIN_RUNTIME_SHA !== preparation.runtime.sha)
97
+ throw new Error(
98
+ "Version preparation is not using its admitted execution runtime",
99
+ );
100
+ const paths = pipelineVersionMaterialPaths(
101
+ preparation.versionPolicy,
102
+ preparation.source.configPath,
103
+ );
104
+ const modes = trackedModes(cwd, paths);
105
+ const guard = guardPipelineVersionSource(cwd, preparation.source, paths);
106
+ const before = materialFiles(cwd, paths);
107
+ const material = materializePipelineVersion(
108
+ preparation.versionPolicy,
109
+ before,
110
+ preparation.version,
111
+ );
112
+ // Product code runs only in this credentialless job. The provider writer
113
+ // independently observes job completion and verifies these exact bytes.
114
+ for (const { path: file, content } of material.changes)
115
+ fs.writeFileSync(path.join(cwd, file), content);
116
+ await buildPipelineProducts({ cwd, plan: contract, platform, environment });
117
+ guard();
118
+ assertPreparedDiff(cwd, paths, modes);
119
+ const result = pipelineVersionRegenerationResult(
120
+ preparation,
121
+ platform,
122
+ materialFiles(cwd, paths),
123
+ );
124
+ collectPipelineVersionMaterial(
125
+ { ...preparation, platforms: [platform] },
126
+ before,
127
+ [result],
128
+ );
129
+ return result;
130
+ }
@@ -0,0 +1,143 @@
1
+ import { recordDigest } from "../../release/discussion/envelope.js";
2
+ import { resumePipelineSession } from "../../workflow/pipeline/session.js";
3
+ import { pipelinePlatforms } from "../../workflow/pipeline/platforms.js";
4
+ import { githubPipelineVersion } from "../../providers/github/pipeline-version.js";
5
+ import { githubPipelineVersionArtifacts } from "../../providers/github/pipeline-version-artifacts.js";
6
+ import { pipelinePublicationJournal } from "./journal.js";
7
+ import {
8
+ planPipelineVersionPreparation,
9
+ verifyPipelineVersionPreparation,
10
+ } from "./version-preparation.js";
11
+ import { collectPipelineVersionMaterial } from "./version-regeneration.js";
12
+
13
+ const phaseOf = (purpose) =>
14
+ purpose === "publication" ? "publish" : "next-development";
15
+
16
+ export async function preparePipelineVersionContext(
17
+ parent,
18
+ purpose,
19
+ publication,
20
+ host,
21
+ journal,
22
+ definitionSha,
23
+ ) {
24
+ const admitted = await host.source.source(
25
+ parent.source.commit,
26
+ parent.source.configPath,
27
+ );
28
+ if (
29
+ recordDigest(admitted.identity) !== recordDigest(parent.source) ||
30
+ recordDigest(admitted.plan) !== publication.plan.contractRoot
31
+ )
32
+ throw new Error(
33
+ "Version preparation source or contract differs from its retained parent",
34
+ );
35
+ const preparation = planPipelineVersionPreparation({
36
+ attempt: publication.attempt,
37
+ generation: publication.generation,
38
+ source: parent.source,
39
+ contract: admitted.plan,
40
+ version: parent.version,
41
+ runtime: host.runtime,
42
+ purpose,
43
+ parentRoot: parent.root,
44
+ });
45
+ const context = {
46
+ schema: "buildchain.pipeline-version-context/v1",
47
+ attempt: publication.attempt,
48
+ generation: publication.generation,
49
+ preparation,
50
+ definitionSha,
51
+ runId: host.runId,
52
+ runAttempt: host.runAttempt,
53
+ platforms: pipelinePlatforms(admitted.plan),
54
+ publication,
55
+ };
56
+ await journal.record(`publication/version-${purpose}-context`, context, {
57
+ phase: phaseOf(purpose),
58
+ });
59
+ return context;
60
+ }
61
+
62
+ export async function qualifyPipelineVersionContext(
63
+ context,
64
+ host,
65
+ directory,
66
+ artifactClient,
67
+ ) {
68
+ if (
69
+ context.schema !== "buildchain.pipeline-version-context/v1" ||
70
+ context.runId !== host.runId ||
71
+ context.runAttempt !== host.runAttempt
72
+ )
73
+ throw new Error(
74
+ "Version context belongs to a different provider execution",
75
+ );
76
+ const session = await resumePipelineSession(
77
+ { ...host, attempt: context.attempt },
78
+ host,
79
+ );
80
+ const journal = pipelinePublicationJournal(session, host);
81
+ await journal.fence();
82
+ const { preparation } = context;
83
+ if (
84
+ context.generation !== session.observed.generation ||
85
+ preparation.attempt !== context.attempt ||
86
+ preparation.generation !== context.generation ||
87
+ recordDigest(preparation.runtime) !== recordDigest(host.runtime)
88
+ )
89
+ throw new Error(
90
+ "Version context changed its admitted attempt, generation or runtime",
91
+ );
92
+ const retained = await journal.materials(
93
+ `publication/version-${preparation.purpose}-context/`,
94
+ );
95
+ if (!retained.some((value) => recordDigest(value) === recordDigest(context)))
96
+ throw new Error(
97
+ "Version context is not retained in the authoritative attempt",
98
+ );
99
+ const admitted = await host.source.source(
100
+ preparation.source.commit,
101
+ preparation.source.configPath,
102
+ );
103
+ if (recordDigest(admitted.identity) !== recordDigest(preparation.source))
104
+ throw new Error("Version context source changed before qualification");
105
+ verifyPipelineVersionPreparation(preparation, admitted.plan);
106
+ const artifacts = githubPipelineVersionArtifacts(host, artifactClient);
107
+ const observed = await artifacts.download(context, directory);
108
+ const adapter = githubPipelineVersion(host.request, host.repository);
109
+ const before = await adapter.inspect(
110
+ preparation.source,
111
+ preparation.versionPolicy,
112
+ );
113
+ collectPipelineVersionMaterial(preparation, before.files, observed.results);
114
+ const regeneration = { context, ...observed };
115
+ await journal.fence();
116
+ await journal.record(
117
+ `publication/version-${preparation.purpose}-qualified`,
118
+ regeneration,
119
+ { phase: phaseOf(preparation.purpose) },
120
+ );
121
+ return { session, journal, regeneration };
122
+ }
123
+
124
+ export async function retainedPipelineVersionRegeneration(
125
+ journal,
126
+ parent,
127
+ purpose,
128
+ runtime,
129
+ ) {
130
+ const values = (
131
+ await journal.materials(`publication/version-${purpose}-qualified/`)
132
+ ).filter(
133
+ ({ context }) =>
134
+ context.preparation.parentRoot === parent.root &&
135
+ recordDigest(context.preparation.runtime) === recordDigest(runtime),
136
+ );
137
+ if (
138
+ values.length > 1 &&
139
+ new Set(values.map(({ results }) => recordDigest(results))).size !== 1
140
+ )
141
+ throw new Error("Version regeneration has conflicting qualified bytes");
142
+ return values.at(-1);
143
+ }
@@ -0,0 +1,36 @@
1
+ import { recordDigest } from "../../release/discussion/envelope.js";
2
+ import { collectPipelineVersionMaterial } from "./version-regeneration.js";
3
+ import { materializePipelineVersion } from "./version.js";
4
+
5
+ // Provider execution is qualified and journaled by version-context before this
6
+ // pure byte check. A self-reported digest never grants Git write authority.
7
+ export function preparedPipelineVersionMaterial(plan, files, regeneration) {
8
+ if (!regeneration) {
9
+ if (plan.versionPolicy.derived_files?.length)
10
+ throw new Error(
11
+ "Derived version files require retained qualified regeneration",
12
+ );
13
+ return materializePipelineVersion(plan.versionPolicy, files, plan.version);
14
+ }
15
+ const { context, build, results } = regeneration;
16
+ const { preparation } = context;
17
+ const { root: buildRoot, ...buildBody } = build;
18
+ if (
19
+ preparation.parentRoot !== plan.root ||
20
+ preparation.version !== plan.version ||
21
+ recordDigest(preparation.source) !== recordDigest(plan.source) ||
22
+ recordDigest(preparation.versionPolicy) !==
23
+ recordDigest(plan.versionPolicy) ||
24
+ build.preparationRoot !== preparation.root ||
25
+ build.runId !== context.runId ||
26
+ build.runAttempt !== context.runAttempt ||
27
+ buildRoot !== recordDigest(buildBody)
28
+ )
29
+ throw new Error(
30
+ "Qualified regeneration does not belong to this exact version materialization",
31
+ );
32
+ return {
33
+ ...collectPipelineVersionMaterial(preparation, files, results),
34
+ regenerationBuildRoot: buildRoot,
35
+ };
36
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ recordDigest,
3
+ validateRuntime,
4
+ } from "../../release/discussion/envelope.js";
5
+ import { validateConsumerSource } from "../../workflow/attempt/identity.js";
6
+ import { pipelinePlatforms } from "../../workflow/pipeline/platforms.js";
7
+ import { choice, text } from "../../consumer/contract/shape.js";
8
+ import { pipelineVersionMaterialPaths } from "./version-regeneration.js";
9
+
10
+ export function planPipelineVersionPreparation({
11
+ attempt,
12
+ generation,
13
+ source,
14
+ contract,
15
+ version,
16
+ runtime,
17
+ purpose,
18
+ parentRoot,
19
+ }) {
20
+ text(attempt, "version preparation attempt", /^attempt-[0-9a-f]{64}$/u);
21
+ text(generation, "version preparation generation", /^sha256:[0-9a-f]{64}$/u);
22
+ text(parentRoot, "version preparation parent", /^sha256:[0-9a-f]{64}$/u);
23
+ text(
24
+ source.repository,
25
+ "version preparation repository",
26
+ /^[\w.-]+\/[\w.-]+$/u,
27
+ );
28
+ validateConsumerSource(source, source.repository);
29
+ validateRuntime(runtime);
30
+ choice(
31
+ runtime.repository,
32
+ ["kungfu-systems/buildchain"],
33
+ "version preparation runtime",
34
+ );
35
+ choice(
36
+ purpose,
37
+ ["publication", "development"],
38
+ "version preparation purpose",
39
+ );
40
+ text(
41
+ version,
42
+ "version preparation target",
43
+ /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u,
44
+ );
45
+ pipelineVersionMaterialPaths(contract.version, source.configPath);
46
+ const body = {
47
+ schema: "buildchain.pipeline-version-preparation/v1",
48
+ attempt,
49
+ generation,
50
+ source,
51
+ version,
52
+ runtime,
53
+ purpose,
54
+ parentRoot,
55
+ contractRoot: recordDigest(contract),
56
+ versionPolicy: contract.version,
57
+ platforms: pipelinePlatforms(contract).map(({ platform }) => platform),
58
+ };
59
+ return structuredClone({ ...body, root: recordDigest(body) });
60
+ }
61
+
62
+ export function verifyPipelineVersionPreparation(preparation, contract) {
63
+ const expected = planPipelineVersionPreparation({ ...preparation, contract });
64
+ if (recordDigest(preparation) !== recordDigest(expected))
65
+ throw new Error(
66
+ "Version preparation changed its source-bound product contract or root",
67
+ );
68
+ return preparation;
69
+ }
@@ -0,0 +1,162 @@
1
+ import { recordDigest } from "../../release/discussion/envelope.js";
2
+ import { object, relativePath } from "../../consumer/contract/shape.js";
3
+ import { materializePipelineVersion } from "./version.js";
4
+
5
+ export function pipelineVersionMaterialPaths(policy, configPath) {
6
+ const primary = [...new Set(policy.files.map(({ path }) => path))];
7
+ const derived = policy.derived_files || [];
8
+ const paths = [...primary, ...derived];
9
+ if (
10
+ !paths.length ||
11
+ paths.length > 128 ||
12
+ new Set(paths).size !== paths.length
13
+ )
14
+ throw new Error(
15
+ "Version material requires a bounded disjoint path inventory",
16
+ );
17
+ for (const file of paths) {
18
+ relativePath(file, "version material path");
19
+ if (
20
+ file.includes("*") ||
21
+ file === configPath ||
22
+ [
23
+ ".buildchain/contract-lock.json",
24
+ ".buildchain/alpha-contract-lock.json",
25
+ ].includes(file) ||
26
+ /^\.(?:git|github)\//u.test(file)
27
+ )
28
+ throw new Error(
29
+ "Version material cannot rewrite its source contract or workflow authority",
30
+ );
31
+ }
32
+ return paths.sort();
33
+ }
34
+
35
+ function checkFiles(files, paths) {
36
+ if (recordDigest(Object.keys(files).sort()) !== recordDigest(paths))
37
+ throw new Error("Version regeneration changed its declared file inventory");
38
+ let total = 0;
39
+ for (const file of paths) {
40
+ if (
41
+ typeof files[file] !== "string" ||
42
+ Buffer.byteLength(files[file]) > 4 * 1024 * 1024
43
+ )
44
+ throw new Error("Version regeneration requires bounded text files");
45
+ total += Buffer.byteLength(files[file]);
46
+ }
47
+ if (total > 64 * 1024 * 1024)
48
+ throw new Error(
49
+ "Version regeneration exceeds its complete material byte limit",
50
+ );
51
+ }
52
+
53
+ // This is an observation from a credentialless product job. A provider adapter
54
+ // must qualify its exact producer execution before passing it to collection.
55
+ export function pipelineVersionRegenerationResult(
56
+ preparation,
57
+ platform,
58
+ files,
59
+ ) {
60
+ const paths = pipelineVersionMaterialPaths(
61
+ preparation.versionPolicy,
62
+ preparation.source.configPath,
63
+ );
64
+ checkFiles(files, paths);
65
+ if (!preparation.platforms.includes(platform))
66
+ throw new Error("Version regeneration platform was not declared");
67
+ const body = {
68
+ schema: "buildchain.pipeline-version-regeneration/v1",
69
+ preparationRoot: preparation.root,
70
+ source: preparation.source,
71
+ platform,
72
+ version: preparation.version,
73
+ files,
74
+ };
75
+ return { ...body, root: recordDigest(body) };
76
+ }
77
+
78
+ function qualifiedFiles(preparation, result, paths) {
79
+ object(result, [
80
+ "schema",
81
+ "preparationRoot",
82
+ "source",
83
+ "platform",
84
+ "version",
85
+ "files",
86
+ "root",
87
+ ]);
88
+ const expected = pipelineVersionRegenerationResult(
89
+ preparation,
90
+ result.platform,
91
+ result.files,
92
+ );
93
+ if (recordDigest(result) !== recordDigest(expected))
94
+ throw new Error(
95
+ "Version regeneration changed its preparation, source, version or result root",
96
+ );
97
+ checkFiles(result.files, paths);
98
+ return result.files;
99
+ }
100
+
101
+ export function collectPipelineVersionMaterial(preparation, files, results) {
102
+ const policy = preparation.versionPolicy;
103
+ const paths = pipelineVersionMaterialPaths(
104
+ policy,
105
+ preparation.source.configPath,
106
+ );
107
+ checkFiles(files, paths);
108
+ const expectedPlatforms = [...new Set(preparation.platforms)].sort();
109
+ const actualPlatforms = results.map(({ platform }) => platform).sort();
110
+ if (
111
+ recordDigest(actualPlatforms) !== recordDigest(expectedPlatforms) ||
112
+ !actualPlatforms.length
113
+ )
114
+ throw new Error(
115
+ "Version regeneration requires every declared platform exactly once",
116
+ );
117
+ const material = materializePipelineVersion(
118
+ policy,
119
+ files,
120
+ preparation.version,
121
+ );
122
+ const primary = new Set(policy.files.map(({ path }) => path));
123
+ const expected = {
124
+ ...files,
125
+ ...Object.fromEntries(
126
+ material.changes.map(({ path, content }) => [path, content]),
127
+ ),
128
+ };
129
+ const derived = new Map();
130
+ for (const result of results) {
131
+ const observed = qualifiedFiles(preparation, result, paths);
132
+ for (const file of paths) {
133
+ if (primary.has(file)) {
134
+ if (observed[file] !== expected[file])
135
+ throw new Error(
136
+ "Product commands changed bytes outside the planned version fields",
137
+ );
138
+ } else if (observed[file] !== files[file]) {
139
+ if (derived.has(file) && derived.get(file) !== observed[file])
140
+ throw new Error(
141
+ "Platforms produced conflicting derived version bytes",
142
+ );
143
+ derived.set(file, observed[file]);
144
+ }
145
+ }
146
+ }
147
+ const changes = [...derived].map(([path, content]) => ({
148
+ path,
149
+ before: recordDigest(files[path]),
150
+ content,
151
+ after: recordDigest(content),
152
+ }));
153
+ return {
154
+ ...material,
155
+ changes: [...material.changes, ...changes].sort((left, right) =>
156
+ left.path.localeCompare(right.path),
157
+ ),
158
+ regenerationRoots: results
159
+ .toSorted((left, right) => left.platform.localeCompare(right.platform))
160
+ .map(({ root }) => root),
161
+ };
162
+ }
@@ -0,0 +1,69 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+ import { recordDigest } from "../../release/discussion/envelope.js";
6
+
7
+ function git(cwd, ...args) {
8
+ return execFileSync("git", ["-C", cwd, ...args], {
9
+ encoding: "utf8",
10
+ maxBuffer: 16 * 1024 * 1024,
11
+ });
12
+ }
13
+
14
+ function fileState(cwd, file) {
15
+ const absolute = path.join(cwd, file);
16
+ if (fs.realpathSync(path.dirname(absolute)) !== path.dirname(absolute))
17
+ throw new Error(
18
+ "Version source parent directories cannot become symbolic links",
19
+ );
20
+ const stat = fs.lstatSync(absolute);
21
+ const bytes = stat.isSymbolicLink()
22
+ ? Buffer.from(fs.readlinkSync(absolute))
23
+ : stat.isFile()
24
+ ? fs.readFileSync(absolute)
25
+ : null;
26
+ if (!bytes)
27
+ throw new Error(
28
+ "Version preparation requires regular tracked source or symbolic links",
29
+ );
30
+ return {
31
+ kind: stat.isSymbolicLink() ? "link" : "file",
32
+ executable:
33
+ process.platform === "win32" ? null : Boolean(stat.mode & 0o111),
34
+ digest: createHash("sha256").update(bytes).digest("hex"),
35
+ };
36
+ }
37
+
38
+ export function guardPipelineVersionSource(cwd, source, allowedPaths) {
39
+ const entries = git(cwd, "ls-tree", "-r", "-z", source.commit)
40
+ .split("\0")
41
+ .filter(Boolean);
42
+ const files = entries.map((entry) => {
43
+ const match =
44
+ /^(?:100644|100755|120000) blob [0-9a-f]{40}\t([\s\S]+)$/u.exec(entry);
45
+ if (!match)
46
+ throw new Error(
47
+ "Version preparation cannot rewrite a source containing unresolved submodules",
48
+ );
49
+ return match[1];
50
+ });
51
+ const before = new Map(files.map((file) => [file, fileState(cwd, file)]));
52
+ return () => {
53
+ const current = git(cwd, "rev-parse", "HEAD", "HEAD^{tree}")
54
+ .trim()
55
+ .split(/\r?\n/u);
56
+ if (recordDigest(current) !== recordDigest([source.commit, source.tree]))
57
+ throw new Error(
58
+ "Version product commands changed their admitted Git HEAD or tree",
59
+ );
60
+ for (const [file, expected] of before) {
61
+ const current = fileState(cwd, file);
62
+ if (allowedPaths.includes(file)) current.digest = expected.digest;
63
+ if (recordDigest(current) !== recordDigest(expected))
64
+ throw new Error(
65
+ `Version product commands changed undeclared source bytes: ${file}`,
66
+ );
67
+ }
68
+ };
69
+ }
@@ -0,0 +1,72 @@
1
+ import { resumePipelineSession } from "../../workflow/pipeline/session.js";
2
+ import { pipelinePublicationJournal } from "./journal.js";
3
+ import { retainedPipelineStableWait } from "./stable-wait.js";
4
+
5
+ export async function claimPipelinePublicationWorker(
6
+ attempt,
7
+ publisherSha,
8
+ host,
9
+ ) {
10
+ const { run: execution } = await host.runs.read(host.runId, host.runAttempt);
11
+ const definitions = (execution.referenced_workflows || []).filter((entry) =>
12
+ entry.path?.startsWith(
13
+ "kungfu-systems/buildchain/.github/workflows/.release-pipeline-products.yml@",
14
+ ),
15
+ );
16
+ if (
17
+ !/^[0-9a-f]{40}$/u.test(publisherSha || "") ||
18
+ definitions.length !== 1 ||
19
+ definitions[0].sha !== publisherSha
20
+ )
21
+ throw new Error(
22
+ "Publisher definition is not the exact hosted reusable workflow",
23
+ );
24
+ const session = await resumePipelineSession({ ...host, attempt }, host);
25
+ const observed = session.observed;
26
+ if (observed.phases.merge?.payload.state !== "success")
27
+ throw new Error(
28
+ "Product publication requires completed protected integration",
29
+ );
30
+ if (observed.status === "complete") return null;
31
+ const phase = observed.missing[0];
32
+ if (!["publish", "distribution", "next-development"].includes(phase))
33
+ throw new Error("Attempt is not at product publication");
34
+ const prior = observed.history
35
+ .at(-1)
36
+ .events.filter((event) =>
37
+ event.payload.materials.some((material) =>
38
+ material.id.startsWith("publication/worker/"),
39
+ ),
40
+ )
41
+ .at(-1)?.payload.writer;
42
+ if (
43
+ prior &&
44
+ (prior.runId !== String(host.runId) ||
45
+ prior.runAttempt !== String(host.runAttempt))
46
+ ) {
47
+ const { run } = await host.runs.read(
48
+ Number(prior.runId),
49
+ Number(prior.runAttempt),
50
+ );
51
+ // A time-only wait releases publication ownership before its outer run ends.
52
+ // The next worker still claims the exact native head under the publication lock.
53
+ if (
54
+ run.status !== "completed" &&
55
+ !(await retainedPipelineStableWait(observed, host))
56
+ )
57
+ return null;
58
+ }
59
+ const journal = pipelinePublicationJournal(session, host);
60
+ const claim = {
61
+ schema: "buildchain.pipeline-publication-worker/v1",
62
+ attempt,
63
+ runId: host.runId,
64
+ runAttempt: host.runAttempt,
65
+ };
66
+ await journal.record("publication/worker", claim, {
67
+ phase,
68
+ expectedHead: observed.head,
69
+ });
70
+ await journal.fence();
71
+ return { session, journal, phase };
72
+ }