@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,122 @@
|
|
|
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
|
+
await retained.diagnostics("transport", "qualification-probe");
|
|
83
|
+
const probe = await retained.checkpoint("transport", {
|
|
84
|
+
schema: "buildchain.material-qualification/v1",
|
|
85
|
+
value: payload.key,
|
|
86
|
+
});
|
|
87
|
+
if ((await retained.readCheckpoint(probe)).value !== payload.key)
|
|
88
|
+
throw new Error("Small material qualification readback mismatch");
|
|
89
|
+
const reader = await retained.materials.put(
|
|
90
|
+
fs.readFileSync(
|
|
91
|
+
path.join(context.runtimeRoot, "dist/readers/release-discussion.cjs"),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
const checkpoint = await retained.checkpoint("transport", {
|
|
95
|
+
schema: "buildchain.material-qualification/v1",
|
|
96
|
+
reader,
|
|
97
|
+
value: payload.key,
|
|
98
|
+
});
|
|
99
|
+
const readback = await retained.readCheckpoint(checkpoint);
|
|
100
|
+
if (readback.value !== payload.key)
|
|
101
|
+
throw new Error("Material qualification readback mismatch");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
await journal.record("recovery", payload.outcome);
|
|
105
|
+
const state = await journal.read();
|
|
106
|
+
if (payload.outcome === "failure")
|
|
107
|
+
throw Object.assign(
|
|
108
|
+
new Error(
|
|
109
|
+
"Injected Discussion qualification failure after durable recording",
|
|
110
|
+
),
|
|
111
|
+
{ code: "discussion-qualification-injected-failure" },
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
schema: "buildchain.release-discussion-qualification/v1",
|
|
115
|
+
discussionId: journal.session.discussion.id,
|
|
116
|
+
discussionUrl: journal.session.discussion.url,
|
|
117
|
+
status: state.status,
|
|
118
|
+
handoff: state.handoff,
|
|
119
|
+
attempt: state.attempt,
|
|
120
|
+
runtime: journal.session.runtime,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ENVELOPE_SCHEMA,
|
|
3
|
+
INTENT_SCHEMA,
|
|
4
|
+
PAYLOAD_SCHEMA,
|
|
5
|
+
decodeRecord,
|
|
6
|
+
recordDigest,
|
|
7
|
+
validateRuntime,
|
|
8
|
+
} from "./envelope.js";
|
|
9
|
+
|
|
10
|
+
// Pure historical reader: no provider access, writes or effect recovery.
|
|
11
|
+
export function readReleaseDiscussion({ body, records }) {
|
|
12
|
+
const intent = decodeRecord(body);
|
|
13
|
+
if (intent?.schema !== INTENT_SCHEMA)
|
|
14
|
+
throw new Error("Missing release Discussion intent");
|
|
15
|
+
if (
|
|
16
|
+
intent.id !==
|
|
17
|
+
recordDigest({ repository: intent.repository, key: intent.key })
|
|
18
|
+
)
|
|
19
|
+
throw new Error("Release intent identity mismatch");
|
|
20
|
+
if (
|
|
21
|
+
!Array.isArray(intent.expectedNodes) ||
|
|
22
|
+
!intent.expectedNodes.length ||
|
|
23
|
+
new Set(intent.expectedNodes).size !== intent.expectedNodes.length
|
|
24
|
+
)
|
|
25
|
+
throw new Error("Missing or duplicate expected nodes");
|
|
26
|
+
if (intent.organization && intent.organization !== "attempt-threads/v1")
|
|
27
|
+
throw new Error(
|
|
28
|
+
"Discussion organization requires its historical runtime reader",
|
|
29
|
+
);
|
|
30
|
+
const attempts = groupAttempts(validateRecords(intent, records));
|
|
31
|
+
if (!attempts.size)
|
|
32
|
+
return {
|
|
33
|
+
intent,
|
|
34
|
+
status: "pending",
|
|
35
|
+
attempts: [],
|
|
36
|
+
missingNodes: intent.expectedNodes,
|
|
37
|
+
};
|
|
38
|
+
const { head, lineage } = resolveLineage(attempts);
|
|
39
|
+
const nodes = currentNodes(attempts.get(head).events);
|
|
40
|
+
const missingNodes = intent.expectedNodes.filter((node) => !nodes[node]);
|
|
41
|
+
const complete = intent.expectedNodes.every(
|
|
42
|
+
(node) => nodes[node]?.status === "success",
|
|
43
|
+
);
|
|
44
|
+
const failed = Object.values(nodes).some((event) =>
|
|
45
|
+
["failure", "cancelled"].includes(event.status),
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
intent,
|
|
49
|
+
attempt: head,
|
|
50
|
+
attempts: lineage.reverse(),
|
|
51
|
+
nodes,
|
|
52
|
+
missingNodes,
|
|
53
|
+
status: complete ? "complete" : failed ? "failed" : "running",
|
|
54
|
+
handoff: {
|
|
55
|
+
schema: "buildchain.release-handoff/v1",
|
|
56
|
+
intent: intent.id,
|
|
57
|
+
predecessor: head,
|
|
58
|
+
completedNodes: intent.expectedNodes.filter(
|
|
59
|
+
(node) => nodes[node]?.status === "success",
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateRecords(intent, records) {
|
|
66
|
+
const events = new Map();
|
|
67
|
+
for (const raw of records) {
|
|
68
|
+
const event = typeof raw === "string" ? decodeRecord(raw) : raw;
|
|
69
|
+
if (!event) continue;
|
|
70
|
+
validateRuntime(event.runtime);
|
|
71
|
+
if (
|
|
72
|
+
!["progress", "checkpoint"].includes(event.kind) ||
|
|
73
|
+
!event.writer ||
|
|
74
|
+
!event.attempt ||
|
|
75
|
+
typeof event.predecessor !== "string"
|
|
76
|
+
)
|
|
77
|
+
throw new Error("Invalid record ownership");
|
|
78
|
+
const { id, ...content } = event;
|
|
79
|
+
if (
|
|
80
|
+
event.schema !== ENVELOPE_SCHEMA ||
|
|
81
|
+
event.payloadSchema !== PAYLOAD_SCHEMA
|
|
82
|
+
)
|
|
83
|
+
throw new Error("Record requires its historical runtime reader");
|
|
84
|
+
if (event.intent !== intent.id || id !== recordDigest(content))
|
|
85
|
+
throw new Error("Release record identity mismatch");
|
|
86
|
+
if (event.node !== "attempt" && !intent.expectedNodes.includes(event.node))
|
|
87
|
+
throw new Error("Undeclared release node");
|
|
88
|
+
if (
|
|
89
|
+
!["running", "success", "failure", "cancelled"].includes(event.status) ||
|
|
90
|
+
!Number.isSafeInteger(event.sequence) ||
|
|
91
|
+
event.sequence < 0
|
|
92
|
+
)
|
|
93
|
+
throw new Error("Invalid release progress");
|
|
94
|
+
events.set(id, event);
|
|
95
|
+
}
|
|
96
|
+
return events;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function groupAttempts(events) {
|
|
100
|
+
const attempts = new Map();
|
|
101
|
+
for (const event of events.values()) {
|
|
102
|
+
const attempt = attempts.get(event.attempt) || {
|
|
103
|
+
predecessor: event.predecessor,
|
|
104
|
+
events: [],
|
|
105
|
+
};
|
|
106
|
+
if (attempt.predecessor !== event.predecessor)
|
|
107
|
+
throw new Error("Conflicting workflow attempt identity");
|
|
108
|
+
attempt.events.push(event);
|
|
109
|
+
attempts.set(event.attempt, attempt);
|
|
110
|
+
}
|
|
111
|
+
return attempts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveLineage(attempts) {
|
|
115
|
+
const predecessors = new Set(
|
|
116
|
+
[...attempts.values()]
|
|
117
|
+
.map((attempt) => attempt.predecessor)
|
|
118
|
+
.filter(Boolean),
|
|
119
|
+
);
|
|
120
|
+
for (const predecessor of predecessors)
|
|
121
|
+
if (!attempts.has(predecessor))
|
|
122
|
+
throw new Error("Recovery predecessor is missing");
|
|
123
|
+
const heads = [...attempts.keys()].filter((id) => !predecessors.has(id));
|
|
124
|
+
if (heads.length !== 1)
|
|
125
|
+
throw new Error("Release attempt succession is ambiguous or cyclic");
|
|
126
|
+
const lineage = [];
|
|
127
|
+
let current = heads[0];
|
|
128
|
+
while (current) {
|
|
129
|
+
if (lineage.includes(current)) throw new Error("Cyclic recovery chain");
|
|
130
|
+
lineage.push(current);
|
|
131
|
+
current = attempts.get(current).predecessor;
|
|
132
|
+
}
|
|
133
|
+
if (lineage.length !== attempts.size)
|
|
134
|
+
throw new Error("Disconnected recovery chain");
|
|
135
|
+
return { head: heads[0], lineage };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function currentNodes(events) {
|
|
139
|
+
const nodes = {},
|
|
140
|
+
sequences = new Map();
|
|
141
|
+
// Reuse is explicit and independently qualified by the recovering runtime.
|
|
142
|
+
for (const event of events) {
|
|
143
|
+
if (event.kind === "checkpoint") continue;
|
|
144
|
+
const key = `${event.node}:${event.sequence}`;
|
|
145
|
+
if (sequences.has(key) && sequences.get(key) !== event.id)
|
|
146
|
+
throw new Error("Conflicting node progress at the same sequence");
|
|
147
|
+
sequences.set(key, event.id);
|
|
148
|
+
const old = nodes[event.node];
|
|
149
|
+
if (!old || old.sequence < event.sequence) nodes[event.node] = event;
|
|
150
|
+
}
|
|
151
|
+
return nodes;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Workflow outputs carry a bounded projection, never the complete comment log.
|
|
155
|
+
export function discussionStatus(state) {
|
|
156
|
+
return {
|
|
157
|
+
discussionId: state.discussion.id,
|
|
158
|
+
discussionUrl: state.discussion.url,
|
|
159
|
+
intent: state.intent.id,
|
|
160
|
+
status: state.status,
|
|
161
|
+
attempt: state.attempt || "",
|
|
162
|
+
attempts: state.attempts,
|
|
163
|
+
threads: [...(state.roots || new Map())].map(([attempt, comment]) => ({
|
|
164
|
+
attempt,
|
|
165
|
+
commentId: comment.id,
|
|
166
|
+
url: comment.url,
|
|
167
|
+
})),
|
|
168
|
+
missingNodes: state.missingNodes,
|
|
169
|
+
handoff: state.handoff || null,
|
|
170
|
+
nodes: Object.fromEntries(
|
|
171
|
+
Object.entries(state.nodes || {}).map(([node, record]) => [
|
|
172
|
+
node,
|
|
173
|
+
{ status: record.status, recordId: record.id, writer: record.writer },
|
|
174
|
+
]),
|
|
175
|
+
),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getOctokit } from "@actions/github";
|
|
4
|
+
import { decodeRecord } from "./envelope.js";
|
|
5
|
+
import { releaseDiscussionStore } from "./store.js";
|
|
6
|
+
import { discussionTransport } from "../../providers/github/discussions/transport.js";
|
|
7
|
+
import { releaseCheckpoints, restoreRecoveryMaterials } from "./checkpoints.js";
|
|
8
|
+
import { validateReleaseCandidatePassport } from "../release-candidate.js";
|
|
9
|
+
import {
|
|
10
|
+
assertCandidateEvidenceBinding,
|
|
11
|
+
aggregateReleasePassport,
|
|
12
|
+
observeProtectedPublicationSource,
|
|
13
|
+
} from "../promote-candidate/evidence-binding.js";
|
|
14
|
+
|
|
15
|
+
export async function recoverDiscussionCandidate({
|
|
16
|
+
discussionId,
|
|
17
|
+
repository,
|
|
18
|
+
token,
|
|
19
|
+
outputDir,
|
|
20
|
+
targetSha,
|
|
21
|
+
targetRef,
|
|
22
|
+
}) {
|
|
23
|
+
const octokit = getOctokit(token);
|
|
24
|
+
const transport = discussionTransport(octokit.graphql);
|
|
25
|
+
const discussion = await transport.get(discussionId);
|
|
26
|
+
const intent = decodeRecord(discussion.body);
|
|
27
|
+
if (
|
|
28
|
+
intent?.repository !== repository ||
|
|
29
|
+
discussion.author?.__typename !== "Bot" ||
|
|
30
|
+
discussion.author.login !== "github-actions"
|
|
31
|
+
)
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Recovery requires the consumer workflow's original Discussion",
|
|
34
|
+
);
|
|
35
|
+
const session = { discussion, intent, writerId: discussion.author.id };
|
|
36
|
+
const store = releaseDiscussionStore(transport);
|
|
37
|
+
const observed = await store.read(session);
|
|
38
|
+
const snapshots = observed.records
|
|
39
|
+
.filter(
|
|
40
|
+
(record) =>
|
|
41
|
+
record.kind === "checkpoint" && record.node === "qualification",
|
|
42
|
+
)
|
|
43
|
+
.sort(
|
|
44
|
+
(a, b) =>
|
|
45
|
+
observed.attempts.indexOf(b.attempt) -
|
|
46
|
+
observed.attempts.indexOf(a.attempt) || b.sequence - a.sequence,
|
|
47
|
+
);
|
|
48
|
+
if (!snapshots.length)
|
|
49
|
+
throw new Error("Discussion has no retained qualified recovery material");
|
|
50
|
+
const retained = releaseCheckpoints({ session, store, octokit });
|
|
51
|
+
const manifest = await retained.readCheckpoint(snapshots[0]);
|
|
52
|
+
if (
|
|
53
|
+
manifest.source?.repository !== repository ||
|
|
54
|
+
manifest.source.version !== intent.key ||
|
|
55
|
+
manifest.source.targetRef !== targetRef
|
|
56
|
+
)
|
|
57
|
+
throw new Error("Recovery material belongs to a different release intent");
|
|
58
|
+
const workspace = path.resolve(outputDir, "../..");
|
|
59
|
+
const prefix =
|
|
60
|
+
path
|
|
61
|
+
.relative(workspace, path.resolve(outputDir))
|
|
62
|
+
.split(path.sep)
|
|
63
|
+
.join("/") + "/";
|
|
64
|
+
if (!manifest.files.every((file) => file.path.startsWith(prefix)))
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Candidate recovery cannot restore files outside its evidence directory",
|
|
67
|
+
);
|
|
68
|
+
const inputs = await restoreRecoveryMaterials(
|
|
69
|
+
manifest,
|
|
70
|
+
workspace,
|
|
71
|
+
retained.materials,
|
|
72
|
+
);
|
|
73
|
+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
74
|
+
const candidate = read(inputs["candidate-passport-path"]),
|
|
75
|
+
summary = read(inputs["candidate-build-summary-path"]);
|
|
76
|
+
const validation = validateReleaseCandidatePassport({
|
|
77
|
+
passport: candidate,
|
|
78
|
+
repository,
|
|
79
|
+
buildSummary: summary,
|
|
80
|
+
});
|
|
81
|
+
if (!validation.ok)
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Recovered candidate is invalid: ${validation.errors.join("; ")}`,
|
|
84
|
+
);
|
|
85
|
+
const stageCapsules = read(inputs["stage-capsules-path"]),
|
|
86
|
+
qualification = read(inputs["publication-qualification-path"]);
|
|
87
|
+
assertCandidateEvidenceBinding({ candidate, stageCapsules, repository });
|
|
88
|
+
const sourceBinding = await observeProtectedPublicationSource({
|
|
89
|
+
octokit,
|
|
90
|
+
repository,
|
|
91
|
+
protectedSourceSha: targetSha,
|
|
92
|
+
candidate,
|
|
93
|
+
});
|
|
94
|
+
aggregateReleasePassport({
|
|
95
|
+
candidate,
|
|
96
|
+
stageCapsules,
|
|
97
|
+
qualification,
|
|
98
|
+
sourceBinding,
|
|
99
|
+
version: intent.key,
|
|
100
|
+
tag: `v${intent.key}`,
|
|
101
|
+
channel: manifest.source.channel,
|
|
102
|
+
});
|
|
103
|
+
// The shared candidate provider performs artifact-kind-specific sealed verification
|
|
104
|
+
// before effects; transport restoration verifies every retained file digest.
|
|
105
|
+
const paths = {
|
|
106
|
+
passport: inputs["candidate-passport-path"],
|
|
107
|
+
buildSummary: inputs["candidate-build-summary-path"],
|
|
108
|
+
stageCapsules: inputs["stage-capsules-path"],
|
|
109
|
+
publicationQualification: inputs["publication-qualification-path"],
|
|
110
|
+
sealedBundleRoot: inputs["sealed-bundle-root"],
|
|
111
|
+
sealedBundleManifest: inputs["sealed-bundle-manifest"],
|
|
112
|
+
publishRequiredArtifacts: inputs["required-artifacts-path"],
|
|
113
|
+
recoveryReceipt: inputs["recovery-receipt-path"] || "",
|
|
114
|
+
releaseAssets: (manifest.releaseAssets || []).map((file) =>
|
|
115
|
+
path.resolve(workspace, file),
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
enabled: true,
|
|
120
|
+
version: candidate.target.version,
|
|
121
|
+
publicationVersion: intent.key,
|
|
122
|
+
candidateVersion: candidate.target.version,
|
|
123
|
+
artifacts: {
|
|
124
|
+
sourceSha: candidate.source.headSha,
|
|
125
|
+
passport: `discussion-${discussion.number}`,
|
|
126
|
+
},
|
|
127
|
+
paths,
|
|
128
|
+
run: { id: snapshots[0].attempt.split(":")[0] },
|
|
129
|
+
discussionId,
|
|
130
|
+
handoff: observed.handoff,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createIntent, createProgress } from "./envelope.js";
|
|
5
|
+
import { releaseDiscussionStore } from "./store.js";
|
|
6
|
+
import { discussionTransport } from "../../providers/github/discussions/transport.js";
|
|
7
|
+
|
|
8
|
+
export const RELEASE_NODES = [
|
|
9
|
+
"qualification",
|
|
10
|
+
"publication",
|
|
11
|
+
"github-release",
|
|
12
|
+
"next-development",
|
|
13
|
+
"settlement",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function selectedRecordRuntime(env) {
|
|
17
|
+
const selection = JSON.parse(env.BUILDCHAIN_RUNTIME_SELECTION || "{}");
|
|
18
|
+
const file = path.join(
|
|
19
|
+
env.BUILDCHAIN_RUNTIME_ROOT || "",
|
|
20
|
+
"dist/readers/release-discussion.cjs",
|
|
21
|
+
);
|
|
22
|
+
return {
|
|
23
|
+
repository: selection.repository,
|
|
24
|
+
sha: selection.sha,
|
|
25
|
+
readerPath: "dist/readers/release-discussion.cjs",
|
|
26
|
+
readerDigest: `sha256:${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function openReleaseSession({
|
|
31
|
+
graphql,
|
|
32
|
+
repository,
|
|
33
|
+
key,
|
|
34
|
+
source,
|
|
35
|
+
runtime,
|
|
36
|
+
attempt,
|
|
37
|
+
discussionId = "",
|
|
38
|
+
predecessor = "",
|
|
39
|
+
dryRun = false,
|
|
40
|
+
recover = false,
|
|
41
|
+
expectedNodes = RELEASE_NODES,
|
|
42
|
+
onFailure = async () => {},
|
|
43
|
+
}) {
|
|
44
|
+
const intent = createIntent({
|
|
45
|
+
repository,
|
|
46
|
+
key,
|
|
47
|
+
source,
|
|
48
|
+
runtime,
|
|
49
|
+
expectedNodes,
|
|
50
|
+
});
|
|
51
|
+
const store = releaseDiscussionStore(discussionTransport(graphql));
|
|
52
|
+
const session = await store.initialize({ intent, discussionId, dryRun });
|
|
53
|
+
if (dryRun)
|
|
54
|
+
return {
|
|
55
|
+
session,
|
|
56
|
+
observe: async (_node, effect) => effect(),
|
|
57
|
+
record: async () => {},
|
|
58
|
+
read: async () => ({ dryRun: true }),
|
|
59
|
+
};
|
|
60
|
+
const current = await store.read(session);
|
|
61
|
+
const retained = current.records.find((event) => event.attempt === attempt);
|
|
62
|
+
const prior =
|
|
63
|
+
retained?.predecessor ??
|
|
64
|
+
(predecessor || (recover ? current.attempt || "" : ""));
|
|
65
|
+
if (
|
|
66
|
+
current.attempt &&
|
|
67
|
+
current.attempt !== attempt &&
|
|
68
|
+
prior !== current.attempt
|
|
69
|
+
)
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Recovery requires predecessor ${current.attempt} in the same Discussion`,
|
|
72
|
+
);
|
|
73
|
+
session.attempt = attempt;
|
|
74
|
+
session.predecessor = prior;
|
|
75
|
+
session.runtime = runtime;
|
|
76
|
+
const record = async (node, status, payload = {}, sequence = 0) =>
|
|
77
|
+
store.append(
|
|
78
|
+
session,
|
|
79
|
+
createProgress({
|
|
80
|
+
intent: session.intent,
|
|
81
|
+
runtime,
|
|
82
|
+
attempt,
|
|
83
|
+
predecessor: prior,
|
|
84
|
+
node,
|
|
85
|
+
status,
|
|
86
|
+
payload,
|
|
87
|
+
sequence,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
await record("attempt", "running");
|
|
91
|
+
const observe = async (node, effect, summarize = () => ({})) => {
|
|
92
|
+
await record(node, "running");
|
|
93
|
+
let result;
|
|
94
|
+
try {
|
|
95
|
+
result = await effect();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Preserve the original failure even when the diagnostic provider is down.
|
|
98
|
+
try {
|
|
99
|
+
await record(
|
|
100
|
+
node,
|
|
101
|
+
"failure",
|
|
102
|
+
{ code: String(error.code || "execution-failed") },
|
|
103
|
+
1,
|
|
104
|
+
);
|
|
105
|
+
await onFailure(node, error);
|
|
106
|
+
} catch (recordError) {
|
|
107
|
+
error.discussionRecordingError = recordError.message;
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
await record(node, "success", summarize(result), 1);
|
|
112
|
+
return result;
|
|
113
|
+
};
|
|
114
|
+
return { session, store, observe, record, read: () => store.read(session) };
|
|
115
|
+
}
|