@kungfu-tech/buildchain 4.1.3-alpha.1 → 4.1.3-alpha.3
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/architecture/action-taxonomy.json +24 -0
- package/architecture/agent-change-map.md +256 -2
- package/architecture/ci-lane-change-budget.json +374 -0
- package/architecture/decisions/0005-minimal-consumer-contract.md +122 -0
- package/architecture/decisions/0006-business-attempt-journal.md +89 -0
- package/architecture/decisions/0007-hosted-pipeline-controller.md +106 -0
- package/architecture/decisions/0008-pipeline-product-publication.md +99 -0
- package/architecture/decisions/0009-pipeline-version-preparation.md +186 -0
- package/architecture/internal-capabilities.json +361 -2
- package/architecture/maintainability-debt.json +6 -4
- package/architecture/maintainability-policy.json +8 -8
- package/architecture/minimal-consumer-migration.json +7567 -0
- package/architecture/product-upstreams.json +20 -0
- package/architecture/release-topology.json +282 -11
- package/architecture/universal-workflow-bootstrap.json +12 -0
- package/architecture/universal-workflow-capability-policy.json +1 -1
- package/architecture/workflow-taxonomy.json +66 -0
- package/dist/readers/business-attempt.cjs +809 -0
- package/dist/site/buildchain-contract.json +7 -5
- package/dist/site/buildchain-site.json +18 -13
- package/dist/site/capability-registry.json +3 -3
- package/dist/site/kfd-claims.json +385 -10
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/node-api-registry.json +21 -21
- package/dist/site/page-registry.json +13 -8
- package/dist/site/public-surface-audit.json +258 -7
- package/dist/site/publication-authority-registry.json +141 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +522 -7
- package/docs/node-api-reference.md +39 -39
- package/docs/runtime-entry.md +45 -5
- package/docs/workflow-catalog.md +6 -0
- package/package.json +4 -3
- package/packages/core/build/standalone/build.js +7 -1
- package/packages/core/consumer/buildchain-config.js +2 -21
- package/packages/core/consumer/contract/entries.js +85 -0
- package/packages/core/consumer/contract/examples.js +96 -0
- package/packages/core/consumer/contract/identity.js +65 -0
- package/packages/core/consumer/contract/inspection.js +45 -0
- package/packages/core/consumer/contract/plan.js +146 -0
- package/packages/core/consumer/contract/products.js +119 -0
- package/packages/core/consumer/contract/reader.js +47 -0
- package/packages/core/consumer/contract/shape.js +46 -0
- package/packages/core/dev-delivery/candidate/admission.js +9 -5
- package/packages/core/dev-delivery/candidate/reservation.js +11 -0
- package/packages/core/dev-delivery/candidate/source-paths.js +12 -4
- package/packages/core/dev-delivery/commands/project-cut-merge-queue-admission.mjs +47 -20
- package/packages/core/dev-delivery/native/actions.js +7 -1
- package/packages/core/dev-delivery/native/heartbeat-action.js +16 -0
- package/packages/core/dev-delivery/native/heartbeat.js +6 -4
- package/packages/core/dev-delivery/queue/landing-action.js +5 -0
- package/packages/core/governance/buildchain-publication-authority.js +6 -0
- package/packages/core/paper/operations/bootstrap.js +2 -0
- package/packages/core/paper/paper-npm-bootstrap.js +2 -2
- package/packages/core/providers/github/attempt-index.js +60 -0
- package/packages/core/providers/github/attempt-journal.js +154 -0
- package/packages/core/providers/github/discussions/materials.js +7 -2
- package/packages/core/providers/github/pipeline-checkout.js +49 -0
- package/packages/core/providers/github/pipeline-entry-runs.js +203 -0
- package/packages/core/providers/github/pipeline-events.js +52 -0
- package/packages/core/providers/github/pipeline-integration.js +95 -0
- package/packages/core/providers/github/pipeline-policy.js +213 -0
- package/packages/core/providers/github/pipeline-product-payload.js +50 -0
- package/packages/core/providers/github/pipeline-product-release.js +191 -0
- package/packages/core/providers/github/pipeline-publication-artifacts.js +153 -0
- package/packages/core/providers/github/pipeline-release-evidence.js +111 -0
- package/packages/core/providers/github/pipeline-run-entry.js +86 -0
- package/packages/core/providers/github/pipeline-runs.js +132 -0
- package/packages/core/providers/github/pipeline-source.js +178 -0
- package/packages/core/providers/github/pipeline-stable-source.js +354 -0
- package/packages/core/providers/github/pipeline-version-artifacts.js +91 -0
- package/packages/core/providers/github/pipeline-version-readback.js +117 -0
- package/packages/core/providers/github/pipeline-version.js +199 -0
- package/packages/core/providers/github/pipeline-worker.js +108 -0
- package/packages/core/publication/candidate/registry-hydration.js +2 -2
- package/packages/core/publication/npm/pack-preview.js +2 -2
- package/packages/core/publication/npm/pack-result.js +31 -0
- package/packages/core/publication/npm/package.js +3 -6
- package/packages/core/publication/npm/pipeline-channel.js +87 -0
- package/packages/core/publication/npm/pipeline-provider.js +109 -0
- package/packages/core/publication/npm/registry.js +12 -9
- package/packages/core/publication/pipeline/actions.js +116 -0
- package/packages/core/publication/pipeline/apply.js +188 -0
- package/packages/core/publication/pipeline/build-segments.js +107 -0
- package/packages/core/publication/pipeline/capsules.js +89 -0
- package/packages/core/publication/pipeline/context.js +39 -0
- package/packages/core/publication/pipeline/development-anchor.js +91 -0
- package/packages/core/publication/pipeline/development-current.js +63 -0
- package/packages/core/publication/pipeline/development-pr.js +112 -0
- package/packages/core/publication/pipeline/development-proof.js +76 -0
- package/packages/core/publication/pipeline/development-transition.js +112 -0
- package/packages/core/publication/pipeline/distribution.js +124 -0
- package/packages/core/publication/pipeline/documents.js +192 -0
- package/packages/core/publication/pipeline/effects.js +162 -0
- package/packages/core/publication/pipeline/files.js +66 -0
- package/packages/core/publication/pipeline/imported-materials.js +32 -0
- package/packages/core/publication/pipeline/journal.js +110 -0
- package/packages/core/publication/pipeline/next-development.js +117 -0
- package/packages/core/publication/pipeline/pack.js +157 -0
- package/packages/core/publication/pipeline/package-policy.js +22 -0
- package/packages/core/publication/pipeline/plan.js +136 -0
- package/packages/core/publication/pipeline/prepare.js +139 -0
- package/packages/core/publication/pipeline/qualification.js +175 -0
- package/packages/core/publication/pipeline/qualify.js +104 -0
- package/packages/core/publication/pipeline/recovery-admission.js +122 -0
- package/packages/core/publication/pipeline/recovery-build-download.js +80 -0
- package/packages/core/publication/pipeline/recovery-build-plan.js +122 -0
- package/packages/core/publication/pipeline/recovery-build-readback.js +68 -0
- package/packages/core/publication/pipeline/recovery-capsules.js +54 -0
- package/packages/core/publication/pipeline/recovery-import.js +80 -0
- package/packages/core/publication/pipeline/recovery-inspection.js +218 -0
- package/packages/core/publication/pipeline/recovery-materials.js +104 -0
- package/packages/core/publication/pipeline/recovery-plan.js +46 -0
- package/packages/core/publication/pipeline/recovery-prepare.js +73 -0
- package/packages/core/publication/pipeline/recovery-qualification.js +128 -0
- package/packages/core/publication/pipeline/recovery-readback.js +56 -0
- package/packages/core/publication/pipeline/recovery-signing.js +58 -0
- package/packages/core/publication/pipeline/sealed-products.js +79 -0
- package/packages/core/publication/pipeline/settle.js +43 -0
- package/packages/core/publication/pipeline/signing.js +140 -0
- package/packages/core/publication/pipeline/source-plan.js +83 -0
- package/packages/core/publication/pipeline/stable-entry.js +215 -0
- package/packages/core/publication/pipeline/stable-products.js +317 -0
- package/packages/core/publication/pipeline/stable-wait.js +170 -0
- package/packages/core/publication/pipeline/stable.js +82 -0
- package/packages/core/publication/pipeline/version-actions.js +93 -0
- package/packages/core/publication/pipeline/version-build.js +130 -0
- package/packages/core/publication/pipeline/version-context.js +143 -0
- package/packages/core/publication/pipeline/version-material.js +36 -0
- package/packages/core/publication/pipeline/version-preparation.js +69 -0
- package/packages/core/publication/pipeline/version-regeneration.js +162 -0
- package/packages/core/publication/pipeline/version-source-guard.js +69 -0
- package/packages/core/publication/pipeline/version.js +81 -0
- package/packages/core/publication/pipeline/worker.js +72 -0
- package/packages/core/publication/publication-reproducibility.js +2 -2
- package/packages/core/release/promote-candidate/product-provider-adapters.js +4 -3
- package/packages/core/release/promote-ref/internal/npm-existing-evidence.js +2 -1
- package/packages/core/release/promote-ref/internal/publish-command.js +16 -5
- package/packages/core/release/stable-release-gate.js +11 -0
- package/packages/core/runtime/buildchain-domain.wasm +0 -0
- package/packages/core/runtime/domain-wasm-artifact.js +2 -2
- package/packages/core/runtime/entry/actions.js +27 -25
- package/packages/core/runtime/entry/attempt.js +25 -0
- package/packages/core/runtime/entry/selection.js +14 -0
- package/packages/core/runtime/entry/source.js +62 -0
- package/packages/core/workflow/attempt/identity.js +170 -0
- package/packages/core/workflow/attempt/journal.js +87 -0
- package/packages/core/workflow/attempt/materials.js +72 -0
- package/packages/core/workflow/attempt/reader-entry.js +12 -0
- package/packages/core/workflow/attempt/reader.js +149 -0
- package/packages/core/workflow/attempt/records.js +153 -0
- package/packages/core/workflow/attempt/store.js +75 -0
- package/packages/core/workflow/commands/pipeline-build.mjs +23 -0
- package/packages/core/workflow/pipeline/action-output.js +11 -0
- package/packages/core/workflow/pipeline/actions.js +75 -0
- package/packages/core/workflow/pipeline/build-control.js +140 -0
- package/packages/core/workflow/pipeline/build-evidence.js +92 -0
- package/packages/core/workflow/pipeline/build-qualification.js +145 -0
- package/packages/core/workflow/pipeline/build-result.js +69 -0
- package/packages/core/workflow/pipeline/build.js +142 -0
- package/packages/core/workflow/pipeline/cancellation.js +169 -0
- package/packages/core/workflow/pipeline/channel-control.js +127 -0
- package/packages/core/workflow/pipeline/controller.js +133 -0
- package/packages/core/workflow/pipeline/delivery-control.js +204 -0
- package/packages/core/workflow/pipeline/delivery-observation.js +118 -0
- package/packages/core/workflow/pipeline/delivery-request.js +198 -0
- package/packages/core/workflow/pipeline/events.js +84 -0
- package/packages/core/workflow/pipeline/fence.js +42 -0
- package/packages/core/workflow/pipeline/group-control.js +56 -0
- package/packages/core/workflow/pipeline/guard-build.js +37 -0
- package/packages/core/workflow/pipeline/guard.js +156 -0
- package/packages/core/workflow/pipeline/host.js +114 -0
- package/packages/core/workflow/pipeline/materials.js +58 -0
- package/packages/core/workflow/pipeline/notifications.js +36 -0
- package/packages/core/workflow/pipeline/parent-notification.js +27 -0
- package/packages/core/workflow/pipeline/platforms.js +21 -0
- package/packages/core/workflow/pipeline/progress.js +108 -0
- package/packages/core/workflow/pipeline/projection.js +20 -0
- package/packages/core/workflow/pipeline/reconcile.js +194 -0
- package/packages/core/workflow/pipeline/recovery-action.js +30 -0
- package/packages/core/workflow/pipeline/recovery-admission.js +122 -0
- package/packages/core/workflow/pipeline/recovery-build-control.js +95 -0
- package/packages/core/workflow/pipeline/recovery-build-evidence.js +71 -0
- package/packages/core/workflow/pipeline/recovery-build.js +121 -0
- package/packages/core/workflow/pipeline/recovery-controller.js +91 -0
- package/packages/core/workflow/pipeline/recovery-integration.js +66 -0
- package/packages/core/workflow/pipeline/recovery-merge-proof.js +90 -0
- package/packages/core/workflow/pipeline/recovery-ownership-settlement.js +150 -0
- package/packages/core/workflow/pipeline/recovery-ownership.js +91 -0
- package/packages/core/workflow/pipeline/recovery-plan.js +126 -0
- package/packages/core/workflow/pipeline/recovery-runtime.js +68 -0
- package/packages/core/workflow/pipeline/recovery-session.js +129 -0
- package/packages/core/workflow/pipeline/recovery-transition.js +135 -0
- package/packages/core/workflow/pipeline/recovery-unrecorded-build.js +74 -0
- package/packages/core/workflow/pipeline/runtime-source.js +111 -0
- package/packages/core/workflow/pipeline/selection.js +106 -0
- package/packages/core/workflow/pipeline/session.js +72 -0
- package/packages/core/workflow/pipeline/settlement.js +137 -0
- package/packages/core/workflow/pipeline/wake.js +38 -0
- package/packages/core/workflow/pipeline/web-status.js +108 -0
- package/scripts/build-release-discussion-reader.mjs +11 -7
- package/scripts/check-pipeline-publication-topology.mjs +48 -0
- package/scripts/check-release-topology.mjs +7 -1
- package/scripts/generate-minimal-consumer-contract.mjs +155 -0
- package/scripts/generate-site-bundle.mjs +1 -1
- package/scripts/verify-golden-path.mjs +5 -1
- package/scripts/verify-standalone-product.mjs +42 -0
- package/templates/minimal-consumer/binary/.buildchain/buildchain.toml +45 -0
- package/templates/minimal-consumer/binary/.github/workflows/buildchain-recover.yml +30 -0
- package/templates/minimal-consumer/binary/.github/workflows/buildchain.yml +28 -0
- package/templates/minimal-consumer/binary/package.json +6 -0
- package/templates/minimal-consumer/binary/src/build.mjs +5 -0
- package/templates/minimal-consumer/binary/src/hello.c +2 -0
- package/templates/minimal-consumer/binary/src/verify.mjs +3 -0
- package/templates/minimal-consumer/npm/.buildchain/buildchain.toml +46 -0
- package/templates/minimal-consumer/npm/.github/workflows/buildchain-recover.yml +30 -0
- package/templates/minimal-consumer/npm/.github/workflows/buildchain.yml +28 -0
- package/templates/minimal-consumer/npm/package.json +6 -0
- package/templates/minimal-consumer/npm/src/build.mjs +5 -0
- package/templates/minimal-consumer/npm/src/verify.mjs +3 -0
- package/templates/minimal-consumer/paper/.buildchain/buildchain.toml +45 -0
- package/templates/minimal-consumer/paper/.github/workflows/buildchain-recover.yml +30 -0
- package/templates/minimal-consumer/paper/.github/workflows/buildchain.yml +28 -0
- package/templates/minimal-consumer/paper/package.json +6 -0
- package/templates/minimal-consumer/paper/src/build.mjs +12 -0
- package/templates/minimal-consumer/paper/src/verify.mjs +3 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { object, text, choice } from "../../consumer/contract/shape.js";
|
|
3
|
+
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
4
|
+
|
|
5
|
+
export const MATERIAL_KINDS = [
|
|
6
|
+
"artifact",
|
|
7
|
+
"checkpoint",
|
|
8
|
+
"receipt",
|
|
9
|
+
"passport",
|
|
10
|
+
"provider-readback",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function materialReference(value, attempt, repository = "") {
|
|
14
|
+
object(
|
|
15
|
+
value,
|
|
16
|
+
["id", "kind", "digest", "bytes", "url", "generation", "producerAttempt"],
|
|
17
|
+
[],
|
|
18
|
+
"material",
|
|
19
|
+
);
|
|
20
|
+
text(value.id, "material.id", /^[a-z][a-z0-9._/-]*$/u);
|
|
21
|
+
choice(value.kind, MATERIAL_KINDS, "material.kind");
|
|
22
|
+
text(value.digest, "material.digest", /^sha256:[0-9a-f]{64}$/u);
|
|
23
|
+
if (!Number.isSafeInteger(value.bytes) || value.bytes < 1)
|
|
24
|
+
throw new Error("Invalid material byte size");
|
|
25
|
+
const url = new URL(value.url);
|
|
26
|
+
if (
|
|
27
|
+
url.protocol !== "https:" ||
|
|
28
|
+
url.username ||
|
|
29
|
+
url.password ||
|
|
30
|
+
url.search ||
|
|
31
|
+
url.hash ||
|
|
32
|
+
!["github.com", "api.github.com"].includes(url.hostname)
|
|
33
|
+
)
|
|
34
|
+
throw new Error(
|
|
35
|
+
"Material must use a permanent GitHub URL without credentials or signed queries",
|
|
36
|
+
);
|
|
37
|
+
if (
|
|
38
|
+
repository &&
|
|
39
|
+
!(url.hostname === "github.com"
|
|
40
|
+
? url.pathname.startsWith(`/${repository}/releases/download/`) ||
|
|
41
|
+
url.pathname.startsWith(`/${repository}/actions/runs/`)
|
|
42
|
+
: url.pathname.startsWith(`/repos/${repository}/actions/artifacts/`) ||
|
|
43
|
+
url.pathname.startsWith(`/repos/${repository}/releases/assets/`))
|
|
44
|
+
)
|
|
45
|
+
throw new Error("Material belongs to another consumer repository");
|
|
46
|
+
if (
|
|
47
|
+
value.generation !== attempt.generation ||
|
|
48
|
+
value.producerAttempt !== attempt.id
|
|
49
|
+
)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Material is not qualified for this exact attempt and generation",
|
|
52
|
+
);
|
|
53
|
+
return { ...value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function verifyMaterialBytes(reference, bytes) {
|
|
57
|
+
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
58
|
+
if (bytes.length !== reference.bytes || digest !== reference.digest)
|
|
59
|
+
throw new Error(`Material integrity mismatch: ${reference.id}`);
|
|
60
|
+
return { id: reference.id, digest, bytes: bytes.length };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function indexMaterials(index, references, attempt) {
|
|
64
|
+
for (const reference of references) {
|
|
65
|
+
materialReference(reference, attempt);
|
|
66
|
+
const old = index[reference.id];
|
|
67
|
+
if (old && recordDigest(old) !== recordDigest(reference))
|
|
68
|
+
throw new Error(`Immutable material identity conflict: ${reference.id}`);
|
|
69
|
+
index[reference.id] = { ...reference };
|
|
70
|
+
}
|
|
71
|
+
return index;
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { readBusinessAttempt } from "./reader.js";
|
|
3
|
+
export { readBusinessAttempt } from "./reader.js";
|
|
4
|
+
|
|
5
|
+
// Captured, provider-authenticated JSON only. No live ref lookup or effects.
|
|
6
|
+
if (typeof require !== "undefined" && require.main === module) {
|
|
7
|
+
const state = readBusinessAttempt(JSON.parse(fs.readFileSync(0, "utf8")));
|
|
8
|
+
const { status, reason, attempt, generation, missing, recovery } = state;
|
|
9
|
+
process.stdout.write(
|
|
10
|
+
`${JSON.stringify({ status, reason, attempt, generation, missing, recovery })}\n`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalJson,
|
|
3
|
+
encodeRecord,
|
|
4
|
+
recordDigest,
|
|
5
|
+
} from "../../release/discussion/envelope.js";
|
|
6
|
+
import { readReleaseDiscussion } from "../../release/discussion/reader.js";
|
|
7
|
+
import { pipelineIntent } from "./identity.js";
|
|
8
|
+
import { validateAttemptRecord } from "./records.js";
|
|
9
|
+
import { indexMaterials } from "./materials.js";
|
|
10
|
+
|
|
11
|
+
export const MAX_ATTEMPT_RECORDS = 10_000;
|
|
12
|
+
const MAX_HISTORY_BYTES = 32 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
function validateIntent(intent) {
|
|
15
|
+
const source = intent.source;
|
|
16
|
+
if (source?.kind !== "pipeline") throw new Error("Not a pipeline intent");
|
|
17
|
+
const expected = pipelineIntent({
|
|
18
|
+
...source,
|
|
19
|
+
repository: intent.repository,
|
|
20
|
+
phases: intent.expectedNodes,
|
|
21
|
+
runtime: intent.runtime,
|
|
22
|
+
});
|
|
23
|
+
if (recordDigest(expected) !== recordDigest(intent))
|
|
24
|
+
throw new Error("Pipeline intent identity mismatch");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function foldAttempt(events, intent) {
|
|
28
|
+
events.sort((a, b) => a.sequence - b.sequence);
|
|
29
|
+
const roots = events.filter((record) => record.node === "attempt");
|
|
30
|
+
if (roots.length !== 1 || roots[0] !== events[0])
|
|
31
|
+
throw new Error("Attempt requires exactly one initial root");
|
|
32
|
+
const phases = Object.create(null),
|
|
33
|
+
materials = Object.create(null),
|
|
34
|
+
keys = new Set(),
|
|
35
|
+
runs = new Map();
|
|
36
|
+
let previous = null;
|
|
37
|
+
for (const record of events) {
|
|
38
|
+
const value = record.payload;
|
|
39
|
+
if (
|
|
40
|
+
value.identity.id !== roots[0].payload.identity.id ||
|
|
41
|
+
value.generation.id !== roots[0].payload.generation.id
|
|
42
|
+
)
|
|
43
|
+
throw new Error("Attempt changed its immutable generation");
|
|
44
|
+
if (
|
|
45
|
+
record.sequence !== (previous ? previous.sequence + 1 : 0) ||
|
|
46
|
+
value.previous !== (previous?.id || "")
|
|
47
|
+
)
|
|
48
|
+
throw new Error(
|
|
49
|
+
"Attempt record chain has a gap, fork or missing predecessor",
|
|
50
|
+
);
|
|
51
|
+
if (keys.has(value.eventKey))
|
|
52
|
+
throw new Error("Conflicting duplicate event key");
|
|
53
|
+
keys.add(value.eventKey);
|
|
54
|
+
if (record.node !== "attempt") {
|
|
55
|
+
const before = intent.expectedNodes.slice(
|
|
56
|
+
0,
|
|
57
|
+
intent.expectedNodes.indexOf(record.node),
|
|
58
|
+
);
|
|
59
|
+
if (before.some((phase) => phases[phase]?.payload.state !== "success"))
|
|
60
|
+
throw new Error("Phase predecessor has not succeeded");
|
|
61
|
+
const old = phases[record.node];
|
|
62
|
+
if (
|
|
63
|
+
old &&
|
|
64
|
+
["success", "failure", "cancelled", "superseded"].includes(
|
|
65
|
+
old.payload.state,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
throw new Error(
|
|
69
|
+
"Terminal phase history is immutable; recover in a successor attempt",
|
|
70
|
+
);
|
|
71
|
+
phases[record.node] = record;
|
|
72
|
+
}
|
|
73
|
+
indexMaterials(materials, value.materials, value.identity);
|
|
74
|
+
runs.set(record.writer, value.writer);
|
|
75
|
+
previous = record;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
identity: roots[0].payload.identity,
|
|
79
|
+
generation: roots[0].payload.generation,
|
|
80
|
+
head: previous.id,
|
|
81
|
+
events,
|
|
82
|
+
phases,
|
|
83
|
+
materials,
|
|
84
|
+
runs: [...runs.values()],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// This projection is evidence navigation, never a Warrant or publication authorization.
|
|
89
|
+
export function readBusinessAttempt({ intent, records }) {
|
|
90
|
+
validateIntent(intent);
|
|
91
|
+
if (!Array.isArray(records) || records.length > MAX_ATTEMPT_RECORDS)
|
|
92
|
+
throw new Error("Attempt record count exceeds its bound");
|
|
93
|
+
if (Buffer.byteLength(canonicalJson(records)) > MAX_HISTORY_BYTES)
|
|
94
|
+
throw new Error("Attempt history byte bound exceeded");
|
|
95
|
+
const unique = new Map(),
|
|
96
|
+
grouped = new Map();
|
|
97
|
+
for (const record of records) {
|
|
98
|
+
validateAttemptRecord(record, intent);
|
|
99
|
+
unique.set(record.id, record);
|
|
100
|
+
}
|
|
101
|
+
for (const record of unique.values()) {
|
|
102
|
+
const events = grouped.get(record.attempt) || [];
|
|
103
|
+
events.push(record);
|
|
104
|
+
grouped.set(record.attempt, events);
|
|
105
|
+
}
|
|
106
|
+
const lineage = readReleaseDiscussion({
|
|
107
|
+
body: encodeRecord(intent),
|
|
108
|
+
records: [...unique.values()],
|
|
109
|
+
});
|
|
110
|
+
const history = lineage.attempts.map((id) =>
|
|
111
|
+
foldAttempt(grouped.get(id), intent),
|
|
112
|
+
);
|
|
113
|
+
const current = history.at(-1);
|
|
114
|
+
if (!current)
|
|
115
|
+
return {
|
|
116
|
+
intent,
|
|
117
|
+
status: "pending",
|
|
118
|
+
reason: "not-admitted",
|
|
119
|
+
attempt: "",
|
|
120
|
+
missing: intent.expectedNodes,
|
|
121
|
+
history,
|
|
122
|
+
recovery: null,
|
|
123
|
+
};
|
|
124
|
+
const missing = intent.expectedNodes.filter(
|
|
125
|
+
(phase) => current.phases[phase]?.payload.state !== "success",
|
|
126
|
+
);
|
|
127
|
+
const states = Object.values(current.phases).map((record) => record.payload);
|
|
128
|
+
const stopped = states.find((value) =>
|
|
129
|
+
["failure", "cancelled", "superseded"].includes(value.state),
|
|
130
|
+
);
|
|
131
|
+
const waiting = states.find((value) => value.state === "waiting");
|
|
132
|
+
const status =
|
|
133
|
+
stopped?.state ||
|
|
134
|
+
(missing.length ? (waiting ? "waiting" : "running") : "complete");
|
|
135
|
+
return {
|
|
136
|
+
intent,
|
|
137
|
+
attempt: current.identity.id,
|
|
138
|
+
generation: current.generation.id,
|
|
139
|
+
status,
|
|
140
|
+
reason: stopped?.reason || waiting?.reason || status,
|
|
141
|
+
head: current.head,
|
|
142
|
+
missing,
|
|
143
|
+
phases: current.phases,
|
|
144
|
+
materials: current.materials,
|
|
145
|
+
runs: current.runs,
|
|
146
|
+
history,
|
|
147
|
+
recovery: status === "complete" ? null : { attempt: current.identity.id },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createProgress,
|
|
3
|
+
recordDigest,
|
|
4
|
+
encodeRecord,
|
|
5
|
+
} from "../../release/discussion/envelope.js";
|
|
6
|
+
import { object, text, choice, unique } from "../../consumer/contract/shape.js";
|
|
7
|
+
import { providerWriter, validateAttempt } from "./identity.js";
|
|
8
|
+
import { materialReference } from "./materials.js";
|
|
9
|
+
|
|
10
|
+
export const ATTEMPT_EVENT = "buildchain.business-attempt-event/v1";
|
|
11
|
+
export const STATES = [
|
|
12
|
+
"running",
|
|
13
|
+
"waiting",
|
|
14
|
+
"success",
|
|
15
|
+
"failure",
|
|
16
|
+
"cancelled",
|
|
17
|
+
"superseded",
|
|
18
|
+
];
|
|
19
|
+
const ENVELOPE_STATUS = {
|
|
20
|
+
running: "running",
|
|
21
|
+
waiting: "running",
|
|
22
|
+
success: "success",
|
|
23
|
+
failure: "failure",
|
|
24
|
+
cancelled: "cancelled",
|
|
25
|
+
superseded: "cancelled",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function attemptRecord({
|
|
29
|
+
intent,
|
|
30
|
+
attempt,
|
|
31
|
+
generation,
|
|
32
|
+
writer,
|
|
33
|
+
runtime,
|
|
34
|
+
previous = null,
|
|
35
|
+
eventKey,
|
|
36
|
+
phase,
|
|
37
|
+
state,
|
|
38
|
+
reason = "",
|
|
39
|
+
materials = [],
|
|
40
|
+
}) {
|
|
41
|
+
validateAttempt(attempt, intent, generation);
|
|
42
|
+
const writerId = providerWriter(writer, intent.repository);
|
|
43
|
+
choice(state, STATES, "state");
|
|
44
|
+
text(eventKey, "eventKey");
|
|
45
|
+
if (
|
|
46
|
+
eventKey.length > 240 ||
|
|
47
|
+
typeof reason !== "string" ||
|
|
48
|
+
reason.length > 2000
|
|
49
|
+
)
|
|
50
|
+
throw new Error("Attempt event text exceeds its bound");
|
|
51
|
+
if (!previous && (phase !== "attempt" || state !== "running"))
|
|
52
|
+
throw new Error("Open an attempt root before phase progress");
|
|
53
|
+
if (
|
|
54
|
+
previous &&
|
|
55
|
+
(previous.attempt !== attempt.id ||
|
|
56
|
+
previous.payload.generation.id !== generation.id)
|
|
57
|
+
)
|
|
58
|
+
throw new Error(
|
|
59
|
+
"Record predecessor belongs to another attempt or generation",
|
|
60
|
+
);
|
|
61
|
+
if (!Array.isArray(materials) || materials.length > 100)
|
|
62
|
+
throw new Error("Attempt event material bound exceeded");
|
|
63
|
+
unique(
|
|
64
|
+
materials.map((item) => item.id),
|
|
65
|
+
"materials",
|
|
66
|
+
);
|
|
67
|
+
materials.forEach((item) =>
|
|
68
|
+
materialReference(item, attempt, intent.repository),
|
|
69
|
+
);
|
|
70
|
+
return createProgress({
|
|
71
|
+
intent,
|
|
72
|
+
attempt: attempt.id,
|
|
73
|
+
predecessor: attempt.predecessor,
|
|
74
|
+
writer: writerId,
|
|
75
|
+
runtime,
|
|
76
|
+
node: phase,
|
|
77
|
+
status: ENVELOPE_STATUS[state],
|
|
78
|
+
sequence: previous ? previous.sequence + 1 : 0,
|
|
79
|
+
payload: {
|
|
80
|
+
schema: ATTEMPT_EVENT,
|
|
81
|
+
identity: attempt,
|
|
82
|
+
generation,
|
|
83
|
+
writer: { ...writer },
|
|
84
|
+
previous: previous?.id || "",
|
|
85
|
+
eventKey,
|
|
86
|
+
state,
|
|
87
|
+
reason,
|
|
88
|
+
materials: structuredClone(materials),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validateAttemptRecord(record, intent) {
|
|
94
|
+
const value = record.payload;
|
|
95
|
+
object(
|
|
96
|
+
value,
|
|
97
|
+
[
|
|
98
|
+
"schema",
|
|
99
|
+
"identity",
|
|
100
|
+
"generation",
|
|
101
|
+
"writer",
|
|
102
|
+
"previous",
|
|
103
|
+
"eventKey",
|
|
104
|
+
"state",
|
|
105
|
+
"reason",
|
|
106
|
+
"materials",
|
|
107
|
+
],
|
|
108
|
+
[],
|
|
109
|
+
"attemptEvent",
|
|
110
|
+
);
|
|
111
|
+
if (value.schema !== ATTEMPT_EVENT)
|
|
112
|
+
throw new Error("Attempt event requires its historical runtime reader");
|
|
113
|
+
validateAttempt(value.identity, intent, value.generation);
|
|
114
|
+
const { id, ...content } = record;
|
|
115
|
+
if (
|
|
116
|
+
id !== recordDigest(content) ||
|
|
117
|
+
record.attempt !== value.identity.id ||
|
|
118
|
+
record.intent !== intent.id ||
|
|
119
|
+
record.predecessor !== value.identity.predecessor
|
|
120
|
+
)
|
|
121
|
+
throw new Error("Attempt record identity mismatch");
|
|
122
|
+
if (providerWriter(value.writer, intent.repository) !== record.writer)
|
|
123
|
+
throw new Error("Attempt provider writer mismatch");
|
|
124
|
+
choice(value.state, STATES, "attemptEvent.state");
|
|
125
|
+
if (record.status !== ENVELOPE_STATUS[value.state])
|
|
126
|
+
throw new Error("Attempt record status mismatch");
|
|
127
|
+
if (value.previous)
|
|
128
|
+
text(value.previous, "previous", /^sha256:[0-9a-f]{64}$/u);
|
|
129
|
+
const expected = attemptRecord({
|
|
130
|
+
intent,
|
|
131
|
+
attempt: value.identity,
|
|
132
|
+
generation: value.generation,
|
|
133
|
+
writer: value.writer,
|
|
134
|
+
runtime: record.runtime,
|
|
135
|
+
previous: value.previous
|
|
136
|
+
? {
|
|
137
|
+
id: value.previous,
|
|
138
|
+
attempt: record.attempt,
|
|
139
|
+
payload: value,
|
|
140
|
+
sequence: record.sequence - 1,
|
|
141
|
+
}
|
|
142
|
+
: null,
|
|
143
|
+
eventKey: value.eventKey,
|
|
144
|
+
phase: record.node,
|
|
145
|
+
state: value.state,
|
|
146
|
+
reason: value.reason,
|
|
147
|
+
materials: value.materials,
|
|
148
|
+
});
|
|
149
|
+
if (recordDigest(expected) !== recordDigest(record))
|
|
150
|
+
throw new Error("Invalid attempt record envelope");
|
|
151
|
+
encodeRecord(record);
|
|
152
|
+
return record;
|
|
153
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { releaseDiscussionStore } from "../../release/discussion/store.js";
|
|
2
|
+
import { validateAttemptRecord } from "./records.js";
|
|
3
|
+
import { readBusinessAttempt } from "./reader.js";
|
|
4
|
+
|
|
5
|
+
// exclusive.run owns the repository/intent-wide writer scope, not merely this
|
|
6
|
+
// process. assertOwner rechecks the live provider fence before every mutation.
|
|
7
|
+
// No default mutex: an in-memory lock cannot serialize independent workflows.
|
|
8
|
+
export function businessAttemptStore(transport, exclusive, options = {}) {
|
|
9
|
+
if (typeof exclusive?.run !== "function")
|
|
10
|
+
throw new Error("Attempt writes require an exclusive provider writer");
|
|
11
|
+
const journal = releaseDiscussionStore(transport, options);
|
|
12
|
+
async function read(session) {
|
|
13
|
+
const observed = await journal.read(session);
|
|
14
|
+
return { ...observed, ...readBusinessAttempt(observed) };
|
|
15
|
+
}
|
|
16
|
+
async function scoped(intent, operation) {
|
|
17
|
+
return exclusive.run(
|
|
18
|
+
{ repository: intent.repository, intent: intent.id },
|
|
19
|
+
async (owner) => {
|
|
20
|
+
if (typeof owner?.assertOwner !== "function")
|
|
21
|
+
throw new Error("Missing live writer fence");
|
|
22
|
+
await owner.assertOwner();
|
|
23
|
+
return operation(owner);
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
async function initialize(request) {
|
|
28
|
+
readBusinessAttempt({ intent: request.intent, records: [] });
|
|
29
|
+
return scoped(request.intent, async (owner) => {
|
|
30
|
+
const repository = await transport.repository(request.intent.repository);
|
|
31
|
+
if (repository.id !== request.intent.source.repositoryId)
|
|
32
|
+
throw new Error("Intent repository identity changed");
|
|
33
|
+
const fenced = { ...transport };
|
|
34
|
+
for (const name of ["create", "append"])
|
|
35
|
+
fenced[name] = async (...args) => {
|
|
36
|
+
await owner.assertOwner();
|
|
37
|
+
return transport[name](...args);
|
|
38
|
+
};
|
|
39
|
+
return releaseDiscussionStore(fenced, options).initialize(request);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function append(session, record, expectedHead) {
|
|
43
|
+
return scoped(session.intent, async (owner) => {
|
|
44
|
+
validateAttemptRecord(record, session.intent);
|
|
45
|
+
const observed = await read(session);
|
|
46
|
+
const existing = observed.records.find((event) => event.id === record.id);
|
|
47
|
+
if (existing) return observed;
|
|
48
|
+
if ((observed.head || "") !== expectedHead)
|
|
49
|
+
throw new Error("Stale attempt head; reobserve before writing");
|
|
50
|
+
if (record.node === "attempt") {
|
|
51
|
+
if (record.predecessor !== (observed.attempt || ""))
|
|
52
|
+
throw new Error("Recovery must succeed the current attempt");
|
|
53
|
+
} else if (record.attempt !== observed.attempt)
|
|
54
|
+
throw new Error("A superseded attempt cannot append new results");
|
|
55
|
+
readBusinessAttempt({
|
|
56
|
+
intent: observed.intent,
|
|
57
|
+
records: [...observed.records, record],
|
|
58
|
+
});
|
|
59
|
+
await owner.assertOwner();
|
|
60
|
+
const fenced = {
|
|
61
|
+
...transport,
|
|
62
|
+
append: async (...args) => {
|
|
63
|
+
await owner.assertOwner();
|
|
64
|
+
return transport.append(...args);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
await releaseDiscussionStore(fenced, options).append(session, record);
|
|
68
|
+
const result = await read(session);
|
|
69
|
+
if (result.head !== record.id)
|
|
70
|
+
throw new Error("Attempt append readback has a conflicting writer");
|
|
71
|
+
return result;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return { initialize, read, append };
|
|
75
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { normalInputs } from "../../consumer/contract/entries.js";
|
|
5
|
+
import { compileConsumerPlan } from "../../consumer/contract/plan.js";
|
|
6
|
+
import { buildPipelineProducts } from "../pipeline/build.js";
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
if (
|
|
10
|
+
args.length !== 4 ||
|
|
11
|
+
args[0] !== "--platform" ||
|
|
12
|
+
args[2] !== "--config-path"
|
|
13
|
+
)
|
|
14
|
+
throw new Error(
|
|
15
|
+
"usage: pipeline-build --platform <platform> --config-path <TOML>",
|
|
16
|
+
);
|
|
17
|
+
const { configPath } = normalInputs({ "config-path": args[3] });
|
|
18
|
+
const cwd = fs.realpathSync(process.cwd());
|
|
19
|
+
const config = fs.realpathSync(path.join(cwd, configPath));
|
|
20
|
+
if (!config.startsWith(`${cwd}${path.sep}`))
|
|
21
|
+
throw new Error("Candidate TOML escapes its checkout");
|
|
22
|
+
const plan = compileConsumerPlan(fs.readFileSync(config, "utf8"));
|
|
23
|
+
await buildPipelineProducts({ cwd, plan, platform: args[1] });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function pipelineActionOutputs(core, result) {
|
|
2
|
+
core.setOutput("operation", result.operation);
|
|
3
|
+
core.setOutput("context", JSON.stringify(result.context || {}));
|
|
4
|
+
core.setOutput(
|
|
5
|
+
"matrix",
|
|
6
|
+
JSON.stringify({ include: result.context?.platforms || [] }),
|
|
7
|
+
);
|
|
8
|
+
core.setOutput("request", JSON.stringify(result.request || {}));
|
|
9
|
+
core.setOutput("attempt", result.attempt || result.context?.attempt || "");
|
|
10
|
+
core.info(result.reason || result.operation);
|
|
11
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { pipelineActionOutputs } from "./action-output.js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { normalInputs } from "../../consumer/contract/entries.js";
|
|
5
|
+
import { pipelineHost } from "./host.js";
|
|
6
|
+
import { controlPipeline } from "./controller.js";
|
|
7
|
+
import { buildPipelineSource } from "./build.js";
|
|
8
|
+
import { recordPipelineBuild } from "./build-control.js";
|
|
9
|
+
import { wakePipelineStableWait } from "../../publication/pipeline/stable-wait.js";
|
|
10
|
+
import {
|
|
11
|
+
RECOVERY_BUILD_CONTEXT,
|
|
12
|
+
recordRecoveryBuild,
|
|
13
|
+
} from "./recovery-build-control.js";
|
|
14
|
+
|
|
15
|
+
export async function controlPipelineAction(core, env) {
|
|
16
|
+
const { configPath } = normalInputs({
|
|
17
|
+
"config-path": core.getInput("config-path") || undefined,
|
|
18
|
+
});
|
|
19
|
+
const host = await pipelineHost(core, env, "Buildchain pipeline controller");
|
|
20
|
+
const result = await controlPipeline(
|
|
21
|
+
env.GITHUB_EVENT_NAME,
|
|
22
|
+
JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, "utf8")),
|
|
23
|
+
{ "config-path": configPath },
|
|
24
|
+
host,
|
|
25
|
+
);
|
|
26
|
+
pipelineActionOutputs(core, result);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function buildPipelineAction(core, env) {
|
|
30
|
+
const context = JSON.parse(core.getInput("context", { required: true }));
|
|
31
|
+
const result = await buildPipelineSource({
|
|
32
|
+
cwd: path.join(env.GITHUB_WORKSPACE, ".buildchain/product"),
|
|
33
|
+
source: context.source,
|
|
34
|
+
platform: core.getInput("platform", { required: true }),
|
|
35
|
+
environment: env,
|
|
36
|
+
});
|
|
37
|
+
core.info(
|
|
38
|
+
`Verified ${result.products.length} products on ${result.platform}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function recordPipelineBuildAction(core, env) {
|
|
43
|
+
const host = await pipelineHost(core, env, "Record product build");
|
|
44
|
+
const context = JSON.parse(core.getInput("context", { required: true }));
|
|
45
|
+
if (context.schema === "buildchain.pipeline-group-build-context/v1")
|
|
46
|
+
return host.groupRecord(context);
|
|
47
|
+
const result =
|
|
48
|
+
context.schema === RECOVERY_BUILD_CONTEXT
|
|
49
|
+
? await recordRecoveryBuild(context, host)
|
|
50
|
+
: await recordPipelineBuild(context, host);
|
|
51
|
+
if (result.outcome !== "success")
|
|
52
|
+
throw new Error("Product build did not succeed");
|
|
53
|
+
core.info(
|
|
54
|
+
result.wakePending
|
|
55
|
+
? "Build retained; next event will retry delivery wake"
|
|
56
|
+
: "Build retained and attempt woken",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function wakePipelineAction(core, env) {
|
|
61
|
+
const waiting = core.getInput("stable-wait");
|
|
62
|
+
const host = await pipelineHost(
|
|
63
|
+
core,
|
|
64
|
+
env,
|
|
65
|
+
waiting ? "Recheck stable publication" : "Record delivery attempt",
|
|
66
|
+
);
|
|
67
|
+
if (waiting) {
|
|
68
|
+
const wait = JSON.parse(waiting);
|
|
69
|
+
if (wait.attempt !== core.getInput("attempt", { required: true }))
|
|
70
|
+
throw new Error("Stable wake changed its exact business attempt");
|
|
71
|
+
core.info(JSON.stringify(await wakePipelineStableWait(wait, host)));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await host.wake(core.getInput("attempt", { required: true }));
|
|
75
|
+
}
|