@kungfu-tech/buildchain 4.1.2 → 4.1.3-alpha.1
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 +44 -0
- package/architecture/internal-capabilities.json +56 -0
- package/architecture/maintainability-debt.json +2 -1
- package/architecture/maintainability-policy.json +4 -4
- package/architecture/release-topology.json +14 -1
- package/architecture/universal-workflow-bootstrap.json +13 -2
- package/architecture/universal-workflow-capability-policy.json +14 -8
- 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 +143 -0
- package/dist/readers/release-discussion.cjs +207 -0
- package/dist/site/buildchain-contract.json +15 -11
- package/dist/site/buildchain-site.json +64 -8
- 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 +59 -3
- package/dist/site/public-surface-audit.json +53 -7
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +2 -1
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +79 -7
- package/docs/node-api-reference.md +9 -0
- package/docs/release-discussions.md +193 -0
- package/package.json +10 -6
- package/packages/core/providers/github/discussions/materials.js +157 -0
- package/packages/core/providers/github/discussions/transport.js +126 -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 +103 -0
- package/packages/core/release/discussion/checkpoints.js +282 -0
- package/packages/core/release/discussion/envelope.js +142 -0
- package/packages/core/release/discussion/evidence.js +51 -0
- package/packages/core/release/discussion/presentation.js +92 -0
- package/packages/core/release/discussion/publication.js +123 -0
- package/packages/core/release/discussion/qualification.js +122 -0
- package/packages/core/release/discussion/reader-entry.js +11 -0
- package/packages/core/release/discussion/reader.js +177 -0
- package/packages/core/release/discussion/recovery.js +132 -0
- package/packages/core/release/discussion/session.js +115 -0
- package/packages/core/release/discussion/store.js +166 -0
- package/packages/core/release/discussion/threads.js +83 -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,282 @@
|
|
|
1
|
+
import {
|
|
2
|
+
retainAttachment,
|
|
3
|
+
diagnosticReport,
|
|
4
|
+
diagnosticLog,
|
|
5
|
+
} from "./evidence.js";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { createProgress, recordDigest } from "./envelope.js";
|
|
9
|
+
import { materialDigest } from "../../providers/github/discussions/materials.js";
|
|
10
|
+
import { discussionMaterials } from "../../providers/github/discussions/materials.js";
|
|
11
|
+
|
|
12
|
+
export function releaseCheckpoints({ session, store, octokit }) {
|
|
13
|
+
const materials = discussionMaterials({
|
|
14
|
+
octokit,
|
|
15
|
+
repository: session.intent.repository,
|
|
16
|
+
intentId: session.intent.id,
|
|
17
|
+
});
|
|
18
|
+
async function checkpoint(
|
|
19
|
+
node,
|
|
20
|
+
value,
|
|
21
|
+
{ attachments = [], label = value.schema || "Recovery checkpoint" } = {},
|
|
22
|
+
) {
|
|
23
|
+
const observed = await store.read(session);
|
|
24
|
+
const records = observed.records.filter(
|
|
25
|
+
(record) =>
|
|
26
|
+
record.kind === "checkpoint" &&
|
|
27
|
+
record.attempt === session.attempt &&
|
|
28
|
+
record.node === node,
|
|
29
|
+
);
|
|
30
|
+
const root = recordDigest(value);
|
|
31
|
+
const duplicate = records.find((record) => record.payload.root === root);
|
|
32
|
+
if (duplicate) return duplicate;
|
|
33
|
+
const sequence =
|
|
34
|
+
Math.max(-1, ...records.map((record) => record.sequence)) + 1;
|
|
35
|
+
const handle = await retainAttachment(materials, {
|
|
36
|
+
name: `${node}-checkpoint-${sequence}.json`,
|
|
37
|
+
mediaType: "application/json",
|
|
38
|
+
bytes: Buffer.from(JSON.stringify(value, null, 2) + "\n"),
|
|
39
|
+
});
|
|
40
|
+
const record = createProgress({
|
|
41
|
+
intent: session.intent,
|
|
42
|
+
runtime: session.runtime,
|
|
43
|
+
attempt: session.attempt,
|
|
44
|
+
writer: session.writer || session.attempt,
|
|
45
|
+
predecessor: session.predecessor,
|
|
46
|
+
kind: "checkpoint",
|
|
47
|
+
node,
|
|
48
|
+
status: "running",
|
|
49
|
+
sequence,
|
|
50
|
+
payload: {
|
|
51
|
+
schema: "buildchain.release-checkpoint/v1",
|
|
52
|
+
root,
|
|
53
|
+
material: handle,
|
|
54
|
+
label,
|
|
55
|
+
attachments: [handle, ...attachments],
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
await store.append(session, record);
|
|
59
|
+
return record;
|
|
60
|
+
}
|
|
61
|
+
async function readCheckpoint(record) {
|
|
62
|
+
if (
|
|
63
|
+
record.kind !== "checkpoint" ||
|
|
64
|
+
record.payload?.schema !== "buildchain.release-checkpoint/v1"
|
|
65
|
+
)
|
|
66
|
+
throw new Error("Unsupported recovery checkpoint");
|
|
67
|
+
const value = JSON.parse(await materials.read(record.payload.material));
|
|
68
|
+
if (recordDigest(value) !== record.payload.root)
|
|
69
|
+
throw new Error("Recovery checkpoint root differs from its record");
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
async function publicationReader(currentBytes) {
|
|
73
|
+
const observed = await store.read(session);
|
|
74
|
+
const first = observed.records
|
|
75
|
+
.filter(
|
|
76
|
+
(record) =>
|
|
77
|
+
record.kind === "checkpoint" && record.node === "qualification",
|
|
78
|
+
)
|
|
79
|
+
.sort(
|
|
80
|
+
(a, b) =>
|
|
81
|
+
observed.attempts.indexOf(a.attempt) -
|
|
82
|
+
observed.attempts.indexOf(b.attempt) || a.sequence - b.sequence,
|
|
83
|
+
)[0];
|
|
84
|
+
if (!first) return currentBytes;
|
|
85
|
+
const manifest = await readCheckpoint(first);
|
|
86
|
+
if (manifest.schema !== "buildchain.release-recovery-material/v1")
|
|
87
|
+
throw new Error("Unsupported retained publication reader manifest");
|
|
88
|
+
return materials.read(manifest.reader);
|
|
89
|
+
}
|
|
90
|
+
async function diagnostics(node, code) {
|
|
91
|
+
const report = diagnosticReport(
|
|
92
|
+
session,
|
|
93
|
+
await store.read(session),
|
|
94
|
+
node,
|
|
95
|
+
code,
|
|
96
|
+
);
|
|
97
|
+
const log = await retainAttachment(materials, {
|
|
98
|
+
name: `${node}-diagnostics.log`,
|
|
99
|
+
mediaType: "text/plain",
|
|
100
|
+
bytes: diagnosticLog(report),
|
|
101
|
+
});
|
|
102
|
+
return checkpoint(node, report, {
|
|
103
|
+
attachments: [log],
|
|
104
|
+
label: `Execution diagnostics: ${report.code}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
checkpoint,
|
|
109
|
+
readCheckpoint,
|
|
110
|
+
materials,
|
|
111
|
+
publicationReader,
|
|
112
|
+
diagnostics,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function assertNoSymlink(file, base) {
|
|
117
|
+
let current = file;
|
|
118
|
+
while (current.startsWith(base)) {
|
|
119
|
+
if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink())
|
|
120
|
+
throw new Error("Recovery path contains a symbolic link");
|
|
121
|
+
if (current === base) break;
|
|
122
|
+
current = path.dirname(current);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const MATERIAL_INPUTS = [
|
|
127
|
+
"candidate-passport-path",
|
|
128
|
+
"candidate-build-summary-path",
|
|
129
|
+
"stage-capsules-path",
|
|
130
|
+
"product-publication-intent-path",
|
|
131
|
+
"publication-qualification-path",
|
|
132
|
+
"required-artifacts-path",
|
|
133
|
+
"sealed-bundle-manifest",
|
|
134
|
+
"recovery-receipt-path",
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
export async function retainRecoveryMaterials(
|
|
138
|
+
{ request, workspace, readerFile },
|
|
139
|
+
materials,
|
|
140
|
+
) {
|
|
141
|
+
const base = path.resolve(workspace);
|
|
142
|
+
const files = new Set(
|
|
143
|
+
MATERIAL_INPUTS.map((name) => request[name])
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((file) => path.resolve(base, file)),
|
|
146
|
+
);
|
|
147
|
+
for (const file of request["artifact-paths"] || [])
|
|
148
|
+
files.add(path.resolve(base, file));
|
|
149
|
+
const walk = (directory) => {
|
|
150
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
151
|
+
const file = path.join(directory, entry.name);
|
|
152
|
+
if (entry.isSymbolicLink())
|
|
153
|
+
throw new Error("Recovery materials must not contain symbolic links");
|
|
154
|
+
if (entry.isDirectory()) walk(file);
|
|
155
|
+
else if (entry.isFile()) files.add(file);
|
|
156
|
+
if (files.size > 512)
|
|
157
|
+
throw new Error("Recovery material file count exceeded");
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
if (request["sealed-bundle-root"]) {
|
|
161
|
+
const root = path.resolve(base, request["sealed-bundle-root"]);
|
|
162
|
+
if (!root.startsWith(path.join(base, ".buildchain") + path.sep))
|
|
163
|
+
throw new Error("Sealed recovery directory is outside consumer evidence");
|
|
164
|
+
assertNoSymlink(root, base);
|
|
165
|
+
walk(root);
|
|
166
|
+
}
|
|
167
|
+
const handles = [];
|
|
168
|
+
let totalSize = 0;
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
const relative = path.relative(base, file).split(path.sep).join("/");
|
|
171
|
+
if (
|
|
172
|
+
!relative.startsWith(".buildchain/") ||
|
|
173
|
+
relative.startsWith(".buildchain/runtime/") ||
|
|
174
|
+
relative.includes("../")
|
|
175
|
+
)
|
|
176
|
+
throw new Error(
|
|
177
|
+
"Recovery materials must remain in the declared consumer evidence directory",
|
|
178
|
+
);
|
|
179
|
+
assertNoSymlink(file, base);
|
|
180
|
+
const info = fs.lstatSync(file);
|
|
181
|
+
if (!info.isFile())
|
|
182
|
+
throw new Error("Recovery material is not a regular file");
|
|
183
|
+
totalSize += info.size;
|
|
184
|
+
if (totalSize > 1024 * 1024 * 1024)
|
|
185
|
+
throw new Error("Recovery material byte budget exceeded");
|
|
186
|
+
handles.push({
|
|
187
|
+
path: relative,
|
|
188
|
+
...(await materials.put(fs.readFileSync(file))),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
schema: "buildchain.release-recovery-material/v1",
|
|
193
|
+
source: {
|
|
194
|
+
repository: request.repository,
|
|
195
|
+
sourceSha: request["source-sha"],
|
|
196
|
+
version: request.version,
|
|
197
|
+
targetRef: request["target-ref"],
|
|
198
|
+
channel: request.channel,
|
|
199
|
+
},
|
|
200
|
+
releaseAssets: (request["artifact-paths"] || []).map((file) =>
|
|
201
|
+
path.relative(base, path.resolve(base, file)).split(path.sep).join("/"),
|
|
202
|
+
),
|
|
203
|
+
files: handles,
|
|
204
|
+
inputs: Object.fromEntries(
|
|
205
|
+
[...MATERIAL_INPUTS, "sealed-bundle-root"].map((key) => [
|
|
206
|
+
key,
|
|
207
|
+
request[key]
|
|
208
|
+
? path
|
|
209
|
+
.relative(base, path.resolve(base, request[key]))
|
|
210
|
+
.split(path.sep)
|
|
211
|
+
.join("/")
|
|
212
|
+
: "",
|
|
213
|
+
]),
|
|
214
|
+
),
|
|
215
|
+
reader: await materials.put(fs.readFileSync(readerFile)),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function recoveryInventory(manifest, directory) {
|
|
220
|
+
if (
|
|
221
|
+
manifest.schema !== "buildchain.release-recovery-material/v1" ||
|
|
222
|
+
!Array.isArray(manifest.files) ||
|
|
223
|
+
manifest.files.length > 512
|
|
224
|
+
)
|
|
225
|
+
throw new Error("Unsupported recovery material manifest");
|
|
226
|
+
const base = path.resolve(directory),
|
|
227
|
+
paths = new Set();
|
|
228
|
+
const files = manifest.files.map((handle) => {
|
|
229
|
+
if (
|
|
230
|
+
typeof handle.path !== "string" ||
|
|
231
|
+
!handle.path.startsWith(".buildchain/") ||
|
|
232
|
+
handle.path.startsWith(".buildchain/runtime/") ||
|
|
233
|
+
handle.path
|
|
234
|
+
.split(/[\\/]/u)
|
|
235
|
+
.some((part) => ["..", ".", ""].includes(part)) ||
|
|
236
|
+
paths.has(handle.path)
|
|
237
|
+
)
|
|
238
|
+
throw new Error("Unsafe recovery material path");
|
|
239
|
+
paths.add(handle.path);
|
|
240
|
+
const file = path.resolve(base, handle.path);
|
|
241
|
+
assertNoSymlink(file, base);
|
|
242
|
+
if (
|
|
243
|
+
fs.existsSync(file) &&
|
|
244
|
+
(!fs.lstatSync(file).isFile() ||
|
|
245
|
+
materialDigest(fs.readFileSync(file)) !== handle.digest)
|
|
246
|
+
)
|
|
247
|
+
throw new Error(
|
|
248
|
+
"Recovery material destination already exists with different bytes",
|
|
249
|
+
);
|
|
250
|
+
return { handle, file };
|
|
251
|
+
});
|
|
252
|
+
const inputs = Object.fromEntries(
|
|
253
|
+
Object.entries(manifest.inputs).map(([key, relative]) => {
|
|
254
|
+
if (!relative) return [key, ""];
|
|
255
|
+
const present =
|
|
256
|
+
key === "sealed-bundle-root"
|
|
257
|
+
? [...paths].some((file) => file.startsWith(relative + "/"))
|
|
258
|
+
: paths.has(relative);
|
|
259
|
+
if (!present)
|
|
260
|
+
throw new Error("Recovery input is outside the retained inventory");
|
|
261
|
+
return [key, path.resolve(base, relative)];
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
if ((manifest.releaseAssets || []).some((file) => !paths.has(file)))
|
|
265
|
+
throw new Error("Release asset is outside the retained inventory");
|
|
266
|
+
return { files, inputs };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function restoreRecoveryMaterials(manifest, directory, materials) {
|
|
270
|
+
const { files, inputs } = recoveryInventory(manifest, directory);
|
|
271
|
+
let total = 0;
|
|
272
|
+
for (const { handle, file } of files) {
|
|
273
|
+
total += handle.size || 0;
|
|
274
|
+
if (total > 1024 * 1024 * 1024)
|
|
275
|
+
throw new Error("Recovery material byte budget exceeded");
|
|
276
|
+
const bytes = await materials.read(handle);
|
|
277
|
+
if (fs.existsSync(file)) continue;
|
|
278
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
279
|
+
fs.writeFileSync(file, bytes, { flag: "wx" });
|
|
280
|
+
}
|
|
281
|
+
return inputs;
|
|
282
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
organization: "attempt-threads/v1",
|
|
98
|
+
runtime,
|
|
99
|
+
source,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createProgress({
|
|
104
|
+
intent,
|
|
105
|
+
attempt,
|
|
106
|
+
predecessor = "",
|
|
107
|
+
runtime,
|
|
108
|
+
node,
|
|
109
|
+
status,
|
|
110
|
+
payload = {},
|
|
111
|
+
sequence = 0,
|
|
112
|
+
kind = "progress",
|
|
113
|
+
writer = attempt,
|
|
114
|
+
}) {
|
|
115
|
+
validateRuntime(runtime);
|
|
116
|
+
requireString(writer, "Record writer");
|
|
117
|
+
requireString(attempt, "Workflow attempt");
|
|
118
|
+
if (node !== "attempt" && !intent.expectedNodes.includes(node))
|
|
119
|
+
throw new Error(`Undeclared release node: ${node}`);
|
|
120
|
+
if (!["running", "success", "failure", "cancelled"].includes(status))
|
|
121
|
+
throw new Error("Invalid release node status");
|
|
122
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0)
|
|
123
|
+
throw new Error("Invalid record sequence");
|
|
124
|
+
if (predecessor === attempt) throw new Error("Attempt cannot recover itself");
|
|
125
|
+
if (!["progress", "checkpoint"].includes(kind))
|
|
126
|
+
throw new Error("Invalid transaction record kind");
|
|
127
|
+
const event = {
|
|
128
|
+
kind,
|
|
129
|
+
writer,
|
|
130
|
+
schema: ENVELOPE_SCHEMA,
|
|
131
|
+
intent: intent.id,
|
|
132
|
+
attempt,
|
|
133
|
+
predecessor,
|
|
134
|
+
runtime,
|
|
135
|
+
payloadSchema: PAYLOAD_SCHEMA,
|
|
136
|
+
node,
|
|
137
|
+
status,
|
|
138
|
+
sequence,
|
|
139
|
+
payload,
|
|
140
|
+
};
|
|
141
|
+
return { ...event, id: recordDigest(event) };
|
|
142
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Evidence is declared transaction data, never an implicit upload of workspace
|
|
2
|
+
// files, environment variables, raw exceptions or authenticated HTTP responses.
|
|
3
|
+
export async function retainAttachment(materials, { name, mediaType, bytes }) {
|
|
4
|
+
if (
|
|
5
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,159}$/u.test(name) ||
|
|
6
|
+
!["application/json", "text/plain", "application/octet-stream"].includes(
|
|
7
|
+
mediaType,
|
|
8
|
+
)
|
|
9
|
+
)
|
|
10
|
+
throw new Error("Invalid transaction attachment descriptor");
|
|
11
|
+
const handle = await materials.put(bytes, { name, mediaType });
|
|
12
|
+
if (!handle.downloadUrl)
|
|
13
|
+
throw new Error("Retained attachment is missing its provider download URL");
|
|
14
|
+
return { name, mediaType, ...handle };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function diagnosticReport(session, state, node, code) {
|
|
18
|
+
return {
|
|
19
|
+
schema: "buildchain.release-diagnostics/v1",
|
|
20
|
+
intent: session.intent.id,
|
|
21
|
+
attempt: session.attempt,
|
|
22
|
+
node,
|
|
23
|
+
code: String(code || "execution-failed")
|
|
24
|
+
.replace(/[^a-zA-Z0-9._-]/gu, "-")
|
|
25
|
+
.slice(0, 160),
|
|
26
|
+
events: state.records
|
|
27
|
+
.filter((record) => record.attempt === session.attempt)
|
|
28
|
+
.map(({ id, node, kind, status, sequence, writer }) => ({
|
|
29
|
+
id,
|
|
30
|
+
node,
|
|
31
|
+
kind,
|
|
32
|
+
status,
|
|
33
|
+
sequence,
|
|
34
|
+
writer,
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function diagnosticLog(report) {
|
|
40
|
+
return Buffer.from(
|
|
41
|
+
[
|
|
42
|
+
`Release intent ${report.intent}`,
|
|
43
|
+
`Attempt ${report.attempt}: ${report.node} ${report.code}`,
|
|
44
|
+
...report.events.map(
|
|
45
|
+
(event) =>
|
|
46
|
+
`${event.node} ${event.kind} ${event.sequence} ${event.status} writer=${event.writer} record=${event.id}`,
|
|
47
|
+
),
|
|
48
|
+
"",
|
|
49
|
+
].join("\n"),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { isAttemptRoot } from "./threads.js";
|
|
2
|
+
|
|
3
|
+
const text = (value) =>
|
|
4
|
+
String(value ?? "")
|
|
5
|
+
.replace(
|
|
6
|
+
/[&<>\[\]`*_\\|]/gu,
|
|
7
|
+
(character) => `&#${character.charCodeAt(0)};`,
|
|
8
|
+
)
|
|
9
|
+
.replace(/[\r\n]/gu, " ");
|
|
10
|
+
const code = (value) => `\`${String(value).replace(/[`\r\n]/gu, " ")}\``;
|
|
11
|
+
|
|
12
|
+
function runLink(repository, writer) {
|
|
13
|
+
const match = /^(\d+):(\d+)(?::.*)?$/u.exec(writer);
|
|
14
|
+
return match
|
|
15
|
+
? `[${text(writer)}](https://github.com/${repository}/actions/runs/${match[1]}/attempts/${match[2]})`
|
|
16
|
+
: code(writer);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function runtimeLink(runtime) {
|
|
20
|
+
return `[${runtime.repository}@${runtime.sha.slice(0, 12)}](https://github.com/${runtime.repository}/commit/${runtime.sha})`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderIntent(intent) {
|
|
24
|
+
return [
|
|
25
|
+
`## ${intent.source?.qualification ? "Qualification" : "Release"}: ${text(intent.key)}`,
|
|
26
|
+
`Repository: ${code(intent.repository)}`,
|
|
27
|
+
"Each top-level transaction comment owns one attempt. Its replies contain node results, recovery checkpoints and downloadable evidence. Recovery attempts link to their predecessors.",
|
|
28
|
+
`Expected nodes: ${intent.expectedNodes.map(code).join(" → ")}`,
|
|
29
|
+
`Intent: ${code(intent.id)}`,
|
|
30
|
+
].join("\n\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function attachmentRows(record, repository) {
|
|
34
|
+
return (record.payload?.attachments || []).map((attachment) => {
|
|
35
|
+
const url = new URL(attachment.downloadUrl);
|
|
36
|
+
if (
|
|
37
|
+
url.protocol !== "https:" ||
|
|
38
|
+
url.hostname !== "github.com" ||
|
|
39
|
+
!url.pathname.startsWith(`/${repository}/releases/download/`) ||
|
|
40
|
+
url.search ||
|
|
41
|
+
url.hash
|
|
42
|
+
)
|
|
43
|
+
throw new Error(
|
|
44
|
+
"Attachment download must belong to the consumer repository",
|
|
45
|
+
);
|
|
46
|
+
return `| [${text(attachment.name)}](${url.href}) | ${text(attachment.mediaType)} | ${attachment.size} | ${code(attachment.digest)} |`;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderEvent(record, intent, state) {
|
|
51
|
+
if (isAttemptRoot(record)) {
|
|
52
|
+
const predecessor = state.roots.get(record.predecessor);
|
|
53
|
+
return [
|
|
54
|
+
`## Attempt ${text(record.attempt)}`,
|
|
55
|
+
`Execution: ${runLink(intent.repository, record.writer)} \nRuntime: ${runtimeLink(record.runtime)}`,
|
|
56
|
+
record.predecessor
|
|
57
|
+
? `Resumes: ${predecessor?.url ? `[${text(record.predecessor)}](${predecessor.url})` : code(record.predecessor)}`
|
|
58
|
+
: "Initial execution of this release intent.",
|
|
59
|
+
`Nodes: ${intent.expectedNodes.map(code).join(" → ")}`,
|
|
60
|
+
"Results and evidence are appended below. This root preserves the original execution context.",
|
|
61
|
+
].join("\n\n");
|
|
62
|
+
}
|
|
63
|
+
const label =
|
|
64
|
+
record.kind === "checkpoint"
|
|
65
|
+
? `checkpoint ${record.sequence}`
|
|
66
|
+
: record.status;
|
|
67
|
+
const lines = [
|
|
68
|
+
`### ${text(record.node)} · ${label}`,
|
|
69
|
+
`Attempt: ${code(record.attempt)} · Writer: ${runLink(intent.repository, record.writer)}`,
|
|
70
|
+
`Runtime: ${runtimeLink(record.runtime)}`,
|
|
71
|
+
];
|
|
72
|
+
if (record.payload?.label) lines.push(text(record.payload.label));
|
|
73
|
+
if (record.payload?.code)
|
|
74
|
+
lines.push(`Failure code: ${code(record.payload.code)}`);
|
|
75
|
+
const attachments = attachmentRows(record, intent.repository);
|
|
76
|
+
if (attachments.length)
|
|
77
|
+
lines.push(
|
|
78
|
+
"| Attachment | Type | Bytes | SHA-256 |\n| --- | --- | ---: | --- |\n" +
|
|
79
|
+
attachments.join("\n"),
|
|
80
|
+
"Retained files use the transaction material archive. Access requires repository permission to read draft release assets.",
|
|
81
|
+
);
|
|
82
|
+
const { attachments: _attachments, ...details } = record.payload || {};
|
|
83
|
+
if (Object.keys(details).length) {
|
|
84
|
+
const json = JSON.stringify(details, null, 2);
|
|
85
|
+
// HTML escaping keeps payload strings from closing the presentation container.
|
|
86
|
+
lines.push(
|
|
87
|
+
`<details><summary>Record details</summary><pre>${json.slice(0, 6000).replace(/[&<>]/gu, (c) => ({ "&": "&", "<": "<", ">": ">" })[c])}${json.length > 6000 ? "\n… See the retained JSON attachment for the complete material." : ""}</pre></details>`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
lines.push(`Record: ${code(record.id)}`);
|
|
91
|
+
return lines.join("\n\n");
|
|
92
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
let retained;
|
|
44
|
+
const journal = await openReleaseSession({
|
|
45
|
+
graphql: octokit.graphql,
|
|
46
|
+
onFailure: (node, error) => retained.diagnostics(node, error.code),
|
|
47
|
+
repository: request.repository,
|
|
48
|
+
key: request.version,
|
|
49
|
+
source: { version: request.version },
|
|
50
|
+
expectedNodes: await publicationNodes(request, octokit.graphql),
|
|
51
|
+
runtime,
|
|
52
|
+
attempt,
|
|
53
|
+
discussionId: request["resume-discussion-id"],
|
|
54
|
+
recover: Boolean(
|
|
55
|
+
request["resume-discussion-id"] ||
|
|
56
|
+
request["publish-transaction-override"],
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
retained = releaseCheckpoints({
|
|
60
|
+
session: journal.session,
|
|
61
|
+
store: journal.store,
|
|
62
|
+
octokit,
|
|
63
|
+
});
|
|
64
|
+
const readerFile = path.join(
|
|
65
|
+
runtimeRoot,
|
|
66
|
+
"dist/readers/release-discussion.cjs",
|
|
67
|
+
);
|
|
68
|
+
const locator = {
|
|
69
|
+
"discussion-id": journal.session.discussion.id,
|
|
70
|
+
"discussion-url": journal.session.discussion.url,
|
|
71
|
+
};
|
|
72
|
+
observe(locator);
|
|
73
|
+
const locatorPath = path.join(
|
|
74
|
+
workspace,
|
|
75
|
+
".buildchain/release-tail/buildchain.release-transaction.json",
|
|
76
|
+
);
|
|
77
|
+
fs.mkdirSync(path.dirname(locatorPath), { recursive: true });
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
locatorPath,
|
|
80
|
+
JSON.stringify({
|
|
81
|
+
schema: "buildchain.release-locator/v1",
|
|
82
|
+
repository: request.repository,
|
|
83
|
+
version: request.version,
|
|
84
|
+
intent: journal.session.intent.id,
|
|
85
|
+
discussionId: journal.session.discussion.id,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
const publicReaderPath = path.join(
|
|
89
|
+
path.dirname(locatorPath),
|
|
90
|
+
"release-discussion.cjs",
|
|
91
|
+
);
|
|
92
|
+
fs.writeFileSync(
|
|
93
|
+
publicReaderPath,
|
|
94
|
+
await retained.publicationReader(fs.readFileSync(readerFile)),
|
|
95
|
+
);
|
|
96
|
+
const execution = {
|
|
97
|
+
...request,
|
|
98
|
+
discussionCheckpoint: retained.checkpoint,
|
|
99
|
+
discussionReaderPath: publicReaderPath,
|
|
100
|
+
discussionLocatorPath: locatorPath,
|
|
101
|
+
retainRecoveryMaterials: async () =>
|
|
102
|
+
retained.checkpoint(
|
|
103
|
+
"qualification",
|
|
104
|
+
await retainRecoveryMaterials(
|
|
105
|
+
{ request, workspace, readerFile },
|
|
106
|
+
retained.materials,
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
};
|
|
110
|
+
const result = await promoteReleaseCandidate(execution, {
|
|
111
|
+
octokit,
|
|
112
|
+
mutationOctokit,
|
|
113
|
+
actor,
|
|
114
|
+
runId,
|
|
115
|
+
observeNode: journal.observe,
|
|
116
|
+
observe: (outputs) => observe({ ...outputs, ...locator }),
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
...result,
|
|
120
|
+
discussion: journal.session.discussion,
|
|
121
|
+
outputs: { ...result.outputs, ...locator },
|
|
122
|
+
};
|
|
123
|
+
}
|