@kungfu-tech/buildchain 2.6.2 → 2.7.0-alpha.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.
@@ -8,6 +8,7 @@ import { initBuildchainRepo } from "../scripts/init-repo.mjs";
8
8
  import { npmPublishDryRun } from "../scripts/npm-publish-dry-run.mjs";
9
9
  import { runLifecycle } from "../scripts/run-lifecycle-core.mjs";
10
10
  import { verifyInfraContractEvidenceBundle } from "../scripts/infra-contract-core.mjs";
11
+ import { runReleasePropagationCli } from "../scripts/release-propagation.mjs";
11
12
  import { validateBuildchainConfig } from "../packages/core/buildchain-config.js";
12
13
  import { detectPackageManager } from "../packages/core/package-manager.js";
13
14
  import {
@@ -107,6 +108,7 @@ function usage() {
107
108
  [--path <jsonl>] -- <command> [args...]
108
109
  buildchain web-surface ...
109
110
  buildchain infra-contract ...
111
+ buildchain release-propagation <plan|write-lock> ...
110
112
  buildchain publish-source <lock|manifest|verify-lock|verify-channel-ref|validate-anchored-release> ...
111
113
  buildchain build-contract ...
112
114
 
@@ -129,6 +131,7 @@ Examples:
129
131
  buildchain infra-contract --mode apply --plan <plan.json> --source-sha <sha> --approval-id <id> --dry-run false --execute-adapter-commands true
130
132
  buildchain infra-contract --mode propagation-apply --propagation-plan <plan.json> --dry-run true
131
133
  buildchain infra-contract --mode evidence-bundle --artifact <artifact.json> --propagation-result <result.json>
134
+ buildchain release-propagation plan --graph graph.json --upstream-release release.json --json
132
135
  `;
133
136
  }
134
137
 
@@ -930,6 +933,11 @@ async function main(argv = process.argv.slice(2)) {
930
933
  return;
931
934
  }
932
935
 
936
+ if (command === "release-propagation") {
937
+ runReleasePropagationCli(args);
938
+ return;
939
+ }
940
+
933
941
  if (command === "build-contract") {
934
942
  runScript("resolve-build-contract.mjs", args);
935
943
  return;
@@ -38,6 +38,13 @@
38
38
  "plane": "verify",
39
39
  "exists": true
40
40
  },
41
+ {
42
+ "id": "release-propagation",
43
+ "title": "Release propagation",
44
+ "path": "docs/release-propagation.md",
45
+ "plane": "use",
46
+ "exists": true
47
+ },
41
48
  {
42
49
  "id": "binary-distribution",
43
50
  "title": "Binary distribution contract",
@@ -149,6 +156,16 @@
149
156
  "bundle": "buildchain-release-bundle.tar.gz",
150
157
  "contract": "kungfu-buildchain-release-passport"
151
158
  },
159
+ "releasePropagation": {
160
+ "graphContract": "kungfu-buildchain-release-propagation-graph",
161
+ "planContract": "kungfu-buildchain-release-propagation-plan",
162
+ "lockContract": "kungfu-buildchain-release-propagation-lock",
163
+ "defaultChannelPolicy": "preserve",
164
+ "defaultChannelMap": {
165
+ "alpha": "alpha",
166
+ "release": "release"
167
+ }
168
+ },
152
169
  "npm": {
153
170
  "package": "@kungfu-tech/buildchain",
154
171
  "command": "./bin/buildchain.mjs",
@@ -44,6 +44,11 @@
44
44
  "usage": "buildchain verify release-passport <file-or-url>",
45
45
  "purpose": "Fail closed unless a release passport and its evidence are complete."
46
46
  },
47
+ {
48
+ "id": "release-propagation",
49
+ "usage": "buildchain release-propagation <plan|write-lock>",
50
+ "purpose": "Plan channel-preserving downstream release PRs and write exact upstream release locks."
51
+ },
47
52
  {
48
53
  "id": "verify-infra-contract-evidence-bundle",
49
54
  "usage": "buildchain verify infra-contract-evidence-bundle <file>",
@@ -17,6 +17,16 @@
17
17
  "bundle": "buildchain-release-bundle.tar.gz",
18
18
  "contract": "kungfu-buildchain-release-passport"
19
19
  },
20
+ "releasePropagation": {
21
+ "graphContract": "kungfu-buildchain-release-propagation-graph",
22
+ "planContract": "kungfu-buildchain-release-propagation-plan",
23
+ "lockContract": "kungfu-buildchain-release-propagation-lock",
24
+ "defaultChannelPolicy": "preserve",
25
+ "defaultChannelMap": {
26
+ "alpha": "alpha",
27
+ "release": "release"
28
+ }
29
+ },
20
30
  "npm": {
21
31
  "package": "@kungfu-tech/buildchain",
22
32
  "command": "./bin/buildchain.mjs",
@@ -16,6 +16,7 @@
16
16
  "./logging": "./packages/core/logging.js",
17
17
  "./release-candidate": "./packages/core/release-candidate.js",
18
18
  "./release-passport": "./packages/core/release-passport.js",
19
+ "./release-propagation": "./packages/core/release-propagation.js",
19
20
  "./site/buildchain-site.json": "./dist/site/buildchain-site.json",
20
21
  "./site/site-manifest.json": "./dist/site/site-manifest.json",
21
22
  "./site/cli-registry.json": "./dist/site/cli-registry.json",
@@ -26,6 +26,13 @@
26
26
  "plane": "verify",
27
27
  "exists": true
28
28
  },
29
+ {
30
+ "id": "release-propagation",
31
+ "title": "Release propagation",
32
+ "path": "docs/release-propagation.md",
33
+ "plane": "use",
34
+ "exists": true
35
+ },
29
36
  {
30
37
  "id": "binary-distribution",
31
38
  "title": "Binary distribution contract",
@@ -20,6 +20,12 @@
20
20
  "surface": "release-governance",
21
21
  "status": "active"
22
22
  },
23
+ {
24
+ "id": "release-propagation",
25
+ "path": ".github/workflows/release-propagation.yml",
26
+ "surface": "release-propagation",
27
+ "status": "preview"
28
+ },
23
29
  {
24
30
  "id": "dev-pr-auto-merge",
25
31
  "path": ".github/workflows/dev-pr-auto-merge.yml",
package/docs/MAP.md CHANGED
@@ -33,6 +33,7 @@ running artifact), *use* (consume / extend) - and a **status**:
33
33
  | How do I declare version files and custom lifecycle commands? | [`lifecycle-protocol.md`](lifecycle-protocol.md) | use | stable |
34
34
  | How does publish evidence, recovery, and finalization work? | [`publish-transaction.md`](publish-transaction.md) | verify | stable |
35
35
  | How do I publish or verify release passport artifacts? | [`release-passport.md`](release-passport.md) | use | stable |
36
+ | How do I propagate finalized upstream releases to downstream package/site PRs? | [`release-propagation.md`](release-propagation.md) | use | preview |
36
37
  | How do I prove a PR-stage reusable build is the artifact source promoted later? | [`release-candidate.md`](release-candidate.md) + [`reusable-build-surface.md`](reusable-build-surface.md) | verify | stable |
37
38
  | Why are binary release assets archived by platform, and where is the single bundle? | [`binary-distribution.md`](binary-distribution.md) | verify | stable |
38
39
  | How do I add timestamped logs inside build scripts? | [`toolkit-observability.md`](toolkit-observability.md) | use | stable |
@@ -87,6 +88,8 @@ running artifact), *use* (consume / extend) - and a **status**:
87
88
  and [`release-passport.md`](release-passport.md).
88
89
  - **Trusted Publishing / npm / publish evidence / recovery** ->
89
90
  [`cli.md`](cli.md) and [`publish-transaction.md`](publish-transaction.md).
91
+ - **release chains / upstream package as source of truth / site synchronization** ->
92
+ [`release-propagation.md`](release-propagation.md).
90
93
  - **GitHub Release passport / binary assets / artifact evidence / agent release checks** ->
91
94
  [`release-passport.md`](release-passport.md),
92
95
  [`binary-distribution.md`](binary-distribution.md), and [`cli.md`](cli.md).
@@ -0,0 +1,156 @@
1
+ # Release Propagation
2
+
3
+ Release propagation lets a finalized upstream release open a downstream update
4
+ PR using the upstream release passport as the audit source. It is for product
5
+ chains such as:
6
+
7
+ ```text
8
+ kfd -> site-libkungfu-dev
9
+ A -> B -> C
10
+ ```
11
+
12
+ The downstream repository receives an exact lock, not a floating dist-tag. A
13
+ site or app can then consume the upstream package, site bundle, or release
14
+ passport as its single source of truth without hand-copying release facts.
15
+
16
+ ## Contract
17
+
18
+ The propagation graph is declarative JSON:
19
+
20
+ ```json
21
+ {
22
+ "schemaVersion": 1,
23
+ "contract": "kungfu-buildchain-release-propagation-graph",
24
+ "nodes": [
25
+ {
26
+ "id": "kfd",
27
+ "repository": "kungfu-systems/kfd",
28
+ "package": "@kungfu-tech/kfd"
29
+ },
30
+ {
31
+ "id": "site-libkungfu-dev",
32
+ "repository": "kungfu-systems/site-libkungfu-dev",
33
+ "lockPath": "buildchain.upstreams/kfd.release.json",
34
+ "baseRef": "dev/v2/v2.7"
35
+ }
36
+ ],
37
+ "edges": [
38
+ {
39
+ "id": "kfd-to-site",
40
+ "from": "kfd",
41
+ "to": "site-libkungfu-dev",
42
+ "channelPolicy": "preserve"
43
+ }
44
+ ]
45
+ }
46
+ ```
47
+
48
+ `channelPolicy: "preserve"` is the default and maps:
49
+
50
+ ```text
51
+ alpha -> alpha
52
+ release -> release
53
+ ```
54
+
55
+ Cross-channel mapping is allowed only when an edge declares
56
+ `channelPolicy: "explicit"` and a `channelMap`. Buildchain rejects cycles so a
57
+ chain can fan out or continue as `A -> B -> C`, but cannot loop back into an
58
+ already visited release line.
59
+
60
+ ## Upstream Release Envelope
61
+
62
+ The upstream release envelope is the post-finalization fact set:
63
+
64
+ ```json
65
+ {
66
+ "repository": "kungfu-systems/kfd",
67
+ "channel": "alpha",
68
+ "tag": "v1.4.0-alpha.3",
69
+ "sourceSha": "1111111111111111111111111111111111111111",
70
+ "package": {
71
+ "name": "@kungfu-tech/kfd",
72
+ "version": "1.4.0-alpha.3",
73
+ "integrity": "sha512-..."
74
+ },
75
+ "releasePassport": {
76
+ "url": "https://github.com/kungfu-systems/kfd/releases/download/v1.4.0-alpha.3/buildchain.release.json",
77
+ "sha256": "2222222222222222222222222222222222222222222222222222222222222222"
78
+ },
79
+ "siteBundle": {
80
+ "manifestSha256": "3333333333333333333333333333333333333333333333333333333333333333"
81
+ }
82
+ }
83
+ ```
84
+
85
+ The package version and integrity must be exact. Downstream build logic should
86
+ install that version directly, not resolve `alpha` or `latest` again.
87
+
88
+ ## CLI
89
+
90
+ Generate a propagation plan:
91
+
92
+ ```bash
93
+ buildchain release-propagation plan \
94
+ --graph buildchain.release-propagation.json \
95
+ --upstream-release .buildchain/upstream-release.json \
96
+ --output .buildchain/release-propagation-plan.json \
97
+ --json
98
+ ```
99
+
100
+ Write the downstream lock:
101
+
102
+ ```bash
103
+ buildchain release-propagation write-lock \
104
+ --plan .buildchain/release-propagation-plan.json \
105
+ --target site-libkungfu-dev \
106
+ --cwd downstream-checkout \
107
+ --json
108
+ ```
109
+
110
+ The written lock has contract
111
+ `kungfu-buildchain-release-propagation-lock` and records:
112
+
113
+ - upstream repository, channel, exact tag, source SHA;
114
+ - npm package name, exact version, and sha512 integrity;
115
+ - release passport URL and SHA-256;
116
+ - optional site bundle manifest SHA-256;
117
+ - downstream repository, channel, base ref, lock path;
118
+ - edge id and channel policy.
119
+
120
+ ## Reusable Workflow
121
+
122
+ Upstream repositories can call
123
+ `.github/workflows/release-propagation.yml@v2` after release finalization:
124
+
125
+ ```yaml
126
+ jobs:
127
+ propagate-site:
128
+ uses: kungfu-systems/buildchain/.github/workflows/release-propagation.yml@v2
129
+ with:
130
+ graph-json: ${{ needs.release.outputs.propagation-graph-json }}
131
+ upstream-release-json: ${{ needs.release.outputs.upstream-release-json }}
132
+ downstream-target: site-libkungfu-dev
133
+ downstream-repository: kungfu-systems/site-libkungfu-dev
134
+ downstream-base-ref: dev/v2/v2.7
135
+ dry-run: false
136
+ secrets:
137
+ propagation-token: ${{ secrets.BUILDCHAIN_PROMOTION_TOKEN }}
138
+ ```
139
+
140
+ The workflow plans propagation, checks out the downstream repository, writes the
141
+ exact lock, and opens or updates a PR. It does not publish the downstream
142
+ release directly. The downstream repository keeps its normal Buildchain
143
+ governance: the PR updates source-of-truth facts, then downstream alpha or
144
+ release publication runs through its own protected channel.
145
+
146
+ ## kfd to site-libkungfu-dev
147
+
148
+ For `kfd -> site-libkungfu-dev`, the graph should preserve channels:
149
+
150
+ - a `kfd` alpha release produces a downstream alpha lock and downstream alpha
151
+ publication consumes the exact `@kungfu-tech/kfd@...-alpha.N` package;
152
+ - a `kfd` stable release produces a downstream release lock and downstream
153
+ stable publication consumes the exact stable package.
154
+
155
+ This keeps the site synchronized to the package truth without allowing the site
156
+ to drift onto a floating npm dist-tag.
@@ -68,3 +68,7 @@ The P0 bundle includes:
68
68
  Future minor lines can add examples, recipes, fixture indexes, and richer
69
69
  schema metadata without breaking existing consumers.
70
70
 
71
+ `release-propagation.md` describes the package-to-package or package-to-site
72
+ release chain model. The site bundle exposes that document and the
73
+ `release-propagation` CLI entry so downstream sites can render the current
74
+ Buildchain-owned propagation contract instead of hand-writing it.
@@ -0,0 +1,6 @@
1
+ # Release Propagation Fixture
2
+
3
+ This fixture demonstrates a generic release propagation graph with a
4
+ `kfd -> site-libkungfu-dev` shaped edge. It is safe test data only; tests use it
5
+ to prove that Buildchain preserves alpha/release channels and writes an exact
6
+ upstream release lock.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.6.2",
3
+ "version": "2.7.0-alpha.0",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -19,6 +19,7 @@
19
19
  "./logging": "./packages/core/logging.js",
20
20
  "./release-candidate": "./packages/core/release-candidate.js",
21
21
  "./release-passport": "./packages/core/release-passport.js",
22
+ "./release-propagation": "./packages/core/release-propagation.js",
22
23
  "./site/buildchain-site.json": "./dist/site/buildchain-site.json",
23
24
  "./site/site-manifest.json": "./dist/site/site-manifest.json",
24
25
  "./site/cli-registry.json": "./dist/site/cli-registry.json",
@@ -52,6 +53,7 @@
52
53
  "docs/product-mechanism.md",
53
54
  "docs/publish-transaction.md",
54
55
  "docs/release-passport.md",
56
+ "docs/release-propagation.md",
55
57
  "docs/release-candidate.md",
56
58
  "docs/release-flow.md",
57
59
  "docs/release-governance.md",
@@ -143,3 +143,15 @@ export {
143
143
  truncateUtf8,
144
144
  workflowFrictionMarker,
145
145
  } from "./issue-reporting.js";
146
+
147
+ export {
148
+ RELEASE_PROPAGATION_GRAPH_CONTRACT,
149
+ RELEASE_PROPAGATION_LOCK_CONTRACT,
150
+ RELEASE_PROPAGATION_PLAN_CONTRACT,
151
+ createReleasePropagationLock,
152
+ normalizeReleasePropagationGraph,
153
+ planReleasePropagation,
154
+ readReleasePropagationJson,
155
+ resolvePropagationChannel,
156
+ writeReleasePropagationLock,
157
+ } from "./release-propagation.js";
@@ -0,0 +1,354 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const RELEASE_PROPAGATION_GRAPH_CONTRACT = "kungfu-buildchain-release-propagation-graph";
6
+ export const RELEASE_PROPAGATION_PLAN_CONTRACT = "kungfu-buildchain-release-propagation-plan";
7
+ export const RELEASE_PROPAGATION_LOCK_CONTRACT = "kungfu-buildchain-release-propagation-lock";
8
+
9
+ const SUPPORTED_CHANNELS = new Set(["alpha", "release"]);
10
+ const SUPPORTED_CHANNEL_POLICIES = new Set(["preserve", "explicit"]);
11
+
12
+ function stableJson(value) {
13
+ return `${JSON.stringify(sortJson(value), null, 2)}\n`;
14
+ }
15
+
16
+ function sortJson(value) {
17
+ if (Array.isArray(value)) {
18
+ return value.map(sortJson);
19
+ }
20
+ if (!value || typeof value !== "object") {
21
+ return value;
22
+ }
23
+ return Object.fromEntries(
24
+ Object.entries(value)
25
+ .sort(([left], [right]) => left.localeCompare(right))
26
+ .map(([key, entry]) => [key, sortJson(entry)]),
27
+ );
28
+ }
29
+
30
+ function sha256Json(value) {
31
+ return crypto.createHash("sha256").update(stableJson(value)).digest("hex");
32
+ }
33
+
34
+ function assertPlainObject(value, label) {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
36
+ throw new Error(`${label} must be an object`);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function assertString(value, label) {
42
+ if (typeof value !== "string" || value.trim() === "") {
43
+ throw new Error(`${label} must be a non-empty string`);
44
+ }
45
+ return value.trim();
46
+ }
47
+
48
+ function optionalString(value) {
49
+ return value === undefined || value === null ? "" : String(value).trim();
50
+ }
51
+
52
+ function normalizeChannel(value, label) {
53
+ const channel = assertString(value, label);
54
+ if (!SUPPORTED_CHANNELS.has(channel)) {
55
+ throw new Error(`${label} must be alpha or release`);
56
+ }
57
+ return channel;
58
+ }
59
+
60
+ function normalizeChannelMap(edge, label) {
61
+ const policy = edge.channelPolicy || "preserve";
62
+ if (!SUPPORTED_CHANNEL_POLICIES.has(policy)) {
63
+ throw new Error(`${label}.channelPolicy must be preserve or explicit`);
64
+ }
65
+ const map = {
66
+ alpha: "alpha",
67
+ release: "release",
68
+ ...(edge.channelMap || {}),
69
+ };
70
+ if (policy === "explicit" && !edge.channelMap) {
71
+ throw new Error(`${label}.channelMap is required when channelPolicy is explicit`);
72
+ }
73
+ for (const channel of Object.keys(map)) {
74
+ normalizeChannel(channel, `${label}.channelMap key`);
75
+ normalizeChannel(map[channel], `${label}.channelMap.${channel}`);
76
+ }
77
+ return { policy, map };
78
+ }
79
+
80
+ function normalizeNode(node, index) {
81
+ assertPlainObject(node, `nodes[${index}]`);
82
+ const normalized = {
83
+ id: assertString(node.id, `nodes[${index}].id`),
84
+ repository: assertString(node.repository, `nodes[${index}].repository`),
85
+ package: optionalString(node.package),
86
+ lockPath: optionalString(node.lockPath || node.lock_path),
87
+ baseRef: optionalString(node.baseRef || node.base_ref),
88
+ workflow: optionalString(node.workflow),
89
+ };
90
+ if (!/^[^/\s]+\/[^/\s]+$/.test(normalized.repository)) {
91
+ throw new Error(`nodes[${index}].repository must be owner/repo`);
92
+ }
93
+ return normalized;
94
+ }
95
+
96
+ function normalizeEdge(edge, index) {
97
+ assertPlainObject(edge, `edges[${index}]`);
98
+ const channel = normalizeChannelMap({
99
+ channelPolicy: edge.channelPolicy || edge.channel_policy,
100
+ channelMap: edge.channelMap || edge.channel_map,
101
+ }, `edges[${index}]`);
102
+ const channels = edge.channels === undefined
103
+ ? ["alpha", "release"]
104
+ : edge.channels.map((entry, channelIndex) => normalizeChannel(entry, `edges[${index}].channels[${channelIndex}]`));
105
+ return {
106
+ id: optionalString(edge.id) || `edge-${index + 1}`,
107
+ from: assertString(edge.from, `edges[${index}].from`),
108
+ to: assertString(edge.to, `edges[${index}].to`),
109
+ channels,
110
+ channelPolicy: channel.policy,
111
+ channelMap: channel.map,
112
+ lockPath: optionalString(edge.lockPath || edge.lock_path),
113
+ prBaseRef: optionalString(edge.prBaseRef || edge.pr_base_ref),
114
+ };
115
+ }
116
+
117
+ function assertUnique(values, label) {
118
+ const seen = new Set();
119
+ for (const value of values) {
120
+ if (seen.has(value)) {
121
+ throw new Error(`${label} contains duplicate value: ${value}`);
122
+ }
123
+ seen.add(value);
124
+ }
125
+ }
126
+
127
+ function assertAcyclic(nodes, edges) {
128
+ const outgoing = new Map(nodes.map((node) => [node.id, []]));
129
+ for (const edge of edges) {
130
+ outgoing.get(edge.from).push(edge.to);
131
+ }
132
+ const visiting = new Set();
133
+ const visited = new Set();
134
+ const stack = [];
135
+ function visit(id) {
136
+ if (visiting.has(id)) {
137
+ const start = stack.indexOf(id);
138
+ const cycle = stack.slice(start).concat(id).join(" -> ");
139
+ throw new Error(`release propagation graph contains a cycle: ${cycle}`);
140
+ }
141
+ if (visited.has(id)) {
142
+ return;
143
+ }
144
+ visiting.add(id);
145
+ stack.push(id);
146
+ for (const next of outgoing.get(id) || []) {
147
+ visit(next);
148
+ }
149
+ stack.pop();
150
+ visiting.delete(id);
151
+ visited.add(id);
152
+ }
153
+ for (const node of nodes) {
154
+ visit(node.id);
155
+ }
156
+ }
157
+
158
+ export function normalizeReleasePropagationGraph(input) {
159
+ const graph = assertPlainObject(input, "release propagation graph");
160
+ const nodes = (graph.nodes || []).map(normalizeNode);
161
+ const edges = (graph.edges || []).map(normalizeEdge);
162
+ if (nodes.length === 0) {
163
+ throw new Error("release propagation graph requires nodes[]");
164
+ }
165
+ assertUnique(nodes.map((node) => node.id), "nodes.id");
166
+ const nodeIds = new Set(nodes.map((node) => node.id));
167
+ for (const edge of edges) {
168
+ if (!nodeIds.has(edge.from)) {
169
+ throw new Error(`edge ${edge.id} references unknown from node: ${edge.from}`);
170
+ }
171
+ if (!nodeIds.has(edge.to)) {
172
+ throw new Error(`edge ${edge.id} references unknown to node: ${edge.to}`);
173
+ }
174
+ }
175
+ assertAcyclic(nodes, edges);
176
+ return {
177
+ schemaVersion: 1,
178
+ contract: RELEASE_PROPAGATION_GRAPH_CONTRACT,
179
+ channelPolicy: graph.channelPolicy || graph.channel_policy || "preserve",
180
+ nodes,
181
+ edges,
182
+ };
183
+ }
184
+
185
+ export function resolvePropagationChannel(edge, upstreamChannel) {
186
+ const channel = normalizeChannel(upstreamChannel, "upstreamRelease.channel");
187
+ if (!edge.channels.includes(channel)) {
188
+ return "";
189
+ }
190
+ const downstreamChannel = edge.channelMap[channel] || "";
191
+ return downstreamChannel ? normalizeChannel(downstreamChannel, `edge ${edge.id} downstream channel`) : "";
192
+ }
193
+
194
+ function normalizeReleasePassport(passport = {}) {
195
+ if (!passport || typeof passport !== "object" || Array.isArray(passport)) {
196
+ throw new Error("upstreamRelease.releasePassport must be an object");
197
+ }
198
+ return {
199
+ url: assertString(passport.url, "upstreamRelease.releasePassport.url"),
200
+ sha256: assertString(passport.sha256 || passport.digest, "upstreamRelease.releasePassport.sha256"),
201
+ };
202
+ }
203
+
204
+ function normalizePackageFact(pkg = {}) {
205
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) {
206
+ throw new Error("upstreamRelease.package must be an object");
207
+ }
208
+ return {
209
+ name: assertString(pkg.name, "upstreamRelease.package.name"),
210
+ version: assertString(pkg.version, "upstreamRelease.package.version"),
211
+ integrity: assertString(pkg.integrity, "upstreamRelease.package.integrity"),
212
+ };
213
+ }
214
+
215
+ function normalizeUpstreamRelease(input) {
216
+ const release = assertPlainObject(input, "upstreamRelease");
217
+ return {
218
+ repository: assertString(release.repository, "upstreamRelease.repository"),
219
+ channel: normalizeChannel(release.channel, "upstreamRelease.channel"),
220
+ tag: assertString(release.tag, "upstreamRelease.tag"),
221
+ sourceSha: assertString(release.sourceSha || release.source_sha, "upstreamRelease.sourceSha"),
222
+ package: normalizePackageFact(release.package),
223
+ releasePassport: normalizeReleasePassport(release.releasePassport || release.release_passport),
224
+ siteBundle: release.siteBundle || release.site_bundle
225
+ ? {
226
+ manifestSha256: assertString(
227
+ release.siteBundle?.manifestSha256 || release.site_bundle?.manifest_sha256,
228
+ "upstreamRelease.siteBundle.manifestSha256",
229
+ ),
230
+ }
231
+ : undefined,
232
+ };
233
+ }
234
+
235
+ export function createReleasePropagationLock({
236
+ graph,
237
+ edge,
238
+ sourceNode,
239
+ targetNode,
240
+ upstreamRelease,
241
+ downstreamChannel,
242
+ } = {}) {
243
+ const lock = {
244
+ schemaVersion: 1,
245
+ contract: RELEASE_PROPAGATION_LOCK_CONTRACT,
246
+ upstream: {
247
+ node: sourceNode.id,
248
+ repository: upstreamRelease.repository,
249
+ channel: upstreamRelease.channel,
250
+ tag: upstreamRelease.tag,
251
+ sourceSha: upstreamRelease.sourceSha,
252
+ package: upstreamRelease.package,
253
+ releasePassport: upstreamRelease.releasePassport,
254
+ siteBundle: upstreamRelease.siteBundle,
255
+ },
256
+ downstream: {
257
+ node: targetNode.id,
258
+ repository: targetNode.repository,
259
+ channel: downstreamChannel,
260
+ baseRef: edge.prBaseRef || targetNode.baseRef,
261
+ lockPath: edge.lockPath || targetNode.lockPath || ".buildchain/upstream-release.lock.json",
262
+ },
263
+ propagation: {
264
+ graphContract: graph.contract,
265
+ edge: edge.id,
266
+ channelPolicy: edge.channelPolicy,
267
+ channelMap: edge.channelMap,
268
+ exact: true,
269
+ floatingTags: false,
270
+ },
271
+ };
272
+ lock.lockSha256 = sha256Json({ ...lock, lockSha256: undefined });
273
+ return lock;
274
+ }
275
+
276
+ export function planReleasePropagation({ graph: graphInput, upstreamRelease: releaseInput, sourceNode = "" } = {}) {
277
+ const graph = normalizeReleasePropagationGraph(graphInput);
278
+ const upstreamRelease = normalizeUpstreamRelease(releaseInput);
279
+ const source = sourceNode
280
+ ? graph.nodes.find((node) => node.id === sourceNode)
281
+ : graph.nodes.find((node) => node.repository === upstreamRelease.repository || node.package === upstreamRelease.package.name);
282
+ if (!source) {
283
+ throw new Error("could not resolve source node for upstream release");
284
+ }
285
+ const targets = [];
286
+ for (const edge of graph.edges.filter((entry) => entry.from === source.id)) {
287
+ const downstreamChannel = resolvePropagationChannel(edge, upstreamRelease.channel);
288
+ if (!downstreamChannel) {
289
+ continue;
290
+ }
291
+ const target = graph.nodes.find((node) => node.id === edge.to);
292
+ const lock = createReleasePropagationLock({
293
+ graph,
294
+ edge,
295
+ sourceNode: source,
296
+ targetNode: target,
297
+ upstreamRelease,
298
+ downstreamChannel,
299
+ });
300
+ targets.push({
301
+ edge: edge.id,
302
+ source: source.id,
303
+ target: target.id,
304
+ repository: target.repository,
305
+ channel: downstreamChannel,
306
+ baseRef: lock.downstream.baseRef,
307
+ lockPath: lock.downstream.lockPath,
308
+ lock,
309
+ });
310
+ }
311
+ return {
312
+ schemaVersion: 1,
313
+ contract: RELEASE_PROPAGATION_PLAN_CONTRACT,
314
+ source: source.id,
315
+ upstreamRelease,
316
+ targets,
317
+ summary: {
318
+ targetCount: targets.length,
319
+ channels: [...new Set(targets.map((target) => target.channel))],
320
+ repositories: targets.map((target) => target.repository),
321
+ },
322
+ };
323
+ }
324
+
325
+ export function readReleasePropagationJson(value, { cwd = process.cwd(), label = "json" } = {}) {
326
+ const source = String(value || "").trim();
327
+ if (!source) {
328
+ throw new Error(`${label} is required`);
329
+ }
330
+ const candidatePath = path.isAbsolute(source) ? source : path.join(cwd, source);
331
+ if (fs.existsSync(candidatePath)) {
332
+ return JSON.parse(fs.readFileSync(candidatePath, "utf8"));
333
+ }
334
+ return JSON.parse(source);
335
+ }
336
+
337
+ export function writeReleasePropagationLock({ plan, target = "", cwd = process.cwd(), output = "" } = {}) {
338
+ assertPlainObject(plan, "plan");
339
+ const matches = plan.targets.filter((entry) => !target || entry.target === target || entry.repository === target);
340
+ if (matches.length !== 1) {
341
+ throw new Error(`expected exactly one propagation target, found ${matches.length}`);
342
+ }
343
+ const selected = matches[0];
344
+ const outputPath = path.resolve(cwd, output || selected.lockPath);
345
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
346
+ fs.writeFileSync(outputPath, stableJson(selected.lock));
347
+ return {
348
+ target: selected.target,
349
+ repository: selected.repository,
350
+ channel: selected.channel,
351
+ path: outputPath,
352
+ lockSha256: selected.lock.lockSha256,
353
+ };
354
+ }
@@ -21,6 +21,7 @@ const requiredPaths = [
21
21
  "docs/install.md",
22
22
  "docs/product-mechanism.md",
23
23
  "docs/release-passport.md",
24
+ "docs/release-propagation.md",
24
25
  "docs/site-bundle-contract.md",
25
26
  "docs/toolkit-observability.md",
26
27
  "docs/versioning.md",
@@ -53,6 +54,7 @@ const requiredPaths = [
53
54
  ".github/workflows/buildchain-patrol-weekly.yml",
54
55
  ".github/workflows/buildchain-patrol-monthly.yml",
55
56
  ".github/workflows/release-candidate-promote.yml",
57
+ ".github/workflows/release-propagation.yml",
56
58
  ".github/workflows/npm-publish.yml",
57
59
  ".github/workflows/binary-distribution.yml",
58
60
  ".github/workflows/verify.yml",
@@ -94,6 +96,9 @@ if (rootPackage.exports?.["./logging"] !== "./packages/core/logging.js") {
94
96
  if (rootPackage.exports?.["./release-passport"] !== "./packages/core/release-passport.js") {
95
97
  throw new Error("root package must export @kungfu-tech/buildchain/release-passport");
96
98
  }
99
+ if (rootPackage.exports?.["./release-propagation"] !== "./packages/core/release-propagation.js") {
100
+ throw new Error("root package must export @kungfu-tech/buildchain/release-propagation");
101
+ }
97
102
  if (rootPackage.publishConfig?.access !== "public") {
98
103
  throw new Error("root package publishConfig.access must be public");
99
104
  }
@@ -110,6 +115,9 @@ for (const expectedFile of ["dist/site/", "docs/install.md", "docs/binary-distri
110
115
  throw new Error(`root package files must include ${expectedFile}`);
111
116
  }
112
117
  }
118
+ if (!rootPackage.files?.includes("docs/release-propagation.md")) {
119
+ throw new Error("root package files must include docs/release-propagation.md");
120
+ }
113
121
  const cliSource = fs.readFileSync(path.join(root, "bin/buildchain.mjs"), "utf8");
114
122
  const coreIndexSource = fs.readFileSync(path.join(root, "packages/core/index.js"), "utf8");
115
123
  const versioningDoc = fs.readFileSync(path.join(root, "docs/versioning.md"), "utf8");
@@ -40,6 +40,7 @@ function buildSiteBundle() {
40
40
  const docs = [
41
41
  docEntry("install", "Install and verify Buildchain", "docs/install.md", "use"),
42
42
  docEntry("release-passport", "Release Passport protocol", "docs/release-passport.md", "verify"),
43
+ docEntry("release-propagation", "Release propagation", "docs/release-propagation.md", "use"),
43
44
  docEntry("binary-distribution", "Binary distribution contract", "docs/binary-distribution.md", "verify"),
44
45
  docEntry("toolkit-observability", "Toolkit observability", "docs/toolkit-observability.md", "use"),
45
46
  docEntry("site-bundle-contract", "Site bundle contract", "docs/site-bundle-contract.md", "use"),
@@ -69,6 +70,7 @@ function buildSiteBundle() {
69
70
  { id: "release-dry-run", usage: "buildchain release --dry-run --target-ref <ref>", purpose: "Explain what a channel merge would publish before the PR is merged." },
70
71
  { id: "collect-github-release", usage: "buildchain collect github-release --tag <tag>", purpose: "Collect release assets into a release passport." },
71
72
  { id: "verify-release-passport", usage: "buildchain verify release-passport <file-or-url>", purpose: "Fail closed unless a release passport and its evidence are complete." },
73
+ { id: "release-propagation", usage: "buildchain release-propagation <plan|write-lock>", purpose: "Plan channel-preserving downstream release PRs and write exact upstream release locks." },
72
74
  { id: "verify-infra-contract-evidence-bundle", usage: "buildchain verify infra-contract-evidence-bundle <file>", purpose: "Fail closed unless an infra-contract lifecycle evidence bundle is complete, hash-bound, and validation-consistent." },
73
75
  { id: "logging", usage: "buildchain log|mark|span|verify observability-log", purpose: "Emit timestamped build events, summarize logs, and enforce required phases." },
74
76
  { id: "diagnostics-summary", usage: "buildchain diagnostics summary <diagnostics.json>...", purpose: "Summarize small diagnostics artifacts into JSON and a cross-platform lifecycle timing table." },
@@ -84,6 +86,7 @@ function buildSiteBundle() {
84
86
  { id: "build", path: ".github/workflows/.build.yml", surface: "reusable-build", status: "active" },
85
87
  { id: "web-surface", path: ".github/workflows/.web-surface.yml", surface: "site-app-deployment", status: "active" },
86
88
  { id: "buildchain-ref-promotion", path: ".github/workflows/buildchain-ref-promotion.yml", surface: "release-governance", status: "active" },
89
+ { id: "release-propagation", path: ".github/workflows/release-propagation.yml", surface: "release-propagation", status: "preview" },
87
90
  { id: "dev-pr-auto-merge", path: ".github/workflows/dev-pr-auto-merge.yml", surface: "dev-governance", status: "active" },
88
91
  { id: "binary-distribution", path: ".github/workflows/binary-distribution.yml", surface: "release-passport", status: "active" },
89
92
  ],
@@ -106,6 +109,13 @@ function buildSiteBundle() {
106
109
  bundle: "buildchain-release-bundle.tar.gz",
107
110
  contract: "kungfu-buildchain-release-passport",
108
111
  },
112
+ releasePropagation: {
113
+ graphContract: "kungfu-buildchain-release-propagation-graph",
114
+ planContract: "kungfu-buildchain-release-propagation-plan",
115
+ lockContract: "kungfu-buildchain-release-propagation-lock",
116
+ defaultChannelPolicy: "preserve",
117
+ defaultChannelMap: { alpha: "alpha", release: "release" },
118
+ },
109
119
  npm: {
110
120
  package: packageJson.name,
111
121
  command: packageJson.bin?.buildchain || "",
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import {
6
+ planReleasePropagation,
7
+ readReleasePropagationJson,
8
+ writeReleasePropagationLock,
9
+ } from "../packages/core/release-propagation.js";
10
+
11
+ function usage() {
12
+ return `Usage:
13
+ buildchain release-propagation plan --graph <json-or-path>
14
+ --upstream-release <json-or-path>
15
+ [--source-node <id>] [--output <file>] [--json]
16
+ buildchain release-propagation write-lock --plan <json-or-path>
17
+ [--target <id-or-repo>] [--cwd <dir>]
18
+ [--output <file>] [--json]
19
+ `;
20
+ }
21
+
22
+ function readFlag(args, name, fallback = "") {
23
+ const index = args.indexOf(`--${name}`);
24
+ return index === -1 ? fallback : args[index + 1] || "";
25
+ }
26
+
27
+ function hasFlag(args, name) {
28
+ return args.includes(`--${name}`);
29
+ }
30
+
31
+ function printJson(value) {
32
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
33
+ }
34
+
35
+ function writeOutput(filePath, value) {
36
+ if (!filePath) {
37
+ return;
38
+ }
39
+ fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
40
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
41
+ }
42
+
43
+ export function runReleasePropagationCli(argv = process.argv.slice(2)) {
44
+ const [mode = "", ...args] = argv;
45
+ if (!mode || mode === "--help" || mode === "-h") {
46
+ process.stdout.write(usage());
47
+ return;
48
+ }
49
+ if (mode === "plan") {
50
+ const graph = readReleasePropagationJson(readFlag(args, "graph"), {
51
+ label: "--graph",
52
+ cwd: process.cwd(),
53
+ });
54
+ const upstreamRelease = readReleasePropagationJson(readFlag(args, "upstream-release"), {
55
+ label: "--upstream-release",
56
+ cwd: process.cwd(),
57
+ });
58
+ const plan = planReleasePropagation({
59
+ graph,
60
+ upstreamRelease,
61
+ sourceNode: readFlag(args, "source-node", ""),
62
+ });
63
+ writeOutput(readFlag(args, "output", ""), plan);
64
+ if (hasFlag(args, "json")) {
65
+ printJson(plan);
66
+ } else {
67
+ process.stdout.write(`release propagation targets: ${plan.summary.targetCount}\n`);
68
+ for (const target of plan.targets) {
69
+ process.stdout.write(`- ${target.repository} ${target.channel} lock=${target.lockPath}\n`);
70
+ }
71
+ }
72
+ return;
73
+ }
74
+ if (mode === "write-lock") {
75
+ const plan = readReleasePropagationJson(readFlag(args, "plan"), {
76
+ label: "--plan",
77
+ cwd: process.cwd(),
78
+ });
79
+ const result = writeReleasePropagationLock({
80
+ plan,
81
+ target: readFlag(args, "target", ""),
82
+ cwd: readFlag(args, "cwd", process.cwd()),
83
+ output: readFlag(args, "output", ""),
84
+ });
85
+ if (hasFlag(args, "json")) {
86
+ printJson(result);
87
+ } else {
88
+ process.stdout.write(`release propagation lock: ${result.path}\n`);
89
+ process.stdout.write(`lock sha256: ${result.lockSha256}\n`);
90
+ }
91
+ return;
92
+ }
93
+ throw new Error(`unsupported release-propagation command: ${mode}`);
94
+ }
95
+
96
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
97
+ runReleasePropagationCli();
98
+ }