@kungfu-tech/buildchain 4.1.2 → 4.1.3-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.
- package/CONTRIBUTING.md +7 -4
- package/architecture/action-taxonomy.json +4 -0
- package/architecture/agent-change-map.md +40 -0
- package/architecture/internal-capabilities.json +49 -0
- package/architecture/maintainability-debt.json +2 -1
- package/architecture/maintainability-policy.json +4 -4
- package/architecture/release-topology.json +11 -1
- package/architecture/universal-workflow-bootstrap.json +13 -2
- package/architecture/universal-workflow-capability-policy.json +15 -9
- package/contracts/promotion-invocation-v1.schema.json +6 -3
- package/contracts/promotion-request-v1.schema.json +6 -3
- package/contracts/release-discussion-v1.schema.json +140 -0
- package/dist/readers/release-discussion.cjs +203 -0
- package/dist/site/buildchain-contract.json +15 -11
- package/dist/site/buildchain-site.json +57 -6
- package/dist/site/capability-registry.json +3 -3
- package/dist/site/kfd-claims.json +95 -9
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/node-api-registry.json +83 -1
- package/dist/site/page-registry.json +54 -3
- package/dist/site/public-surface-audit.json +53 -7
- package/dist/site/publication-registry.json +2 -2
- package/dist/site/release-provenance.json +2 -1
- package/dist/site/site-manifest.json +3 -3
- package/dist/site/workflow-registry.json +79 -7
- package/docs/node-api-reference.md +9 -0
- package/docs/release-discussions.md +154 -0
- package/package.json +10 -6
- package/packages/core/providers/github/discussions/materials.js +146 -0
- package/packages/core/providers/github/discussions/transport.js +115 -0
- package/packages/core/publication/binary/action.js +21 -7
- package/packages/core/release/discussion/actions.js +65 -0
- package/packages/core/release/discussion/binary.js +92 -0
- package/packages/core/release/discussion/checkpoints.js +241 -0
- package/packages/core/release/discussion/envelope.js +141 -0
- package/packages/core/release/discussion/publication.js +121 -0
- package/packages/core/release/discussion/qualification.js +121 -0
- package/packages/core/release/discussion/reader-entry.js +11 -0
- package/packages/core/release/discussion/reader.js +168 -0
- package/packages/core/release/discussion/recovery.js +132 -0
- package/packages/core/release/discussion/session.js +113 -0
- package/packages/core/release/discussion/store.js +169 -0
- package/packages/core/release/github-release.js +3 -1
- package/packages/core/release/promote-candidate/action.js +86 -6
- package/packages/core/release/promote-candidate/preparation.js +106 -0
- package/packages/core/release/promote-candidate/product-provider.js +4 -1
- package/packages/core/release/promote-candidate/provider-settlement.js +36 -23
- package/packages/core/release/promote-candidate/transaction.js +55 -97
- package/packages/core/release/promotion/candidate.js +11 -2
- package/packages/core/release/promotion/qualification.js +5 -2
- package/packages/core/release/promotion-request.js +8 -4
- package/packages/core/workflow/engine/execution.js +5 -1
- package/packages/core/workflow/engine/provider-context.js +2 -0
- package/packages/core/workflow/engine/release-observation.js +1 -0
- package/packages/core/workflow/engine/release-promotion.js +38 -26
- package/packages/core/workflow/universal-workflow-bootstrap.js +1 -0
- package/scripts/build-release-discussion-reader.mjs +34 -0
- package/scripts/inventory/binary.mjs +1 -1
- package/scripts/maintainability-metrics.mjs +1 -0
- package/scripts/site-capability-metadata.mjs +1 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createProgress, recordDigest } from "./envelope.js";
|
|
4
|
+
import { materialDigest } from "../../providers/github/discussions/materials.js";
|
|
5
|
+
import { discussionMaterials } from "../../providers/github/discussions/materials.js";
|
|
6
|
+
|
|
7
|
+
export function releaseCheckpoints({ session, store, octokit }) {
|
|
8
|
+
const materials = discussionMaterials({
|
|
9
|
+
octokit,
|
|
10
|
+
repository: session.intent.repository,
|
|
11
|
+
intentId: session.intent.id,
|
|
12
|
+
});
|
|
13
|
+
async function checkpoint(node, value) {
|
|
14
|
+
const observed = await store.read(session);
|
|
15
|
+
const records = observed.records.filter(
|
|
16
|
+
(record) =>
|
|
17
|
+
record.kind === "checkpoint" &&
|
|
18
|
+
record.attempt === session.attempt &&
|
|
19
|
+
record.node === node,
|
|
20
|
+
);
|
|
21
|
+
const root = recordDigest(value);
|
|
22
|
+
const duplicate = records.find((record) => record.payload.root === root);
|
|
23
|
+
if (duplicate) return duplicate;
|
|
24
|
+
const handle = await materials.put(Buffer.from(JSON.stringify(value)));
|
|
25
|
+
const record = createProgress({
|
|
26
|
+
intent: session.intent,
|
|
27
|
+
runtime: session.runtime,
|
|
28
|
+
attempt: session.attempt,
|
|
29
|
+
predecessor: session.predecessor,
|
|
30
|
+
kind: "checkpoint",
|
|
31
|
+
node,
|
|
32
|
+
status: "running",
|
|
33
|
+
sequence: Math.max(-1, ...records.map((record) => record.sequence)) + 1,
|
|
34
|
+
payload: {
|
|
35
|
+
schema: "buildchain.release-checkpoint/v1",
|
|
36
|
+
root,
|
|
37
|
+
material: handle,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
await store.append(session, record);
|
|
41
|
+
return record;
|
|
42
|
+
}
|
|
43
|
+
async function readCheckpoint(record) {
|
|
44
|
+
if (
|
|
45
|
+
record.kind !== "checkpoint" ||
|
|
46
|
+
record.payload?.schema !== "buildchain.release-checkpoint/v1"
|
|
47
|
+
)
|
|
48
|
+
throw new Error("Unsupported recovery checkpoint");
|
|
49
|
+
const value = JSON.parse(await materials.read(record.payload.material));
|
|
50
|
+
if (recordDigest(value) !== record.payload.root)
|
|
51
|
+
throw new Error("Recovery checkpoint root differs from its record");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
async function publicationReader(currentBytes) {
|
|
55
|
+
const observed = await store.read(session);
|
|
56
|
+
const first = observed.records
|
|
57
|
+
.filter(
|
|
58
|
+
(record) =>
|
|
59
|
+
record.kind === "checkpoint" && record.node === "qualification",
|
|
60
|
+
)
|
|
61
|
+
.sort(
|
|
62
|
+
(a, b) =>
|
|
63
|
+
observed.attempts.indexOf(a.attempt) -
|
|
64
|
+
observed.attempts.indexOf(b.attempt) || a.sequence - b.sequence,
|
|
65
|
+
)[0];
|
|
66
|
+
if (!first) return currentBytes;
|
|
67
|
+
const manifest = await readCheckpoint(first);
|
|
68
|
+
if (manifest.schema !== "buildchain.release-recovery-material/v1")
|
|
69
|
+
throw new Error("Unsupported retained publication reader manifest");
|
|
70
|
+
return materials.read(manifest.reader);
|
|
71
|
+
}
|
|
72
|
+
return { checkpoint, readCheckpoint, materials, publicationReader };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertNoSymlink(file, base) {
|
|
76
|
+
let current = file;
|
|
77
|
+
while (current.startsWith(base)) {
|
|
78
|
+
if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink())
|
|
79
|
+
throw new Error("Recovery path contains a symbolic link");
|
|
80
|
+
if (current === base) break;
|
|
81
|
+
current = path.dirname(current);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MATERIAL_INPUTS = [
|
|
86
|
+
"candidate-passport-path",
|
|
87
|
+
"candidate-build-summary-path",
|
|
88
|
+
"stage-capsules-path",
|
|
89
|
+
"product-publication-intent-path",
|
|
90
|
+
"publication-qualification-path",
|
|
91
|
+
"required-artifacts-path",
|
|
92
|
+
"sealed-bundle-manifest",
|
|
93
|
+
"recovery-receipt-path",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export async function retainRecoveryMaterials(
|
|
97
|
+
{ request, workspace, readerFile },
|
|
98
|
+
materials,
|
|
99
|
+
) {
|
|
100
|
+
const base = path.resolve(workspace);
|
|
101
|
+
const files = new Set(
|
|
102
|
+
MATERIAL_INPUTS.map((name) => request[name])
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.map((file) => path.resolve(base, file)),
|
|
105
|
+
);
|
|
106
|
+
for (const file of request["artifact-paths"] || [])
|
|
107
|
+
files.add(path.resolve(base, file));
|
|
108
|
+
const walk = (directory) => {
|
|
109
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
110
|
+
const file = path.join(directory, entry.name);
|
|
111
|
+
if (entry.isSymbolicLink())
|
|
112
|
+
throw new Error("Recovery materials must not contain symbolic links");
|
|
113
|
+
if (entry.isDirectory()) walk(file);
|
|
114
|
+
else if (entry.isFile()) files.add(file);
|
|
115
|
+
if (files.size > 512)
|
|
116
|
+
throw new Error("Recovery material file count exceeded");
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
if (request["sealed-bundle-root"]) {
|
|
120
|
+
const root = path.resolve(base, request["sealed-bundle-root"]);
|
|
121
|
+
if (!root.startsWith(path.join(base, ".buildchain") + path.sep))
|
|
122
|
+
throw new Error("Sealed recovery directory is outside consumer evidence");
|
|
123
|
+
assertNoSymlink(root, base);
|
|
124
|
+
walk(root);
|
|
125
|
+
}
|
|
126
|
+
const handles = [];
|
|
127
|
+
let totalSize = 0;
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
const relative = path.relative(base, file).split(path.sep).join("/");
|
|
130
|
+
if (
|
|
131
|
+
!relative.startsWith(".buildchain/") ||
|
|
132
|
+
relative.startsWith(".buildchain/runtime/") ||
|
|
133
|
+
relative.includes("../")
|
|
134
|
+
)
|
|
135
|
+
throw new Error(
|
|
136
|
+
"Recovery materials must remain in the declared consumer evidence directory",
|
|
137
|
+
);
|
|
138
|
+
assertNoSymlink(file, base);
|
|
139
|
+
const info = fs.lstatSync(file);
|
|
140
|
+
if (!info.isFile())
|
|
141
|
+
throw new Error("Recovery material is not a regular file");
|
|
142
|
+
totalSize += info.size;
|
|
143
|
+
if (totalSize > 1024 * 1024 * 1024)
|
|
144
|
+
throw new Error("Recovery material byte budget exceeded");
|
|
145
|
+
handles.push({
|
|
146
|
+
path: relative,
|
|
147
|
+
...(await materials.put(fs.readFileSync(file))),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
schema: "buildchain.release-recovery-material/v1",
|
|
152
|
+
source: {
|
|
153
|
+
repository: request.repository,
|
|
154
|
+
sourceSha: request["source-sha"],
|
|
155
|
+
version: request.version,
|
|
156
|
+
targetRef: request["target-ref"],
|
|
157
|
+
channel: request.channel,
|
|
158
|
+
},
|
|
159
|
+
releaseAssets: (request["artifact-paths"] || []).map((file) =>
|
|
160
|
+
path.relative(base, path.resolve(base, file)).split(path.sep).join("/"),
|
|
161
|
+
),
|
|
162
|
+
files: handles,
|
|
163
|
+
inputs: Object.fromEntries(
|
|
164
|
+
[...MATERIAL_INPUTS, "sealed-bundle-root"].map((key) => [
|
|
165
|
+
key,
|
|
166
|
+
request[key]
|
|
167
|
+
? path
|
|
168
|
+
.relative(base, path.resolve(base, request[key]))
|
|
169
|
+
.split(path.sep)
|
|
170
|
+
.join("/")
|
|
171
|
+
: "",
|
|
172
|
+
]),
|
|
173
|
+
),
|
|
174
|
+
reader: await materials.put(fs.readFileSync(readerFile)),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function recoveryInventory(manifest, directory) {
|
|
179
|
+
if (
|
|
180
|
+
manifest.schema !== "buildchain.release-recovery-material/v1" ||
|
|
181
|
+
!Array.isArray(manifest.files) ||
|
|
182
|
+
manifest.files.length > 512
|
|
183
|
+
)
|
|
184
|
+
throw new Error("Unsupported recovery material manifest");
|
|
185
|
+
const base = path.resolve(directory),
|
|
186
|
+
paths = new Set();
|
|
187
|
+
const files = manifest.files.map((handle) => {
|
|
188
|
+
if (
|
|
189
|
+
typeof handle.path !== "string" ||
|
|
190
|
+
!handle.path.startsWith(".buildchain/") ||
|
|
191
|
+
handle.path.startsWith(".buildchain/runtime/") ||
|
|
192
|
+
handle.path
|
|
193
|
+
.split(/[\\/]/u)
|
|
194
|
+
.some((part) => ["..", ".", ""].includes(part)) ||
|
|
195
|
+
paths.has(handle.path)
|
|
196
|
+
)
|
|
197
|
+
throw new Error("Unsafe recovery material path");
|
|
198
|
+
paths.add(handle.path);
|
|
199
|
+
const file = path.resolve(base, handle.path);
|
|
200
|
+
assertNoSymlink(file, base);
|
|
201
|
+
if (
|
|
202
|
+
fs.existsSync(file) &&
|
|
203
|
+
(!fs.lstatSync(file).isFile() ||
|
|
204
|
+
materialDigest(fs.readFileSync(file)) !== handle.digest)
|
|
205
|
+
)
|
|
206
|
+
throw new Error(
|
|
207
|
+
"Recovery material destination already exists with different bytes",
|
|
208
|
+
);
|
|
209
|
+
return { handle, file };
|
|
210
|
+
});
|
|
211
|
+
const inputs = Object.fromEntries(
|
|
212
|
+
Object.entries(manifest.inputs).map(([key, relative]) => {
|
|
213
|
+
if (!relative) return [key, ""];
|
|
214
|
+
const present =
|
|
215
|
+
key === "sealed-bundle-root"
|
|
216
|
+
? [...paths].some((file) => file.startsWith(relative + "/"))
|
|
217
|
+
: paths.has(relative);
|
|
218
|
+
if (!present)
|
|
219
|
+
throw new Error("Recovery input is outside the retained inventory");
|
|
220
|
+
return [key, path.resolve(base, relative)];
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
if ((manifest.releaseAssets || []).some((file) => !paths.has(file)))
|
|
224
|
+
throw new Error("Release asset is outside the retained inventory");
|
|
225
|
+
return { files, inputs };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function restoreRecoveryMaterials(manifest, directory, materials) {
|
|
229
|
+
const { files, inputs } = recoveryInventory(manifest, directory);
|
|
230
|
+
let total = 0;
|
|
231
|
+
for (const { handle, file } of files) {
|
|
232
|
+
total += handle.size || 0;
|
|
233
|
+
if (total > 1024 * 1024 * 1024)
|
|
234
|
+
throw new Error("Recovery material byte budget exceeded");
|
|
235
|
+
const bytes = await materials.read(handle);
|
|
236
|
+
if (fs.existsSync(file)) continue;
|
|
237
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
238
|
+
fs.writeFileSync(file, bytes, { flag: "wx" });
|
|
239
|
+
}
|
|
240
|
+
return inputs;
|
|
241
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const ENVELOPE_SCHEMA = "buildchain.discussion-record/v1";
|
|
4
|
+
export const INTENT_SCHEMA = "buildchain.release-discussion/v1";
|
|
5
|
+
export const PAYLOAD_SCHEMA = "buildchain.release-progress/v1";
|
|
6
|
+
const MARKER = "<!-- buildchain-transaction\n";
|
|
7
|
+
export const MAX_RECORD_BYTES = 48_000;
|
|
8
|
+
|
|
9
|
+
export function canonicalJson(value) {
|
|
10
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
11
|
+
throw new Error("Transaction numbers must be finite");
|
|
12
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
13
|
+
if (value && typeof value === "object")
|
|
14
|
+
return `{${Object.keys(value)
|
|
15
|
+
.sort()
|
|
16
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
17
|
+
.join(",")}}`;
|
|
18
|
+
const encoded = JSON.stringify(value);
|
|
19
|
+
if (encoded === undefined)
|
|
20
|
+
throw new Error("Transaction records must contain JSON values");
|
|
21
|
+
return encoded;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function recordDigest(value) {
|
|
25
|
+
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function requireString(value, label) {
|
|
29
|
+
if (typeof value !== "string" || !value.trim())
|
|
30
|
+
throw new Error(`${label} is required`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateRuntime(runtime) {
|
|
35
|
+
if (
|
|
36
|
+
!/^[\w.-]+\/[\w.-]+$/u.test(runtime?.repository || "") ||
|
|
37
|
+
!/^[a-f0-9]{40}$/u.test(runtime?.sha || "") ||
|
|
38
|
+
!/^sha256:[a-f0-9]{64}$/u.test(runtime?.readerDigest || "")
|
|
39
|
+
)
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Record requires selected runtime repository, exact revision and reader digest",
|
|
42
|
+
);
|
|
43
|
+
return runtime;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function encodeRecord(
|
|
47
|
+
record,
|
|
48
|
+
summary = "Buildchain release transaction record",
|
|
49
|
+
) {
|
|
50
|
+
const body = `${summary}\n\n${MARKER}${canonicalJson(record)}\n-->`;
|
|
51
|
+
if (Buffer.byteLength(body) > MAX_RECORD_BYTES)
|
|
52
|
+
throw new Error(
|
|
53
|
+
"Transaction record exceeds the bounded body size; retain material as artifacts",
|
|
54
|
+
);
|
|
55
|
+
return body;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function decodeRecord(body) {
|
|
59
|
+
if (typeof body !== "string" || !body.includes(MARKER)) return undefined;
|
|
60
|
+
if (Buffer.byteLength(body) > MAX_RECORD_BYTES)
|
|
61
|
+
throw new Error("Oversized transaction record");
|
|
62
|
+
const start = body.indexOf(MARKER) + MARKER.length;
|
|
63
|
+
const end = body.indexOf("\n-->", start);
|
|
64
|
+
if (end < 0 || body.indexOf(MARKER, start) >= 0)
|
|
65
|
+
throw new Error("Malformed transaction envelope");
|
|
66
|
+
const record = JSON.parse(body.slice(start, end));
|
|
67
|
+
if (![ENVELOPE_SCHEMA, INTENT_SCHEMA].includes(record.schema))
|
|
68
|
+
throw new Error("Unsupported transaction envelope schema");
|
|
69
|
+
return record;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createIntent({
|
|
73
|
+
repository,
|
|
74
|
+
key,
|
|
75
|
+
expectedNodes,
|
|
76
|
+
runtime,
|
|
77
|
+
source,
|
|
78
|
+
}) {
|
|
79
|
+
requireString(repository, "Consumer repository");
|
|
80
|
+
requireString(key, "Release intent key");
|
|
81
|
+
if (key.length > 240 || /[\r\n<>]/u.test(key))
|
|
82
|
+
throw new Error("Invalid release intent key");
|
|
83
|
+
if (
|
|
84
|
+
!Array.isArray(expectedNodes) ||
|
|
85
|
+
!expectedNodes.length ||
|
|
86
|
+
new Set(expectedNodes).size !== expectedNodes.length ||
|
|
87
|
+
expectedNodes.some((node) => !/^[a-z][a-z0-9-]*$/u.test(node))
|
|
88
|
+
)
|
|
89
|
+
throw new Error("Release intent requires unique expected semantic nodes");
|
|
90
|
+
validateRuntime(runtime);
|
|
91
|
+
const identity = { repository, key };
|
|
92
|
+
return {
|
|
93
|
+
schema: INTENT_SCHEMA,
|
|
94
|
+
id: recordDigest(identity),
|
|
95
|
+
...identity,
|
|
96
|
+
expectedNodes,
|
|
97
|
+
runtime,
|
|
98
|
+
source,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createProgress({
|
|
103
|
+
intent,
|
|
104
|
+
attempt,
|
|
105
|
+
predecessor = "",
|
|
106
|
+
runtime,
|
|
107
|
+
node,
|
|
108
|
+
status,
|
|
109
|
+
payload = {},
|
|
110
|
+
sequence = 0,
|
|
111
|
+
kind = "progress",
|
|
112
|
+
writer = attempt,
|
|
113
|
+
}) {
|
|
114
|
+
validateRuntime(runtime);
|
|
115
|
+
requireString(writer, "Record writer");
|
|
116
|
+
requireString(attempt, "Workflow attempt");
|
|
117
|
+
if (node !== "attempt" && !intent.expectedNodes.includes(node))
|
|
118
|
+
throw new Error(`Undeclared release node: ${node}`);
|
|
119
|
+
if (!["running", "success", "failure", "cancelled"].includes(status))
|
|
120
|
+
throw new Error("Invalid release node status");
|
|
121
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0)
|
|
122
|
+
throw new Error("Invalid record sequence");
|
|
123
|
+
if (predecessor === attempt) throw new Error("Attempt cannot recover itself");
|
|
124
|
+
if (!["progress", "checkpoint"].includes(kind))
|
|
125
|
+
throw new Error("Invalid transaction record kind");
|
|
126
|
+
const event = {
|
|
127
|
+
kind,
|
|
128
|
+
writer,
|
|
129
|
+
schema: ENVELOPE_SCHEMA,
|
|
130
|
+
intent: intent.id,
|
|
131
|
+
attempt,
|
|
132
|
+
predecessor,
|
|
133
|
+
runtime,
|
|
134
|
+
payloadSchema: PAYLOAD_SCHEMA,
|
|
135
|
+
node,
|
|
136
|
+
status,
|
|
137
|
+
sequence,
|
|
138
|
+
payload,
|
|
139
|
+
};
|
|
140
|
+
return { ...event, id: recordDigest(event) };
|
|
141
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { decodeRecord } from "./envelope.js";
|
|
2
|
+
import { discussionTransport } from "../../providers/github/discussions/transport.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { openReleaseSession, RELEASE_NODES } from "./session.js";
|
|
6
|
+
import { releaseCheckpoints, retainRecoveryMaterials } from "./checkpoints.js";
|
|
7
|
+
import { promoteReleaseCandidate } from "../promote-candidate/transaction.js";
|
|
8
|
+
|
|
9
|
+
export async function publicationNodes(request, graphql) {
|
|
10
|
+
if (!request["resume-discussion-id"])
|
|
11
|
+
return [
|
|
12
|
+
...RELEASE_NODES,
|
|
13
|
+
...(request["standalone-binary-distribution"]
|
|
14
|
+
? ["binary-distribution"]
|
|
15
|
+
: []),
|
|
16
|
+
];
|
|
17
|
+
const discussion = await discussionTransport(graphql).get(
|
|
18
|
+
request["resume-discussion-id"],
|
|
19
|
+
);
|
|
20
|
+
const intent = decodeRecord(discussion.body);
|
|
21
|
+
if (
|
|
22
|
+
intent?.repository !== request.repository ||
|
|
23
|
+
intent.key !== request.version
|
|
24
|
+
)
|
|
25
|
+
throw new Error("Discussion recovery cannot change the release intent");
|
|
26
|
+
return intent.expectedNodes;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function publishWithDiscussion(
|
|
30
|
+
request,
|
|
31
|
+
{
|
|
32
|
+
octokit,
|
|
33
|
+
mutationOctokit,
|
|
34
|
+
actor,
|
|
35
|
+
runId,
|
|
36
|
+
attempt,
|
|
37
|
+
runtime,
|
|
38
|
+
workspace,
|
|
39
|
+
runtimeRoot,
|
|
40
|
+
observe = () => {},
|
|
41
|
+
},
|
|
42
|
+
) {
|
|
43
|
+
const journal = await openReleaseSession({
|
|
44
|
+
graphql: octokit.graphql,
|
|
45
|
+
repository: request.repository,
|
|
46
|
+
key: request.version,
|
|
47
|
+
source: { version: request.version },
|
|
48
|
+
expectedNodes: await publicationNodes(request, octokit.graphql),
|
|
49
|
+
runtime,
|
|
50
|
+
attempt,
|
|
51
|
+
discussionId: request["resume-discussion-id"],
|
|
52
|
+
recover: Boolean(
|
|
53
|
+
request["resume-discussion-id"] ||
|
|
54
|
+
request["publish-transaction-override"],
|
|
55
|
+
),
|
|
56
|
+
});
|
|
57
|
+
const retained = releaseCheckpoints({
|
|
58
|
+
session: journal.session,
|
|
59
|
+
store: journal.store,
|
|
60
|
+
octokit,
|
|
61
|
+
});
|
|
62
|
+
const readerFile = path.join(
|
|
63
|
+
runtimeRoot,
|
|
64
|
+
"dist/readers/release-discussion.cjs",
|
|
65
|
+
);
|
|
66
|
+
const locator = {
|
|
67
|
+
"discussion-id": journal.session.discussion.id,
|
|
68
|
+
"discussion-url": journal.session.discussion.url,
|
|
69
|
+
};
|
|
70
|
+
observe(locator);
|
|
71
|
+
const locatorPath = path.join(
|
|
72
|
+
workspace,
|
|
73
|
+
".buildchain/release-tail/buildchain.release-transaction.json",
|
|
74
|
+
);
|
|
75
|
+
fs.mkdirSync(path.dirname(locatorPath), { recursive: true });
|
|
76
|
+
fs.writeFileSync(
|
|
77
|
+
locatorPath,
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
schema: "buildchain.release-locator/v1",
|
|
80
|
+
repository: request.repository,
|
|
81
|
+
version: request.version,
|
|
82
|
+
intent: journal.session.intent.id,
|
|
83
|
+
discussionId: journal.session.discussion.id,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
const publicReaderPath = path.join(
|
|
87
|
+
path.dirname(locatorPath),
|
|
88
|
+
"release-discussion.cjs",
|
|
89
|
+
);
|
|
90
|
+
fs.writeFileSync(
|
|
91
|
+
publicReaderPath,
|
|
92
|
+
await retained.publicationReader(fs.readFileSync(readerFile)),
|
|
93
|
+
);
|
|
94
|
+
const execution = {
|
|
95
|
+
...request,
|
|
96
|
+
discussionCheckpoint: retained.checkpoint,
|
|
97
|
+
discussionReaderPath: publicReaderPath,
|
|
98
|
+
discussionLocatorPath: locatorPath,
|
|
99
|
+
retainRecoveryMaterials: async () =>
|
|
100
|
+
retained.checkpoint(
|
|
101
|
+
"qualification",
|
|
102
|
+
await retainRecoveryMaterials(
|
|
103
|
+
{ request, workspace, readerFile },
|
|
104
|
+
retained.materials,
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
const result = await promoteReleaseCandidate(execution, {
|
|
109
|
+
octokit,
|
|
110
|
+
mutationOctokit,
|
|
111
|
+
actor,
|
|
112
|
+
runId,
|
|
113
|
+
observeNode: journal.observe,
|
|
114
|
+
observe: (outputs) => observe({ ...outputs, ...locator }),
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
...result,
|
|
118
|
+
discussion: journal.session.discussion,
|
|
119
|
+
outputs: { ...result.outputs, ...locator },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { discussionStatus } from "./reader.js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { releaseCheckpoints } from "./checkpoints.js";
|
|
5
|
+
import { openReleaseSession, selectedRecordRuntime } from "./session.js";
|
|
6
|
+
import { decodeRecord } from "./envelope.js";
|
|
7
|
+
import { discussionTransport } from "../../providers/github/discussions/transport.js";
|
|
8
|
+
import { releaseDiscussionStore } from "./store.js";
|
|
9
|
+
|
|
10
|
+
// The same public Bootstrap entry exposes transport qualification and diagnosis
|
|
11
|
+
// to any consumer; this is not a Buildchain-only writer or runtime bootstrap.
|
|
12
|
+
export async function executeReleaseDiscussion(request, _admission, context) {
|
|
13
|
+
const payload = request.payload;
|
|
14
|
+
if (
|
|
15
|
+
payload?.schema !== "buildchain.release-discussion-request/v1" ||
|
|
16
|
+
!["inspect", "qualify"].includes(payload.operation)
|
|
17
|
+
)
|
|
18
|
+
throw new Error("Unsupported release Discussion operation");
|
|
19
|
+
const allowed =
|
|
20
|
+
payload.operation === "inspect"
|
|
21
|
+
? ["schema", "operation", "discussionId"]
|
|
22
|
+
: [
|
|
23
|
+
"schema",
|
|
24
|
+
"operation",
|
|
25
|
+
"key",
|
|
26
|
+
"outcome",
|
|
27
|
+
"discussionId",
|
|
28
|
+
"predecessor",
|
|
29
|
+
"verifyMaterials",
|
|
30
|
+
];
|
|
31
|
+
if (Object.keys(payload).some((key) => !allowed.includes(key)))
|
|
32
|
+
throw new Error("Unknown release Discussion request field");
|
|
33
|
+
const repository = request.consumer.repository;
|
|
34
|
+
const transport = discussionTransport(context.octokit.graphql);
|
|
35
|
+
if (payload.operation === "inspect") {
|
|
36
|
+
const discussion = await transport.get(payload.discussionId);
|
|
37
|
+
const intent = decodeRecord(discussion.body);
|
|
38
|
+
if (intent?.repository !== repository)
|
|
39
|
+
throw new Error("Discussion is outside the consumer repository");
|
|
40
|
+
return discussionStatus(
|
|
41
|
+
await releaseDiscussionStore(transport).read({
|
|
42
|
+
discussion,
|
|
43
|
+
intent,
|
|
44
|
+
writerId: discussion.author.id,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (request.capability.permissions?.discussions !== "write")
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Discussion qualification requires declared discussions: write",
|
|
51
|
+
);
|
|
52
|
+
if (
|
|
53
|
+
!/^[a-zA-Z0-9._-]{1,80}$/u.test(payload.key || "") ||
|
|
54
|
+
!["success", "failure"].includes(payload.outcome)
|
|
55
|
+
)
|
|
56
|
+
throw new Error("Invalid Discussion qualification request");
|
|
57
|
+
const journal = await openReleaseSession({
|
|
58
|
+
graphql: context.octokit.graphql,
|
|
59
|
+
repository,
|
|
60
|
+
key: `qualification:${payload.key}`,
|
|
61
|
+
source: { qualification: payload.key },
|
|
62
|
+
expectedNodes: ["transport", "recovery"],
|
|
63
|
+
runtime: selectedRecordRuntime({
|
|
64
|
+
BUILDCHAIN_RUNTIME_SELECTION: context.runtimeSelection,
|
|
65
|
+
BUILDCHAIN_RUNTIME_ROOT: context.runtimeRoot,
|
|
66
|
+
}),
|
|
67
|
+
attempt: `${context.runId}:${context.runAttempt || "1"}`,
|
|
68
|
+
discussionId: payload.discussionId || "",
|
|
69
|
+
predecessor: payload.predecessor || "",
|
|
70
|
+
});
|
|
71
|
+
await journal.observe("transport", async () => {
|
|
72
|
+
if (payload.verifyMaterials === true) {
|
|
73
|
+
if (request.capability.permissions?.contents !== "write")
|
|
74
|
+
throw new Error(
|
|
75
|
+
"Material qualification requires declared contents: write",
|
|
76
|
+
);
|
|
77
|
+
const retained = releaseCheckpoints({
|
|
78
|
+
session: journal.session,
|
|
79
|
+
store: journal.store,
|
|
80
|
+
octokit: context.octokit,
|
|
81
|
+
});
|
|
82
|
+
const probe = await retained.checkpoint("transport", {
|
|
83
|
+
schema: "buildchain.material-qualification/v1",
|
|
84
|
+
value: payload.key,
|
|
85
|
+
});
|
|
86
|
+
if ((await retained.readCheckpoint(probe)).value !== payload.key)
|
|
87
|
+
throw new Error("Small material qualification readback mismatch");
|
|
88
|
+
const reader = await retained.materials.put(
|
|
89
|
+
fs.readFileSync(
|
|
90
|
+
path.join(context.runtimeRoot, "dist/readers/release-discussion.cjs"),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
const checkpoint = await retained.checkpoint("transport", {
|
|
94
|
+
schema: "buildchain.material-qualification/v1",
|
|
95
|
+
reader,
|
|
96
|
+
value: payload.key,
|
|
97
|
+
});
|
|
98
|
+
const readback = await retained.readCheckpoint(checkpoint);
|
|
99
|
+
if (readback.value !== payload.key)
|
|
100
|
+
throw new Error("Material qualification readback mismatch");
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
await journal.record("recovery", payload.outcome);
|
|
104
|
+
const state = await journal.read();
|
|
105
|
+
if (payload.outcome === "failure")
|
|
106
|
+
throw Object.assign(
|
|
107
|
+
new Error(
|
|
108
|
+
"Injected Discussion qualification failure after durable recording",
|
|
109
|
+
),
|
|
110
|
+
{ code: "discussion-qualification-injected-failure" },
|
|
111
|
+
);
|
|
112
|
+
return {
|
|
113
|
+
schema: "buildchain.release-discussion-qualification/v1",
|
|
114
|
+
discussionId: journal.session.discussion.id,
|
|
115
|
+
discussionUrl: journal.session.discussion.url,
|
|
116
|
+
status: state.status,
|
|
117
|
+
handoff: state.handoff,
|
|
118
|
+
attempt: state.attempt,
|
|
119
|
+
runtime: journal.session.runtime,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { readReleaseDiscussion } from "./reader.js";
|
|
3
|
+
export { readReleaseDiscussion } from "./reader.js";
|
|
4
|
+
export { decodeRecord } from "./envelope.js";
|
|
5
|
+
|
|
6
|
+
// The published self-contained reader accepts only captured JSON on stdin.
|
|
7
|
+
// Run with Node's permission model: no network, child process or file writes.
|
|
8
|
+
if (typeof require !== "undefined" && require.main === module) {
|
|
9
|
+
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
10
|
+
process.stdout.write(`${JSON.stringify(readReleaseDiscussion(input))}\n`);
|
|
11
|
+
}
|