@kungfu-tech/buildchain 4.1.3-alpha.5 → 4.1.3-alpha.7
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/AGENTS.md +41 -75
- package/CONTRIBUTING.md +19 -26
- package/architecture/agent-change-map.md +7 -12
- package/architecture/capability-state-machine-manifest.json +1 -4
- package/architecture/ci-lane-change-budget.json +35 -1026
- package/architecture/core-mechanisms.json +3 -6
- package/architecture/decisions/0008-pipeline-product-publication.md +29 -8
- package/architecture/internal-capabilities.json +1 -5
- package/architecture/maintainability-debt.json +43 -36
- package/architecture/maintainability-policy.json +8 -8
- package/architecture/minimal-consumer-migration.json +80 -1977
- package/architecture/platform-stage-checkpoints.json +31 -7
- package/architecture/release-topology.json +64 -84
- package/architecture/stage-capsule-qualification.json +3 -1
- package/architecture/universal-workflow-bootstrap.json +3 -1
- package/architecture/universal-workflow-capability-policy.json +4 -4
- package/architecture/universal-workflow-fault-campaign.json +3 -1
- package/architecture/workflow-taxonomy.json +24 -341
- package/dist/site/buildchain-contract.json +13 -67
- package/dist/site/buildchain-site.json +28 -23
- package/dist/site/capability-registry.json +2 -2
- package/dist/site/controller-registry.json +1 -47
- package/dist/site/kfd-claims.json +78 -448
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +3 -3
- package/dist/site/node-api-registry.json +24 -24
- package/dist/site/page-registry.json +21 -16
- package/dist/site/public-surface-audit.json +31 -347
- package/dist/site/publication-authority-registry.json +51 -511
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +7 -7
- package/dist/site/workflow-registry.json +259 -1126
- package/docs/code-organization.md +9 -6
- package/docs/node-api-reference.md +57 -57
- package/docs/release-governance.md +10 -12
- package/docs/runtime-entry.md +10 -3
- package/docs/runtime-train-validation.md +39 -11
- package/docs/stable-candidate-patrol.md +6 -5
- package/docs/workflow-catalog.md +3 -32
- package/package.json +1 -1
- package/packages/core/adoption/buildchain-kfd-claims.js +7 -7
- package/packages/core/dev-delivery/dev-delivery-provider-heartbeat.js +18 -15
- package/packages/core/dev-delivery/native/actions.js +2 -0
- package/packages/core/dev-delivery/native/process-entry.js +19 -0
- package/packages/core/dev-delivery/queue/landing-action.js +49 -1
- package/packages/core/dev-delivery/warrant/failure-settlement.js +8 -6
- package/packages/core/dev-delivery/warrant/service.js +7 -0
- package/packages/core/governance/buildchain-publication-authority.js +2 -23
- package/packages/core/governance/github-governance-authority.js +1 -0
- package/packages/core/observability/controller-evidence.js +0 -8
- package/packages/core/providers/github/pipeline-entry-runs.js +8 -1
- package/packages/core/providers/github/pipeline-events.js +79 -0
- package/packages/core/providers/github/pipeline-product-release.js +10 -3
- package/packages/core/publication/npm/pipeline-channel.js +0 -51
- package/packages/core/publication/npm/pipeline-provider.js +29 -1
- package/packages/core/publication/pipeline/apply.js +2 -1
- package/packages/core/publication/pipeline/capsules.js +4 -1
- package/packages/core/publication/pipeline/distribution.js +29 -23
- package/packages/core/publication/pipeline/effects.js +34 -4
- package/packages/core/publication/pipeline/pack.js +31 -8
- package/packages/core/publication/pipeline/settle.js +0 -1
- package/packages/core/workflow/attempt/journal.js +11 -9
- package/packages/core/workflow/pipeline/actions.js +0 -23
- package/packages/core/workflow/pipeline/build-evidence.js +123 -5
- package/packages/core/workflow/pipeline/build-result.js +1 -0
- package/packages/core/workflow/pipeline/channel-control.js +25 -4
- package/packages/core/workflow/pipeline/controller.js +9 -1
- package/packages/core/workflow/pipeline/delivery-control.js +54 -9
- package/packages/core/workflow/pipeline/delivery-observation.js +16 -10
- package/packages/core/workflow/pipeline/group-control.js +1 -0
- package/packages/core/workflow/pipeline/guard-build.js +37 -0
- package/packages/core/workflow/pipeline/guard.js +4 -2
- package/packages/core/workflow/pipeline/host.js +23 -1
- package/packages/core/workflow/pipeline/integration-status.js +113 -0
- package/packages/core/workflow/pipeline/record-action.js +27 -0
- package/packages/core/workflow/pipeline/recorder-status.js +69 -0
- package/packages/core/workflow/pipeline/recovery-integration.js +14 -1
- package/packages/core/workflow/workflow-taxonomy.mjs +67 -47
- package/scripts/check-inventory.mjs +7 -70
- package/scripts/check-pipeline-publication-topology.mjs +2 -0
- package/scripts/check-public-dogfood-contract.mjs +38 -51
- package/scripts/check-release-topology.mjs +8 -7
- package/scripts/check-self-consumer-contract.mjs +133 -0
- package/scripts/check-universal-workflow-bootstrap.mjs +4 -6
- package/scripts/generate-minimal-consumer-contract.mjs +1 -1
- package/scripts/inventory/binary.mjs +9 -19
- package/scripts/inventory/promotion.mjs +2 -13
- package/scripts/prepare-product-toolchain.mjs +10 -0
- package/scripts/verify-product-platform.mjs +65 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { setTimeout } from "node:timers/promises";
|
|
2
3
|
import { pipelineProductPayload } from "./pipeline-product-payload.js";
|
|
3
4
|
|
|
4
5
|
const sha256 = (bytes) =>
|
|
@@ -48,6 +49,7 @@ export function githubPipelineProductRelease({
|
|
|
48
49
|
throw new Error("Release requires the exact immutable publication tag");
|
|
49
50
|
let value = await findRelease();
|
|
50
51
|
if (!value && create) {
|
|
52
|
+
let failure;
|
|
51
53
|
try {
|
|
52
54
|
await github.rest.repos.createRelease({
|
|
53
55
|
owner,
|
|
@@ -61,12 +63,17 @@ export function githubPipelineProductRelease({
|
|
|
61
63
|
make_latest: "false",
|
|
62
64
|
});
|
|
63
65
|
} catch (error) {
|
|
66
|
+
failure = error;
|
|
67
|
+
}
|
|
68
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
69
|
+
if (attempt) await setTimeout(1000);
|
|
64
70
|
value = await findRelease();
|
|
65
|
-
if (
|
|
71
|
+
if (value) break;
|
|
66
72
|
}
|
|
67
|
-
value = await findRelease();
|
|
68
73
|
if (!value)
|
|
69
|
-
throw
|
|
74
|
+
throw (
|
|
75
|
+
failure || new Error("Release creation lacks exact provider readback")
|
|
76
|
+
);
|
|
70
77
|
}
|
|
71
78
|
if (
|
|
72
79
|
value &&
|
|
@@ -12,43 +12,6 @@ export function pipelineNpmChannel({
|
|
|
12
12
|
);
|
|
13
13
|
return response.json();
|
|
14
14
|
}
|
|
15
|
-
async function credential(name) {
|
|
16
|
-
if (environment.NODE_AUTH_TOKEN) return environment.NODE_AUTH_TOKEN;
|
|
17
|
-
if (
|
|
18
|
-
!environment.ACTIONS_ID_TOKEN_REQUEST_URL ||
|
|
19
|
-
!environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
20
|
-
)
|
|
21
|
-
throw new Error(
|
|
22
|
-
"npm channel publication requires its hosted package-scoped OIDC or configured publisher credential",
|
|
23
|
-
);
|
|
24
|
-
const url = new URL(environment.ACTIONS_ID_TOKEN_REQUEST_URL);
|
|
25
|
-
if (url.protocol !== "https:")
|
|
26
|
-
throw new Error("OIDC request requires the provider HTTPS endpoint");
|
|
27
|
-
url.searchParams.set("audience", "npm:registry.npmjs.org");
|
|
28
|
-
const identity = await json(url.href, {
|
|
29
|
-
headers: {
|
|
30
|
-
authorization: `Bearer ${environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
|
|
31
|
-
accept: "application/json",
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
if (typeof identity.value !== "string" || !identity.value)
|
|
35
|
-
throw new Error("Hosted OIDC provider omitted its identity token");
|
|
36
|
-
const exchange = await json(
|
|
37
|
-
`${REGISTRY}/-/npm/v1/oidc/token/exchange/package/${encodeURIComponent(name)}`,
|
|
38
|
-
{
|
|
39
|
-
method: "POST",
|
|
40
|
-
headers: {
|
|
41
|
-
authorization: `Bearer ${identity.value}`,
|
|
42
|
-
accept: "application/json",
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
);
|
|
46
|
-
if (typeof exchange.token !== "string" || !exchange.token)
|
|
47
|
-
throw new Error(
|
|
48
|
-
"npm OIDC exchange omitted its package-scoped credential",
|
|
49
|
-
);
|
|
50
|
-
return exchange.token;
|
|
51
|
-
}
|
|
52
15
|
return {
|
|
53
16
|
async observe(effect) {
|
|
54
17
|
if (effect.access === "restricted" && !environment.NODE_AUTH_TOKEN)
|
|
@@ -69,19 +32,5 @@ export function pipelineNpmChannel({
|
|
|
69
32
|
? { state: "present", version }
|
|
70
33
|
: { state: "absent", version: null };
|
|
71
34
|
},
|
|
72
|
-
async apply(effect) {
|
|
73
|
-
const token = await credential(effect.name);
|
|
74
|
-
await json(
|
|
75
|
-
`${REGISTRY}/-/package/${encodeURIComponent(effect.name)}/dist-tags/${encodeURIComponent(effect.tag)}`,
|
|
76
|
-
{
|
|
77
|
-
method: "PUT",
|
|
78
|
-
headers: {
|
|
79
|
-
authorization: `Bearer ${token}`,
|
|
80
|
-
"content-type": "application/json",
|
|
81
|
-
},
|
|
82
|
-
body: JSON.stringify(effect.version),
|
|
83
|
-
},
|
|
84
|
-
);
|
|
85
|
-
},
|
|
86
35
|
};
|
|
87
36
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
4
|
import { publishedDigest, runNpm } from "./registry.js";
|
|
4
5
|
import { createNativeChildEnvironment } from "../../dev-delivery/native/execution.js";
|
|
5
6
|
import {
|
|
@@ -8,6 +9,33 @@ import {
|
|
|
8
9
|
writeImmutablePublicationFile,
|
|
9
10
|
} from "../pipeline/files.js";
|
|
10
11
|
|
|
12
|
+
function publicationFailureCode(result) {
|
|
13
|
+
const code = stripVTControlCharacters(String(result.stderr ?? "")).match(
|
|
14
|
+
/^npm (?:error|ERR!) code ([A-Z0-9_]+)\r?$/m,
|
|
15
|
+
)?.[1];
|
|
16
|
+
return [
|
|
17
|
+
"ENEEDAUTH",
|
|
18
|
+
"EOTP",
|
|
19
|
+
"E401",
|
|
20
|
+
"E403",
|
|
21
|
+
"E404",
|
|
22
|
+
"EPUBLISHCONFLICT",
|
|
23
|
+
"EPRIVATE",
|
|
24
|
+
"EINTEGRITY",
|
|
25
|
+
"EUSAGE",
|
|
26
|
+
"EINVALIDPACKAGENAME",
|
|
27
|
+
"ETARGET",
|
|
28
|
+
"ENOENT",
|
|
29
|
+
"ETIMEDOUT",
|
|
30
|
+
"ECONNRESET",
|
|
31
|
+
"EAI_AGAIN",
|
|
32
|
+
"ENETUNREACH",
|
|
33
|
+
"ENOTFOUND",
|
|
34
|
+
].includes(code)
|
|
35
|
+
? code
|
|
36
|
+
: "unclassified";
|
|
37
|
+
}
|
|
38
|
+
|
|
11
39
|
export function pipelineNpmEnvironment(directory, environment = process.env) {
|
|
12
40
|
const cwd = path.resolve(directory);
|
|
13
41
|
const config = path.join(cwd, ".buildchain-publisher.npmrc");
|
|
@@ -102,7 +130,7 @@ export function pipelineNpmProvider({
|
|
|
102
130
|
});
|
|
103
131
|
if (result.status !== 0)
|
|
104
132
|
throw new Error(
|
|
105
|
-
`Sealed npm publication failed with exit ${result.status}`,
|
|
133
|
+
`Sealed npm publication failed with exit ${result.status} (npm code ${publicationFailureCode(result)})`,
|
|
106
134
|
);
|
|
107
135
|
},
|
|
108
136
|
};
|
|
@@ -115,13 +115,14 @@ export async function applyPipelinePublication(
|
|
|
115
115
|
matches: (effect, value) => providerFor(effect).matches(effect, value),
|
|
116
116
|
apply: (effect) => providerFor(effect).apply(effect),
|
|
117
117
|
};
|
|
118
|
+
const receipts = await journal.materials("publication/effect/");
|
|
118
119
|
const effects = pipelinePublicationEffects({
|
|
119
120
|
plan,
|
|
120
121
|
qualified,
|
|
121
122
|
documents,
|
|
122
123
|
evidence: evidence.map(({ bytes, ...asset }) => asset),
|
|
124
|
+
receipts,
|
|
123
125
|
});
|
|
124
|
-
const receipts = await journal.materials("publication/effect/");
|
|
125
126
|
let fence = journal.fence;
|
|
126
127
|
if (context.recovery?.preserveTransaction) {
|
|
127
128
|
const readback = await observeRecoveryPublicationEffects({
|
|
@@ -72,7 +72,10 @@ export function pipelineProductCapsules({
|
|
|
72
72
|
identityRoot: stageCapsuleIdentityRoot(identity),
|
|
73
73
|
retentionPromise: {
|
|
74
74
|
class: "github-artifact",
|
|
75
|
-
retainUntil: bundle.providerArtifact.expires_at
|
|
75
|
+
retainUntil: bundle.providerArtifact.expires_at.replace(
|
|
76
|
+
/T(\d{2}:\d{2}:\d{2})Z$/u,
|
|
77
|
+
"T$1.000Z",
|
|
78
|
+
),
|
|
76
79
|
},
|
|
77
80
|
capsuleRoot: `sha256:${"0".repeat(64)}`,
|
|
78
81
|
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
2
2
|
import { pipelineNpmChannel } from "../npm/pipeline-channel.js";
|
|
3
|
-
import { applyPipelineEffects } from "./effects.js";
|
|
4
|
-
import { compareDevelopmentVersions } from "../publication-development.js";
|
|
3
|
+
import { applyPipelineEffects, pipelinePublicationEffects } from "./effects.js";
|
|
5
4
|
|
|
6
5
|
export async function distributePipelineProducts(
|
|
7
6
|
context,
|
|
@@ -9,12 +8,10 @@ export async function distributePipelineProducts(
|
|
|
9
8
|
journal,
|
|
10
9
|
retained,
|
|
11
10
|
environment,
|
|
12
|
-
directory,
|
|
13
11
|
) {
|
|
14
12
|
const { plan, materialization } = context;
|
|
15
13
|
const channel = `v${plan.version.split(".")[0]}${plan.channel === "alpha" ? "-alpha" : ""}`;
|
|
16
14
|
const refPath = `/repos/${host.repository}/git/ref/tags/${channel}`;
|
|
17
|
-
const npmTag = plan.channel === "alpha" ? "alpha" : "latest";
|
|
18
15
|
const npm = pipelineNpmChannel({ environment });
|
|
19
16
|
async function observe(effect) {
|
|
20
17
|
if (effect.kind === "git-channel") {
|
|
@@ -25,7 +22,14 @@ export async function distributePipelineProducts(
|
|
|
25
22
|
? { state: "present", commit: value.object.sha }
|
|
26
23
|
: { state: "absent", commit: null };
|
|
27
24
|
}
|
|
28
|
-
|
|
25
|
+
// Only retained historical plans contain npm channel effects. New releases
|
|
26
|
+
// set their channel in npm publish and never enter a separate npm operation.
|
|
27
|
+
const value = await npm.observe(effect);
|
|
28
|
+
if (value.version !== effect.version)
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Historical npm channel ${effect.name}@${effect.tag} requires a one-time authenticated correction to ${effect.version}; recovery never republishes or rewrites npm tags`,
|
|
31
|
+
);
|
|
32
|
+
return value;
|
|
29
33
|
}
|
|
30
34
|
const plans = await journal.materials("publication/distribution-plan/");
|
|
31
35
|
if (plans.length > 1)
|
|
@@ -40,18 +44,26 @@ export async function distributePipelineProducts(
|
|
|
40
44
|
commit: materialization.source.commit,
|
|
41
45
|
},
|
|
42
46
|
];
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
name: artifact.package.name,
|
|
50
|
-
access: artifact.targets.find(({ provider }) => provider === "npm")
|
|
51
|
-
.access,
|
|
52
|
-
tag: npmTag,
|
|
53
|
-
version: plan.version,
|
|
47
|
+
const receipts = await journal.materials("publication/effect/");
|
|
48
|
+
if (receipts.length) {
|
|
49
|
+
const published = pipelinePublicationEffects({
|
|
50
|
+
plan,
|
|
51
|
+
...retained,
|
|
52
|
+
receipts,
|
|
54
53
|
});
|
|
54
|
+
for (const effect of published.filter(
|
|
55
|
+
(item) =>
|
|
56
|
+
item.kind === "npm-package" && item.tag.startsWith("buildchain-"),
|
|
57
|
+
))
|
|
58
|
+
raw.push({
|
|
59
|
+
id: `npm-channel:${effect.product}`,
|
|
60
|
+
kind: "npm-channel",
|
|
61
|
+
name: effect.name,
|
|
62
|
+
access: effect.access,
|
|
63
|
+
tag: plan.channel === "alpha" ? "alpha" : "latest",
|
|
64
|
+
version: effect.version,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
55
67
|
effects = [];
|
|
56
68
|
for (const effect of raw) {
|
|
57
69
|
const before = await observe(effect);
|
|
@@ -81,12 +93,6 @@ export async function distributePipelineProducts(
|
|
|
81
93
|
throw new Error(
|
|
82
94
|
"Floating channel changed since retained distribution admission",
|
|
83
95
|
);
|
|
84
|
-
if (
|
|
85
|
-
effect.kind === "npm-channel" &&
|
|
86
|
-
current.version &&
|
|
87
|
-
compareDevelopmentVersions(current.version, effect.version) > 0
|
|
88
|
-
)
|
|
89
|
-
throw new Error("npm channel cannot regress a newer published version");
|
|
90
96
|
await journal.fence();
|
|
91
97
|
if (effect.kind === "git-channel") {
|
|
92
98
|
if (current.state === "absent")
|
|
@@ -101,7 +107,7 @@ export async function distributePipelineProducts(
|
|
|
101
107
|
{ method: "PATCH", body: { sha: effect.commit, force: false } },
|
|
102
108
|
);
|
|
103
109
|
}
|
|
104
|
-
|
|
110
|
+
throw new Error("Distribution cannot write npm channels");
|
|
105
111
|
},
|
|
106
112
|
};
|
|
107
113
|
const receipts = await applyPipelineEffects({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setTimeout } from "node:timers/promises";
|
|
1
2
|
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
2
3
|
|
|
3
4
|
const KINDS = new Set([
|
|
@@ -14,6 +15,7 @@ export function pipelinePublicationEffects({
|
|
|
14
15
|
qualified,
|
|
15
16
|
documents,
|
|
16
17
|
evidence = [],
|
|
18
|
+
receipts = [],
|
|
17
19
|
}) {
|
|
18
20
|
const effects = [
|
|
19
21
|
{
|
|
@@ -34,7 +36,7 @@ export function pipelinePublicationEffects({
|
|
|
34
36
|
version: artifact.package.version,
|
|
35
37
|
integrity: artifact.package.integrity,
|
|
36
38
|
access: target.access || "public",
|
|
37
|
-
tag:
|
|
39
|
+
tag: plan.channel === "alpha" ? "alpha" : "latest",
|
|
38
40
|
});
|
|
39
41
|
if (target.provider === "github-release")
|
|
40
42
|
effects.push({
|
|
@@ -72,7 +74,24 @@ export function pipelinePublicationEffects({
|
|
|
72
74
|
tag: plan.tag,
|
|
73
75
|
prerelease: plan.channel === "alpha",
|
|
74
76
|
});
|
|
75
|
-
return effects.map((effect) =>
|
|
77
|
+
return effects.map((effect) => {
|
|
78
|
+
if (effect.kind === "npm-package") {
|
|
79
|
+
// Historical receipts keep their exact identity across runtime repairs.
|
|
80
|
+
const original = {
|
|
81
|
+
...effect,
|
|
82
|
+
tag: `buildchain-${plan.root.slice(7, 23)}`,
|
|
83
|
+
};
|
|
84
|
+
if (
|
|
85
|
+
receipts.some(
|
|
86
|
+
(receipt) =>
|
|
87
|
+
receipt.effectId === effect.id &&
|
|
88
|
+
receipt.effectRoot === recordDigest(original),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
effect = original;
|
|
92
|
+
}
|
|
93
|
+
return { ...effect, root: recordDigest(effect) };
|
|
94
|
+
});
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
// This executor owns only bounded provider operations. Journal admission/fencing
|
|
@@ -85,6 +104,7 @@ export async function applyPipelineEffects({
|
|
|
85
104
|
provider,
|
|
86
105
|
fence,
|
|
87
106
|
retain,
|
|
107
|
+
wait = setTimeout,
|
|
88
108
|
}) {
|
|
89
109
|
const results = [];
|
|
90
110
|
for (const effect of effects) {
|
|
@@ -133,11 +153,21 @@ export async function applyPipelineEffects({
|
|
|
133
153
|
} catch (error) {
|
|
134
154
|
failure = error;
|
|
135
155
|
}
|
|
136
|
-
|
|
156
|
+
// Accepted npm versions remain unavailable during registry scanning.
|
|
157
|
+
// Bound that wait to fifteen minutes without repeating the write.
|
|
158
|
+
const scanning = effect.kind === "npm-package" && !failure;
|
|
159
|
+
const maximumReads = scanning ? 31 : 3;
|
|
160
|
+
for (let attempt = 0; attempt < maximumReads; attempt++) {
|
|
161
|
+
if (attempt) await wait(scanning ? 30_000 : 1000);
|
|
162
|
+
await fence();
|
|
163
|
+
observed = await provider.observe(effect);
|
|
164
|
+
if (observed.state !== "absent" || provider.matches(effect, observed))
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
137
167
|
if (!provider.matches(effect, observed)) {
|
|
138
168
|
if (failure) throw failure;
|
|
139
169
|
throw new Error(
|
|
140
|
-
|
|
170
|
+
`Publication write lacks exact successful provider readback (${effect.kind})`,
|
|
141
171
|
);
|
|
142
172
|
}
|
|
143
173
|
}
|
|
@@ -44,6 +44,33 @@ function packageArtifact(directory, output, expected, version, environment) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function archiveNames(file, suffix) {
|
|
48
|
+
const options = {
|
|
49
|
+
cwd: path.dirname(file),
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
52
|
+
timeout: 30000,
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
};
|
|
55
|
+
if (suffix === ".zip") {
|
|
56
|
+
const script = [
|
|
57
|
+
"import json,sys,zipfile",
|
|
58
|
+
"with zipfile.ZipFile(sys.argv[1]) as archive:",
|
|
59
|
+
" print(json.dumps(archive.namelist()))",
|
|
60
|
+
].join("\n");
|
|
61
|
+
return JSON.parse(
|
|
62
|
+
execFileSync(
|
|
63
|
+
process.platform === "win32" ? "python" : "python3",
|
|
64
|
+
["-c", script, path.basename(file)],
|
|
65
|
+
options,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return execFileSync("tar", ["-tf", path.basename(file)], options)
|
|
70
|
+
.trim()
|
|
71
|
+
.split(/\r?\n/u);
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
export function inspectPipelineFileArtifact(directory, expected) {
|
|
48
75
|
const file = publicationPath(directory, expected.path);
|
|
49
76
|
if (expected.kind === "pdf") {
|
|
@@ -60,18 +87,14 @@ export function inspectPipelineFileArtifact(directory, expected) {
|
|
|
60
87
|
);
|
|
61
88
|
if (!suffix)
|
|
62
89
|
throw new Error("Declared binary output must be a standard archive");
|
|
63
|
-
const names =
|
|
64
|
-
cwd: path.dirname(file),
|
|
65
|
-
encoding: "utf8",
|
|
66
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
67
|
-
})
|
|
68
|
-
.trim()
|
|
69
|
-
.split(/\r?\n/u);
|
|
90
|
+
const names = archiveNames(file, suffix);
|
|
70
91
|
if (
|
|
71
92
|
!names[0] ||
|
|
72
93
|
names.some(
|
|
73
94
|
(name) =>
|
|
74
|
-
name.
|
|
95
|
+
!name.trim() ||
|
|
96
|
+
/[\u0000-\u001f\u007f]/u.test(name) ||
|
|
97
|
+
name.replaceAll("\\", "/").startsWith("/") ||
|
|
75
98
|
/^[A-Za-z]:/u.test(name) ||
|
|
76
99
|
name.replaceAll("\\", "/").split("/").includes(".."),
|
|
77
100
|
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setTimeout } from "node:timers/promises";
|
|
1
2
|
import {
|
|
2
3
|
canonicalJson,
|
|
3
4
|
recordDigest,
|
|
@@ -72,16 +73,17 @@ export function atomicAttemptJournal(provider, intent) {
|
|
|
72
73
|
}
|
|
73
74
|
// A lost response can follow a committed append. Reconcile the immutable
|
|
74
75
|
// record in live history; never retry the old parent with force=true.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
77
|
+
if (attempt) await setTimeout(1000);
|
|
78
|
+
const result = await read();
|
|
79
|
+
if (
|
|
80
|
+
result.snapshot.records.some(
|
|
81
|
+
(event) => canonicalJson(event) === canonicalJson(record),
|
|
82
|
+
)
|
|
79
83
|
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
);
|
|
84
|
-
return result;
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
throw failure || new Error("Attempt append missing from provider readback");
|
|
85
87
|
}
|
|
86
88
|
return { read, append };
|
|
87
89
|
}
|
|
@@ -5,12 +5,7 @@ import { normalInputs } from "../../consumer/contract/entries.js";
|
|
|
5
5
|
import { pipelineHost } from "./host.js";
|
|
6
6
|
import { controlPipeline } from "./controller.js";
|
|
7
7
|
import { buildPipelineSource } from "./build.js";
|
|
8
|
-
import { recordPipelineBuild } from "./build-control.js";
|
|
9
8
|
import { wakePipelineStableWait } from "../../publication/pipeline/stable-wait.js";
|
|
10
|
-
import {
|
|
11
|
-
RECOVERY_BUILD_CONTEXT,
|
|
12
|
-
recordRecoveryBuild,
|
|
13
|
-
} from "./recovery-build-control.js";
|
|
14
9
|
|
|
15
10
|
export async function controlPipelineAction(core, env) {
|
|
16
11
|
const { configPath } = normalInputs({
|
|
@@ -39,24 +34,6 @@ export async function buildPipelineAction(core, env) {
|
|
|
39
34
|
);
|
|
40
35
|
}
|
|
41
36
|
|
|
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
37
|
export async function wakePipelineAction(core, env) {
|
|
61
38
|
const waiting = core.getInput("stable-wait");
|
|
62
39
|
const host = await pipelineHost(
|
|
@@ -5,6 +5,83 @@ import {
|
|
|
5
5
|
} from "./build-qualification.js";
|
|
6
6
|
import { pipelinePlatforms } from "./platforms.js";
|
|
7
7
|
|
|
8
|
+
const RECOVERY_CHECK = "Buildchain recovery verification";
|
|
9
|
+
|
|
10
|
+
// Dispatch-created Actions checks do not satisfy protected PR checks. Preserve
|
|
11
|
+
// their evidence under a diagnostic name instead of shadowing normal PR checks.
|
|
12
|
+
export async function observePipelineBuildChecks(source, request, repository) {
|
|
13
|
+
if (
|
|
14
|
+
source.repository !== repository ||
|
|
15
|
+
!/^[0-9a-f]{40}$/u.test(source.commit)
|
|
16
|
+
)
|
|
17
|
+
throw new Error("Recovery check repository or source identity drift");
|
|
18
|
+
const base = `/repos/${repository}`,
|
|
19
|
+
renames = [],
|
|
20
|
+
eligible = [];
|
|
21
|
+
for (let page = 1; page <= 20; page++) {
|
|
22
|
+
const result = await request(
|
|
23
|
+
`${base}/commits/${source.commit}/check-runs?filter=all&per_page=100&page=${page}`,
|
|
24
|
+
);
|
|
25
|
+
if (!Array.isArray(result.check_runs) || result.check_runs.length > 100)
|
|
26
|
+
throw new Error("Recovery check inventory is incomplete");
|
|
27
|
+
for (const check of result.check_runs) {
|
|
28
|
+
const match = /^buildchain:(attempt-[0-9a-f]{64}):(\d+):(\d+)$/u.exec(
|
|
29
|
+
check.external_id || "",
|
|
30
|
+
);
|
|
31
|
+
if (
|
|
32
|
+
check.name !== "check" ||
|
|
33
|
+
check.app?.slug !== "github-actions" ||
|
|
34
|
+
!match
|
|
35
|
+
)
|
|
36
|
+
continue;
|
|
37
|
+
const runId = Number(match[2]),
|
|
38
|
+
runAttempt = Number(match[3]);
|
|
39
|
+
if (
|
|
40
|
+
check.head_sha !== source.commit ||
|
|
41
|
+
![check.id, runId, runAttempt].every(
|
|
42
|
+
(value) => Number.isSafeInteger(value) && value > 0,
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
throw new Error("Recovery check provider identity drift");
|
|
46
|
+
const run = await request(
|
|
47
|
+
`${base}/actions/runs/${runId}/attempts/${runAttempt}`,
|
|
48
|
+
);
|
|
49
|
+
if (
|
|
50
|
+
run.id !== runId ||
|
|
51
|
+
run.run_attempt !== runAttempt ||
|
|
52
|
+
run.repository?.full_name !== repository
|
|
53
|
+
)
|
|
54
|
+
throw new Error("Recovery check execution identity drift");
|
|
55
|
+
if (["workflow_dispatch", "repository_dispatch"].includes(run.event))
|
|
56
|
+
renames.push({
|
|
57
|
+
id: check.id,
|
|
58
|
+
externalId: check.external_id,
|
|
59
|
+
sourceHead: source.commit,
|
|
60
|
+
runId,
|
|
61
|
+
runAttempt,
|
|
62
|
+
name: RECOVERY_CHECK,
|
|
63
|
+
});
|
|
64
|
+
else if (
|
|
65
|
+
[
|
|
66
|
+
"push",
|
|
67
|
+
"pull_request",
|
|
68
|
+
"pull_request_review",
|
|
69
|
+
"pull_request_target",
|
|
70
|
+
].includes(run.event) &&
|
|
71
|
+
run.head_sha === source.commit
|
|
72
|
+
)
|
|
73
|
+
eligible.push({
|
|
74
|
+
id: check.id,
|
|
75
|
+
runId,
|
|
76
|
+
runAttempt,
|
|
77
|
+
conclusion: check.conclusion,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (result.check_runs.length < 100) return { renames, eligible };
|
|
81
|
+
}
|
|
82
|
+
throw new Error("Recovery check inventory exceeds its complete-read bound");
|
|
83
|
+
}
|
|
84
|
+
|
|
8
85
|
// The archive reference is already validated against the current attempt. A
|
|
9
86
|
// build result is still re-read from the provider before it can admit delivery.
|
|
10
87
|
export async function pipelineBuildEvidence(session, host) {
|
|
@@ -74,18 +151,59 @@ export async function publishPipelineBuildCheck(
|
|
|
74
151
|
"Product check source differs from independently verified build",
|
|
75
152
|
);
|
|
76
153
|
const externalId = `buildchain:${context.attempt}:${context.runId}:${context.runAttempt}`;
|
|
154
|
+
const recovered = readback.schema === PIPELINE_BUILD_QUALIFICATION;
|
|
155
|
+
let checkName = recovered ? RECOVERY_CHECK : "check";
|
|
156
|
+
const output = {
|
|
157
|
+
title: recovered
|
|
158
|
+
? "Buildchain recovered product verification"
|
|
159
|
+
: "Buildchain product verification",
|
|
160
|
+
summary: `Exact product jobs: ${readback.root}\nAttempt: ${context.attempt}`,
|
|
161
|
+
};
|
|
162
|
+
if (recovered) {
|
|
163
|
+
const checks = await observePipelineBuildChecks(
|
|
164
|
+
context.source,
|
|
165
|
+
request,
|
|
166
|
+
repository,
|
|
167
|
+
);
|
|
168
|
+
for (const change of checks.renames)
|
|
169
|
+
await request(`/repos/${repository}/check-runs/${change.id}`, {
|
|
170
|
+
method: "PATCH",
|
|
171
|
+
body: { name: change.name },
|
|
172
|
+
});
|
|
173
|
+
const original = checks.eligible.find((check) =>
|
|
174
|
+
readback.segments.some(
|
|
175
|
+
(segment) =>
|
|
176
|
+
segment.readback.runId === check.runId &&
|
|
177
|
+
segment.readback.runAttempt === check.runAttempt,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
// A complete replacement has no reusable original check to project onto.
|
|
181
|
+
// Retain that failed execution and publish this independently qualified
|
|
182
|
+
// aggregate as the required check. Protected landing still requalifies it
|
|
183
|
+
// and publishes the App-bound commit status before queue admission.
|
|
184
|
+
if (!original) checkName = "check";
|
|
185
|
+
// Keep the eligible normal-event check identity. Only a fully requalified
|
|
186
|
+
// source aggregate can repair its failed projection; original runs and
|
|
187
|
+
// immutable build receipts still retain their original failure outcomes.
|
|
188
|
+
if (
|
|
189
|
+
original &&
|
|
190
|
+
original.conclusion !== "success" &&
|
|
191
|
+
readback.outcome === "success"
|
|
192
|
+
)
|
|
193
|
+
await request(`/repos/${repository}/check-runs/${original.id}`, {
|
|
194
|
+
method: "PATCH",
|
|
195
|
+
body: { status: "completed", conclusion: "success", output },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
77
198
|
return request(`/repos/${repository}/check-runs`, {
|
|
78
199
|
method: "POST",
|
|
79
200
|
body: {
|
|
80
|
-
name:
|
|
201
|
+
name: checkName,
|
|
81
202
|
head_sha: context.source.commit,
|
|
82
203
|
external_id: externalId,
|
|
83
204
|
status: "completed",
|
|
84
205
|
conclusion: readback.outcome === "success" ? "success" : "failure",
|
|
85
|
-
output
|
|
86
|
-
title: "Buildchain product verification",
|
|
87
|
-
summary: `Exact product jobs: ${readback.root}\nAttempt: ${context.attempt}`,
|
|
88
|
-
},
|
|
206
|
+
output,
|
|
89
207
|
details_url: `https://github.com/${repository}/actions/runs/${context.runId}/attempts/${context.runAttempt}`,
|
|
90
208
|
},
|
|
91
209
|
});
|