@kungfu-tech/buildchain 0.0.0-bootstrap.0 → 2.0.13-alpha.11
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/README.md +262 -0
- package/bin/buildchain.mjs +222 -0
- package/docs/cli.md +124 -0
- package/docs/lifecycle-protocol.md +422 -0
- package/docs/publish-transaction.md +285 -0
- package/docs/reusable-build-surface.md +350 -0
- package/docs/web-surface-deployments.md +211 -0
- package/package.json +52 -1
- package/packages/core/README.md +15 -0
- package/packages/core/buildchain-config.js +721 -0
- package/packages/core/index.js +40 -0
- package/packages/core/package-manager.js +291 -0
- package/packages/core/publish-transaction.js +418 -0
- package/packages/core/release-line-dry-run.js +296 -0
- package/scripts/aggregate-build-summary.mjs +88 -0
- package/scripts/build-contract-core.mjs +731 -0
- package/scripts/check-inventory.mjs +325 -0
- package/scripts/init-repo.mjs +316 -0
- package/scripts/npm-publish-dry-run.mjs +176 -0
- package/scripts/npm-publish-transaction.mjs +268 -0
- package/scripts/publish-source-ref-resolver.mjs +113 -0
- package/scripts/release-line-dry-run.mjs +53 -0
- package/scripts/release-line-policy.mjs +141 -0
- package/scripts/release-transaction.mjs +212 -0
- package/scripts/resolve-build-contract.mjs +63 -0
- package/scripts/resolve-publish-gate.mjs +33 -0
- package/scripts/resolve-publish-source.mjs +99 -0
- package/scripts/run-lifecycle-core.mjs +162 -0
- package/scripts/run-lifecycle.mjs +40 -0
- package/scripts/strip-trailing-whitespace.mjs +14 -0
- package/scripts/tsup-action.config.mjs +19 -0
- package/scripts/verify-publish-source-lock.mjs +37 -0
- package/scripts/verify-release-pr.mjs +34 -0
- package/scripts/web-surface-core.mjs +382 -0
- package/scripts/web-surface.mjs +112 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const RELEASE_TRANSACTION_STATES = Object.freeze([
|
|
6
|
+
"prepared",
|
|
7
|
+
"publishing",
|
|
8
|
+
"publish_failed",
|
|
9
|
+
"published",
|
|
10
|
+
"finalizing",
|
|
11
|
+
"complete",
|
|
12
|
+
"repair_required",
|
|
13
|
+
"abandoned",
|
|
14
|
+
"failed_permanently",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const TERMINAL_STATES = new Set(["complete", "abandoned", "failed_permanently"]);
|
|
18
|
+
const BLOCKED_RECOVERY_STATES = new Set(["abandoned", "failed_permanently"]);
|
|
19
|
+
|
|
20
|
+
const ALLOWED_TRANSITIONS = new Map([
|
|
21
|
+
["prepared", new Set(["publishing", "abandoned", "failed_permanently"])],
|
|
22
|
+
["publishing", new Set(["publish_failed", "published", "repair_required", "abandoned", "failed_permanently"])],
|
|
23
|
+
["publish_failed", new Set(["publishing", "repair_required", "abandoned", "failed_permanently"])],
|
|
24
|
+
["published", new Set(["finalizing", "complete", "repair_required", "abandoned", "failed_permanently"])],
|
|
25
|
+
["finalizing", new Set(["complete", "repair_required", "failed_permanently"])],
|
|
26
|
+
["repair_required", new Set(["publishing", "abandoned", "failed_permanently"])],
|
|
27
|
+
["complete", new Set([])],
|
|
28
|
+
["abandoned", new Set(["publishing"])],
|
|
29
|
+
["failed_permanently", new Set(["publishing"])],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function nowIso() {
|
|
33
|
+
return new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertKnownState(state, label = "transaction state") {
|
|
37
|
+
if (!RELEASE_TRANSACTION_STATES.includes(state)) {
|
|
38
|
+
throw new Error(`${label} must be one of ${RELEASE_TRANSACTION_STATES.join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertNonEmptyString(value, label) {
|
|
43
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
44
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function optionalString(value) {
|
|
50
|
+
return value === undefined || value === null ? "" : String(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function posixPath(value) {
|
|
54
|
+
return String(value || "").split(path.sep).join("/");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stableJson(value) {
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
60
|
+
}
|
|
61
|
+
if (value && typeof value === "object") {
|
|
62
|
+
return `{${Object.keys(value)
|
|
63
|
+
.sort()
|
|
64
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
65
|
+
.join(",")}}`;
|
|
66
|
+
}
|
|
67
|
+
return JSON.stringify(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sha256(value) {
|
|
71
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function releaseTransactionId({ repository, version, sourceSha, targetRef }) {
|
|
75
|
+
return sha256(
|
|
76
|
+
stableJson({
|
|
77
|
+
repository: assertNonEmptyString(repository, "repository"),
|
|
78
|
+
version: assertNonEmptyString(version, "version"),
|
|
79
|
+
source_sha: assertNonEmptyString(sourceSha, "sourceSha"),
|
|
80
|
+
target_ref: assertNonEmptyString(targetRef, "targetRef"),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function releaseTransactionStateRef(version) {
|
|
86
|
+
const safeVersion = encodeURIComponent(assertNonEmptyString(version, "version"))
|
|
87
|
+
.replaceAll("%", "_")
|
|
88
|
+
.replaceAll(".", "-");
|
|
89
|
+
return `buildchain/release-state/${safeVersion}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function defaultReleaseStatePath(version, workspace = process.cwd()) {
|
|
93
|
+
return path.join(workspace, ".buildchain", "release-state", `${version}.json`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function defaultPublishEvidenceDir(version, workspace = process.cwd()) {
|
|
97
|
+
return path.join(workspace, ".buildchain", "release-evidence", version);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function defaultPublishEvidencePath(version, workspace = process.cwd()) {
|
|
101
|
+
return path.join(defaultPublishEvidenceDir(version, workspace), "evidence.json");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createReleaseTransaction({
|
|
105
|
+
repository,
|
|
106
|
+
version,
|
|
107
|
+
exactTag = "",
|
|
108
|
+
channel,
|
|
109
|
+
line = "",
|
|
110
|
+
sourceSha,
|
|
111
|
+
targetRef,
|
|
112
|
+
releaseSha,
|
|
113
|
+
releaseMaterialSha = releaseSha,
|
|
114
|
+
publishToolingSha = "",
|
|
115
|
+
lifecycleIdentity = "lifecycle.publish",
|
|
116
|
+
statePath = "",
|
|
117
|
+
evidencePath = "",
|
|
118
|
+
actor = "",
|
|
119
|
+
runId = "",
|
|
120
|
+
} = {}) {
|
|
121
|
+
const id = releaseTransactionId({ repository, version, sourceSha, targetRef });
|
|
122
|
+
const createdAt = nowIso();
|
|
123
|
+
return {
|
|
124
|
+
schema: 1,
|
|
125
|
+
id,
|
|
126
|
+
repository: assertNonEmptyString(repository, "repository"),
|
|
127
|
+
target_ref: assertNonEmptyString(targetRef, "targetRef"),
|
|
128
|
+
source_sha: assertNonEmptyString(sourceSha, "sourceSha"),
|
|
129
|
+
release_sha: assertNonEmptyString(releaseSha, "releaseSha"),
|
|
130
|
+
release_material_sha: assertNonEmptyString(releaseMaterialSha, "releaseMaterialSha"),
|
|
131
|
+
publish_tooling_sha: optionalString(publishToolingSha || releaseSha),
|
|
132
|
+
version: assertNonEmptyString(version, "version"),
|
|
133
|
+
exact_tag: assertNonEmptyString(exactTag || version, "exactTag"),
|
|
134
|
+
channel: assertNonEmptyString(channel, "channel"),
|
|
135
|
+
line: optionalString(line),
|
|
136
|
+
version_strategy: "",
|
|
137
|
+
lifecycle_identity: assertNonEmptyString(lifecycleIdentity, "lifecycleIdentity"),
|
|
138
|
+
state_ref: releaseTransactionStateRef(version),
|
|
139
|
+
state_path: statePath ? posixPath(statePath) : "",
|
|
140
|
+
evidence_path: evidencePath ? posixPath(evidencePath) : "",
|
|
141
|
+
state: "prepared",
|
|
142
|
+
previous_state: "",
|
|
143
|
+
actor: optionalString(actor),
|
|
144
|
+
run_id: optionalString(runId),
|
|
145
|
+
superseded_by: "",
|
|
146
|
+
failure: "",
|
|
147
|
+
artifacts: [],
|
|
148
|
+
evidence: [],
|
|
149
|
+
created_at: createdAt,
|
|
150
|
+
updated_at: createdAt,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function transitionReleaseTransaction(record, nextState, metadata = {}) {
|
|
155
|
+
if (!record || typeof record !== "object") {
|
|
156
|
+
throw new Error("release transaction record must be an object");
|
|
157
|
+
}
|
|
158
|
+
const currentState = record.state || "prepared";
|
|
159
|
+
assertKnownState(currentState, "current transaction state");
|
|
160
|
+
assertKnownState(nextState, "next transaction state");
|
|
161
|
+
if (currentState !== nextState) {
|
|
162
|
+
const allowed = ALLOWED_TRANSITIONS.get(currentState) || new Set();
|
|
163
|
+
if (!allowed.has(nextState)) {
|
|
164
|
+
throw new Error(`cannot transition release transaction from ${currentState} to ${nextState}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
...record,
|
|
169
|
+
previous_state: currentState === nextState ? record.previous_state || "" : currentState,
|
|
170
|
+
state: nextState,
|
|
171
|
+
actor: optionalString(metadata.actor ?? record.actor),
|
|
172
|
+
run_id: optionalString(metadata.runId ?? record.run_id),
|
|
173
|
+
superseded_by: optionalString(metadata.supersededBy ?? record.superseded_by),
|
|
174
|
+
failure: optionalString(metadata.failure ?? record.failure),
|
|
175
|
+
updated_at: nowIso(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function readReleaseTransaction(filePath) {
|
|
180
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const record = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
184
|
+
assertKnownState(record.state || "prepared");
|
|
185
|
+
return record;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function writeReleaseTransaction(filePath, record) {
|
|
189
|
+
if (!filePath) {
|
|
190
|
+
return record;
|
|
191
|
+
}
|
|
192
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
193
|
+
fs.writeFileSync(filePath, `${JSON.stringify(record, null, 2)}\n`);
|
|
194
|
+
return record;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function artifactIdentity(artifact) {
|
|
198
|
+
return [
|
|
199
|
+
artifact.group || "",
|
|
200
|
+
artifact.kind,
|
|
201
|
+
artifact.name,
|
|
202
|
+
artifact.ref || "",
|
|
203
|
+
].join("\0");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function artifactMatchesRequirement(actual, required) {
|
|
207
|
+
return (
|
|
208
|
+
actual.kind === required.kind &&
|
|
209
|
+
actual.name === required.name &&
|
|
210
|
+
(!required.ref || actual.ref === required.ref) &&
|
|
211
|
+
(!required.group || actual.group === required.group)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function normalizePublishArtifact(artifact, label = "artifact") {
|
|
216
|
+
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
|
|
217
|
+
throw new Error(`${label} must be an object`);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
group: optionalString(artifact.group),
|
|
221
|
+
kind: assertNonEmptyString(artifact.kind, `${label}.kind`),
|
|
222
|
+
name: assertNonEmptyString(artifact.name, `${label}.name`),
|
|
223
|
+
ref: optionalString(artifact.ref),
|
|
224
|
+
digest: assertNonEmptyString(artifact.digest, `${label}.digest`),
|
|
225
|
+
required: artifact.required === undefined ? true : Boolean(artifact.required),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function parsePublishArtifactsJson(value, label = "publish artifacts") {
|
|
230
|
+
const raw = String(value || "").trim();
|
|
231
|
+
if (!raw) {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
const parsed = JSON.parse(raw);
|
|
235
|
+
if (!Array.isArray(parsed)) {
|
|
236
|
+
throw new Error(`${label} must be a JSON array`);
|
|
237
|
+
}
|
|
238
|
+
return parsed.map((artifact, index) =>
|
|
239
|
+
normalizePublishArtifact(artifact, `${label}[${index}]`),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function normalizePublishEvidence(evidence) {
|
|
244
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
245
|
+
throw new Error("publish evidence must be an object");
|
|
246
|
+
}
|
|
247
|
+
if (Number(evidence.schema) !== 1) {
|
|
248
|
+
throw new Error("publish evidence schema must be 1");
|
|
249
|
+
}
|
|
250
|
+
const artifacts = Array.isArray(evidence.artifacts)
|
|
251
|
+
? evidence.artifacts.map((artifact, index) =>
|
|
252
|
+
normalizePublishArtifact(artifact, `artifacts[${index}]`),
|
|
253
|
+
)
|
|
254
|
+
: [];
|
|
255
|
+
return {
|
|
256
|
+
schema: 1,
|
|
257
|
+
version: assertNonEmptyString(evidence.version, "evidence.version"),
|
|
258
|
+
channel: assertNonEmptyString(evidence.channel, "evidence.channel"),
|
|
259
|
+
source_sha: assertNonEmptyString(evidence.source_sha, "evidence.source_sha"),
|
|
260
|
+
release_sha: assertNonEmptyString(evidence.release_sha, "evidence.release_sha"),
|
|
261
|
+
target_ref: optionalString(evidence.target_ref),
|
|
262
|
+
release_material_sha: optionalString(evidence.release_material_sha || evidence.release_sha),
|
|
263
|
+
publish_tooling_sha: optionalString(evidence.publish_tooling_sha || evidence.release_sha),
|
|
264
|
+
artifacts,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function readPublishEvidence(filePath) {
|
|
269
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
return normalizePublishEvidence(JSON.parse(fs.readFileSync(filePath, "utf8")));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function planArtifactPublish({ requiredArtifacts = [], existingArtifacts = [] } = {}) {
|
|
276
|
+
const required = requiredArtifacts.map((artifact, index) =>
|
|
277
|
+
normalizePublishArtifact(artifact, `requiredArtifacts[${index}]`),
|
|
278
|
+
);
|
|
279
|
+
const existing = existingArtifacts.map((artifact, index) =>
|
|
280
|
+
normalizePublishArtifact(artifact, `existingArtifacts[${index}]`),
|
|
281
|
+
);
|
|
282
|
+
const existingByIdentity = new Map(existing.map((artifact) => [artifactIdentity(artifact), artifact]));
|
|
283
|
+
const publish = [];
|
|
284
|
+
const accepted = [];
|
|
285
|
+
const conflicts = [];
|
|
286
|
+
for (const artifact of required) {
|
|
287
|
+
const current =
|
|
288
|
+
existingByIdentity.get(artifactIdentity(artifact)) ||
|
|
289
|
+
existing.find((candidate) => artifactMatchesRequirement(candidate, artifact));
|
|
290
|
+
if (!current) {
|
|
291
|
+
publish.push(artifact);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (current.digest !== artifact.digest) {
|
|
295
|
+
conflicts.push({ expected: artifact, actual: current });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
accepted.push(current);
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
publish,
|
|
302
|
+
accepted,
|
|
303
|
+
conflicts,
|
|
304
|
+
complete: publish.length === 0 && conflicts.length === 0,
|
|
305
|
+
repairRequired: conflicts.length > 0,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function validatePublishEvidence({
|
|
310
|
+
evidence,
|
|
311
|
+
version,
|
|
312
|
+
channel,
|
|
313
|
+
sourceSha,
|
|
314
|
+
releaseSha,
|
|
315
|
+
targetRef = "",
|
|
316
|
+
releaseMaterialSha = releaseSha,
|
|
317
|
+
publishToolingSha = "",
|
|
318
|
+
requiredArtifacts = [],
|
|
319
|
+
} = {}) {
|
|
320
|
+
const normalized = normalizePublishEvidence(evidence);
|
|
321
|
+
const errors = [];
|
|
322
|
+
const check = (actual, expected, label) => {
|
|
323
|
+
if (expected && actual !== expected) {
|
|
324
|
+
errors.push(`${label} mismatch: expected ${expected}, got ${actual || "<empty>"}`);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
check(normalized.version, version, "version");
|
|
328
|
+
check(normalized.channel, channel, "channel");
|
|
329
|
+
check(normalized.source_sha, sourceSha, "source_sha");
|
|
330
|
+
check(normalized.release_sha, releaseSha, "release_sha");
|
|
331
|
+
check(normalized.target_ref, targetRef, "target_ref");
|
|
332
|
+
check(normalized.release_material_sha, releaseMaterialSha, "release_material_sha");
|
|
333
|
+
if (publishToolingSha) {
|
|
334
|
+
check(normalized.publish_tooling_sha, publishToolingSha, "publish_tooling_sha");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const artifactPlan = planArtifactPublish({
|
|
338
|
+
requiredArtifacts,
|
|
339
|
+
existingArtifacts: normalized.artifacts,
|
|
340
|
+
});
|
|
341
|
+
for (const artifact of artifactPlan.publish) {
|
|
342
|
+
errors.push(
|
|
343
|
+
`required artifact missing: ${[artifact.kind, artifact.name, artifact.ref]
|
|
344
|
+
.filter(Boolean)
|
|
345
|
+
.join(":")}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
for (const conflict of artifactPlan.conflicts) {
|
|
349
|
+
errors.push(
|
|
350
|
+
`artifact digest mismatch: ${conflict.expected.kind}:${conflict.expected.name}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
valid: errors.length === 0,
|
|
356
|
+
errors,
|
|
357
|
+
evidence: normalized,
|
|
358
|
+
artifactPlan,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function assertTransactionIdentity(record, expected, { allowToolingDrift = true } = {}) {
|
|
363
|
+
const errors = [];
|
|
364
|
+
const check = (actual, wanted, label) => {
|
|
365
|
+
if (wanted && actual !== wanted) {
|
|
366
|
+
errors.push(`${label} mismatch: expected ${wanted}, got ${actual || "<empty>"}`);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
check(record.repository, expected.repository, "repository");
|
|
370
|
+
check(record.version, expected.version, "version");
|
|
371
|
+
check(record.source_sha, expected.sourceSha, "source_sha");
|
|
372
|
+
check(record.target_ref, expected.targetRef, "target_ref");
|
|
373
|
+
check(record.release_material_sha, expected.releaseMaterialSha, "release_material_sha");
|
|
374
|
+
if (!allowToolingDrift) {
|
|
375
|
+
check(record.publish_tooling_sha, expected.publishToolingSha, "publish_tooling_sha");
|
|
376
|
+
}
|
|
377
|
+
if (errors.length > 0) {
|
|
378
|
+
throw new Error(`release transaction identity mismatch: ${errors.join("; ")}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function planTransactionRecovery({
|
|
383
|
+
transaction,
|
|
384
|
+
evidence,
|
|
385
|
+
validation,
|
|
386
|
+
explicitOverride = false,
|
|
387
|
+
} = {}) {
|
|
388
|
+
if (!transaction) {
|
|
389
|
+
return { action: "prepare", blocked: false, reason: "no existing transaction" };
|
|
390
|
+
}
|
|
391
|
+
const state = transaction.state || "prepared";
|
|
392
|
+
assertKnownState(state);
|
|
393
|
+
if (BLOCKED_RECOVERY_STATES.has(state) && !explicitOverride) {
|
|
394
|
+
return {
|
|
395
|
+
action: "blocked",
|
|
396
|
+
blocked: true,
|
|
397
|
+
reason: `transaction is ${state}${transaction.superseded_by ? ` by ${transaction.superseded_by}` : ""}`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (state === "complete") {
|
|
401
|
+
return { action: "inspect", blocked: false, reason: "transaction is complete" };
|
|
402
|
+
}
|
|
403
|
+
if (state === "repair_required" && !explicitOverride) {
|
|
404
|
+
return { action: "blocked", blocked: true, reason: "transaction requires explicit repair" };
|
|
405
|
+
}
|
|
406
|
+
const result = validation || (evidence ? { valid: true } : undefined);
|
|
407
|
+
if (result?.valid) {
|
|
408
|
+
return { action: "finalize", blocked: false, reason: "publish evidence is valid" };
|
|
409
|
+
}
|
|
410
|
+
if (state === "published" || state === "finalizing") {
|
|
411
|
+
return { action: "repair", blocked: true, reason: "published transaction has invalid or missing evidence" };
|
|
412
|
+
}
|
|
413
|
+
return { action: "publish", blocked: false, reason: "publish evidence is missing or incomplete" };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function isReleaseTransactionTerminal(record) {
|
|
417
|
+
return TERMINAL_STATES.has(record?.state);
|
|
418
|
+
}
|