@kungfu-tech/buildchain 3.0.5-alpha.4 → 3.0.5-alpha.6
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/actions/macos-credential-island/README.md +8 -0
- package/dist/site/buildchain-contract.json +4 -4
- package/dist/site/buildchain-site.json +14 -14
- package/dist/site/kfd-claims.json +2 -2
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +2 -2
- package/dist/site/node-api-registry.json +11 -11
- package/dist/site/page-registry.json +8 -8
- package/dist/site/public-surface-audit.json +1 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +6 -6
- package/docs/aws-us-elastic-runner-burst-plane.md +29 -10
- package/docs/node-api-reference.md +27 -27
- package/docs/release-governance.md +32 -0
- package/package.json +1 -1
- package/packages/core/public-surface-audit.js +4 -35
- package/packages/core/release-propagation-work.js +7 -3
- package/packages/core/workflow-call-contract.js +308 -0
- package/packages/core/workflow-yaml-contract.js +272 -0
- package/scripts/aws-windows-jit-campaign-core.mjs +65 -10
- package/scripts/aws-windows-jit-campaign.mjs +40 -5
- package/scripts/aws-windows-jit-core.mjs +3 -3
- package/scripts/capture-package-release-propagation.mjs +24 -1
- package/scripts/finalize-native-artifact-signing-result.mjs +37 -15
- package/scripts/workflow-call-contract.mjs +133 -0
|
@@ -38,6 +38,34 @@ function number(value) {
|
|
|
38
38
|
return { N: String(value) };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function money(value, label) {
|
|
42
|
+
const normalized = String(value ?? "").trim();
|
|
43
|
+
if (!normalized) {
|
|
44
|
+
throw new Error(`${label} is required`);
|
|
45
|
+
}
|
|
46
|
+
const parsed = Number(normalized);
|
|
47
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
48
|
+
throw new Error(`${label} must be a non-negative finite number`);
|
|
49
|
+
}
|
|
50
|
+
return Math.round(parsed * 100_000_000) / 100_000_000;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function acceptedInstances(value) {
|
|
54
|
+
const normalized = String(value ?? "").trim();
|
|
55
|
+
if (!normalized) return WINDOWS_EC2_JIT.maxAcceptedInstances;
|
|
56
|
+
const parsed = Number(normalized);
|
|
57
|
+
if (
|
|
58
|
+
!Number.isInteger(parsed) ||
|
|
59
|
+
parsed < 1 ||
|
|
60
|
+
parsed > WINDOWS_EC2_JIT.maxAcceptedInstances
|
|
61
|
+
) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`maxAcceptedInstances must be an integer from 1 through ${WINDOWS_EC2_JIT.maxAcceptedInstances}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
41
69
|
function string(value) {
|
|
42
70
|
return { S: String(value) };
|
|
43
71
|
}
|
|
@@ -56,6 +84,22 @@ export function createWindowsJitCampaignArmPlan(values = {}) {
|
|
|
56
84
|
(WINDOWS_EC2_JIT.pricePerHourUsd *
|
|
57
85
|
WINDOWS_EC2_JIT.maximumInstanceLifetimeMinutes) /
|
|
58
86
|
60;
|
|
87
|
+
const phaseSpendBaselineUsd = money(
|
|
88
|
+
values.phaseSpendBaselineUsd,
|
|
89
|
+
"phaseSpendBaselineUsd",
|
|
90
|
+
);
|
|
91
|
+
const maxAcceptedInstances = acceptedInstances(values.maxAcceptedInstances);
|
|
92
|
+
const campaignReservationCeilingUsd = reservationUsd * maxAcceptedInstances;
|
|
93
|
+
const campaignSafetyCeilingUsd =
|
|
94
|
+
campaignReservationCeilingUsd +
|
|
95
|
+
reservationUsd * WINDOWS_EC2_JIT.maxConcurrentInstances;
|
|
96
|
+
const remainingPhaseBudgetUsd =
|
|
97
|
+
WINDOWS_EC2_JIT.budgetLimitUsd - phaseSpendBaselineUsd;
|
|
98
|
+
if (campaignSafetyCeilingUsd >= remainingPhaseBudgetUsd) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"campaign safety envelope must remain below the remaining Windows phase budget",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
59
103
|
return {
|
|
60
104
|
schemaVersion: 1,
|
|
61
105
|
contract: AWS_WINDOWS_JIT_CAMPAIGN_CONTRACT,
|
|
@@ -75,9 +119,14 @@ export function createWindowsJitCampaignArmPlan(values = {}) {
|
|
|
75
119
|
stateTable: tableName(values.stateTable),
|
|
76
120
|
},
|
|
77
121
|
limits: {
|
|
78
|
-
maxAcceptedInstances
|
|
122
|
+
maxAcceptedInstances,
|
|
79
123
|
reservationUsd,
|
|
80
124
|
budgetLimitUsd: WINDOWS_EC2_JIT.budgetLimitUsd,
|
|
125
|
+
phaseSpendBaselineUsd,
|
|
126
|
+
remainingPhaseBudgetUsd,
|
|
127
|
+
campaignReservationCeilingUsd,
|
|
128
|
+
campaignSafetyCeilingUsd,
|
|
129
|
+
reservationLimitUsd: remainingPhaseBudgetUsd - reservationUsd,
|
|
81
130
|
},
|
|
82
131
|
};
|
|
83
132
|
}
|
|
@@ -97,6 +146,8 @@ export function windowsCampaignArmItems(plan) {
|
|
|
97
146
|
state: string("ARMED"),
|
|
98
147
|
campaign_id: string(plan.campaign.id),
|
|
99
148
|
source_sha: string(plan.source.sha),
|
|
149
|
+
phase_spend_baseline_usd: number(plan.limits.phaseSpendBaselineUsd),
|
|
150
|
+
budget_limit_usd: number(plan.limits.budgetLimitUsd),
|
|
100
151
|
armed_at: number(plan.campaign.armedAt),
|
|
101
152
|
expires_epoch: number(plan.campaign.expiresAt),
|
|
102
153
|
},
|
|
@@ -115,6 +166,17 @@ export function windowsCampaignArmItems(plan) {
|
|
|
115
166
|
max_accepted_instances: number(plan.limits.maxAcceptedInstances),
|
|
116
167
|
reservation_usd: number(plan.limits.reservationUsd),
|
|
117
168
|
budget_limit_usd: number(plan.limits.budgetLimitUsd),
|
|
169
|
+
phase_spend_baseline_usd: number(plan.limits.phaseSpendBaselineUsd),
|
|
170
|
+
remaining_phase_budget_usd: number(
|
|
171
|
+
plan.limits.remainingPhaseBudgetUsd,
|
|
172
|
+
),
|
|
173
|
+
campaign_reservation_ceiling_usd: number(
|
|
174
|
+
plan.limits.campaignReservationCeilingUsd,
|
|
175
|
+
),
|
|
176
|
+
campaign_safety_ceiling_usd: number(
|
|
177
|
+
plan.limits.campaignSafetyCeilingUsd,
|
|
178
|
+
),
|
|
179
|
+
reservation_limit_usd: number(plan.limits.reservationLimitUsd),
|
|
118
180
|
armed_at: number(plan.campaign.armedAt),
|
|
119
181
|
expires_epoch: number(plan.campaign.expiresAt),
|
|
120
182
|
},
|
|
@@ -128,7 +190,6 @@ export function windowsCampaignReservationItems(plan, observedAt) {
|
|
|
128
190
|
const now = epoch(observedAt, "observedAt");
|
|
129
191
|
const runPk = runKey(plan);
|
|
130
192
|
const campaignPk = `CAMPAIGN#${plan.campaign.id}`;
|
|
131
|
-
const maxAccepted = plan.safety.campaignAcceptedInstanceCeiling;
|
|
132
193
|
const reservation = plan.safety.campaignReservationUsd;
|
|
133
194
|
return [
|
|
134
195
|
{
|
|
@@ -171,17 +232,13 @@ export function windowsCampaignReservationItems(plan, observedAt) {
|
|
|
171
232
|
UpdateExpression:
|
|
172
233
|
"ADD accepted_instances :one, reserved_usd :reservation SET updated_at = :now",
|
|
173
234
|
ConditionExpression:
|
|
174
|
-
"#state = :armed AND source_sha = :source AND accepted_instances <
|
|
235
|
+
"#state = :armed AND source_sha = :source AND accepted_instances < max_accepted_instances AND reserved_usd <= reservation_limit_usd",
|
|
175
236
|
ExpressionAttributeNames: { "#state": "state" },
|
|
176
237
|
ExpressionAttributeValues: {
|
|
177
238
|
":armed": string("ARMED"),
|
|
178
239
|
":source": string(plan.source.sha),
|
|
179
240
|
":one": number(1),
|
|
180
241
|
":reservation": number(reservation),
|
|
181
|
-
":max": number(maxAccepted),
|
|
182
|
-
":remaining": number(
|
|
183
|
-
plan.safety.campaignBudgetLimitUsd - reservation,
|
|
184
|
-
),
|
|
185
242
|
":now": number(now),
|
|
186
243
|
},
|
|
187
244
|
},
|
|
@@ -230,9 +287,7 @@ export function windowsCampaignKillArgs(stateTable, reason, observedAt) {
|
|
|
230
287
|
"--expression-attribute-values",
|
|
231
288
|
JSON.stringify({
|
|
232
289
|
":killed": string("KILLED"),
|
|
233
|
-
":reason": string(
|
|
234
|
-
exact(reason, /^[a-z0-9][a-z0-9-]{2,63}$/, "reason"),
|
|
235
|
-
),
|
|
290
|
+
":reason": string(exact(reason, /^[a-z0-9][a-z0-9-]{2,63}$/, "reason")),
|
|
236
291
|
":now": number(epoch(observedAt, "observedAt")),
|
|
237
292
|
}),
|
|
238
293
|
"--output",
|
|
@@ -30,7 +30,9 @@ function aws(plan, serviceArgs) {
|
|
|
30
30
|
const detail = String(result.stderr || result.stdout || "")
|
|
31
31
|
.trim()
|
|
32
32
|
.slice(0, 2000);
|
|
33
|
-
throw new Error(
|
|
33
|
+
throw new Error(
|
|
34
|
+
`AWS campaign mutation failed${detail ? `: ${detail}` : ""}`,
|
|
35
|
+
);
|
|
34
36
|
}
|
|
35
37
|
return result.stdout ? JSON.parse(result.stdout) : {};
|
|
36
38
|
}
|
|
@@ -43,10 +45,15 @@ function armPlan() {
|
|
|
43
45
|
region: arg("region", "us-east-1"),
|
|
44
46
|
armedAt: arg("armed-at", new Date().toISOString()),
|
|
45
47
|
expiresAt: arg("expires-at"),
|
|
48
|
+
phaseSpendBaselineUsd: arg("phase-spend-baseline-usd"),
|
|
49
|
+
maxAcceptedInstances: arg("max-accepted-instances"),
|
|
46
50
|
});
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
function confirm(
|
|
53
|
+
function confirm(
|
|
54
|
+
plan,
|
|
55
|
+
{ phaseSpendBaseline = true, maxAcceptedInstances = true } = {},
|
|
56
|
+
) {
|
|
50
57
|
if (arg("confirm-campaign-id") !== plan.campaign.id) {
|
|
51
58
|
throw new Error("--confirm-campaign-id must equal the campaign id");
|
|
52
59
|
}
|
|
@@ -54,7 +61,29 @@ function confirm(plan) {
|
|
|
54
61
|
throw new Error("--confirm-source-sha must equal the exact source SHA");
|
|
55
62
|
}
|
|
56
63
|
if (arg("confirm-state-table") !== plan.aws.stateTable) {
|
|
57
|
-
throw new Error(
|
|
64
|
+
throw new Error(
|
|
65
|
+
"--confirm-state-table must equal the campaign state table",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
phaseSpendBaseline &&
|
|
70
|
+
(!arg("confirm-phase-spend-baseline-usd").trim() ||
|
|
71
|
+
Number(arg("confirm-phase-spend-baseline-usd")) !==
|
|
72
|
+
plan.limits.phaseSpendBaselineUsd)
|
|
73
|
+
) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"--confirm-phase-spend-baseline-usd must equal the phase spend baseline",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
maxAcceptedInstances &&
|
|
80
|
+
(!arg("confirm-max-accepted-instances").trim() ||
|
|
81
|
+
Number(arg("confirm-max-accepted-instances")) !==
|
|
82
|
+
plan.limits.maxAcceptedInstances)
|
|
83
|
+
) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"--confirm-max-accepted-instances must equal the campaign slot ceiling",
|
|
86
|
+
);
|
|
58
87
|
}
|
|
59
88
|
}
|
|
60
89
|
|
|
@@ -65,7 +94,9 @@ function killSwitchTopic() {
|
|
|
65
94
|
topic,
|
|
66
95
|
)
|
|
67
96
|
) {
|
|
68
|
-
throw new Error(
|
|
97
|
+
throw new Error(
|
|
98
|
+
"--kill-switch-topic must be the dedicated Windows JIT SNS ARN",
|
|
99
|
+
);
|
|
69
100
|
}
|
|
70
101
|
if (arg("confirm-kill-switch-topic") !== topic) {
|
|
71
102
|
throw new Error(
|
|
@@ -113,8 +144,12 @@ export function main() {
|
|
|
113
144
|
region: arg("region", "us-east-1"),
|
|
114
145
|
armedAt: now.toISOString(),
|
|
115
146
|
expiresAt: new Date(now.getTime() + 1000).toISOString(),
|
|
147
|
+
phaseSpendBaselineUsd: 0,
|
|
148
|
+
});
|
|
149
|
+
confirm(plan, {
|
|
150
|
+
phaseSpendBaseline: false,
|
|
151
|
+
maxAcceptedInstances: false,
|
|
116
152
|
});
|
|
117
|
-
confirm(plan);
|
|
118
153
|
const topic = killSwitchTopic();
|
|
119
154
|
aws(
|
|
120
155
|
plan,
|
|
@@ -10,9 +10,9 @@ export const WINDOWS_EC2_JIT = Object.freeze({
|
|
|
10
10
|
instanceType: "c7i.4xlarge",
|
|
11
11
|
pricePerHourUsd: 1.45,
|
|
12
12
|
maximumInstanceLifetimeMinutes: 180,
|
|
13
|
-
maxConcurrentInstances:
|
|
14
|
-
maxAcceptedInstances:
|
|
15
|
-
budgetLimitUsd:
|
|
13
|
+
maxConcurrentInstances: 1,
|
|
14
|
+
maxAcceptedInstances: 5,
|
|
15
|
+
budgetLimitUsd: 80,
|
|
16
16
|
minimumSmokeJobs: 1,
|
|
17
17
|
minimumFullJobs: 3,
|
|
18
18
|
maximumCleanupLatencySeconds: 900,
|
|
@@ -49,7 +49,30 @@ function assertSourcePath(value) {
|
|
|
49
49
|
return sourcePath;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function
|
|
52
|
+
function hasCommit(sourceSha, cwd) {
|
|
53
|
+
try {
|
|
54
|
+
execFileSync("git", ["cat-file", "-e", `${sourceSha}^{commit}`], {
|
|
55
|
+
cwd,
|
|
56
|
+
stdio: "ignore",
|
|
57
|
+
});
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readConfigAtSource(sourceSha, configPath, cwd) {
|
|
65
|
+
if (!hasCommit(sourceSha, cwd)) {
|
|
66
|
+
try {
|
|
67
|
+
execFileSync("git", ["fetch", "--no-tags", "--depth=1", "origin", sourceSha], {
|
|
68
|
+
cwd,
|
|
69
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const detail = String(error.stderr || error.message || "unknown git fetch failure").trim();
|
|
73
|
+
throw new Error(`exact release source ${sourceSha} is unavailable from origin: ${detail}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
53
76
|
const bytes = execFileSync("git", ["show", `${sourceSha}:${configPath}`], {
|
|
54
77
|
cwd,
|
|
55
78
|
encoding: "utf8",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
artifactSigningEvidenceDigest,
|
|
13
13
|
createArtifactSigningResult,
|
|
14
14
|
} from "../packages/core/artifact-signing-result.js";
|
|
15
|
+
import { acceptedMacosCredentialEvidence } from "../actions/macos-credential-island/dmg-assembly.js";
|
|
15
16
|
import { writeGitHubOutputs } from "./build-contract-core.mjs";
|
|
16
17
|
|
|
17
18
|
function required(value, label) {
|
|
@@ -32,15 +33,39 @@ function resolveBelow(root, relative, label) {
|
|
|
32
33
|
return target;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
function expectedCredentialExecution(
|
|
37
|
+
appBundleResult,
|
|
38
|
+
expectedRunId,
|
|
39
|
+
expectedRunAttempt,
|
|
40
|
+
) {
|
|
41
|
+
if (!appBundleResult) return null;
|
|
42
|
+
return {
|
|
43
|
+
runId: required(expectedRunId, "credential execution run id"),
|
|
44
|
+
runAttempt: required(
|
|
45
|
+
expectedRunAttempt,
|
|
46
|
+
"credential execution run attempt",
|
|
47
|
+
),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function acceptedProviderEvidence(evidence, request, expectedExecution) {
|
|
52
|
+
return expectedExecution
|
|
53
|
+
? acceptedMacosCredentialEvidence(evidence, request, expectedExecution)
|
|
54
|
+
: evidence.status === "passed" &&
|
|
55
|
+
evidence.provider === request.signature.provider;
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
export function finalizeNativeArtifactSigningResult({
|
|
36
59
|
requestRoot = process.env.BUILDCHAIN_SIGNING_REQUEST_ROOT,
|
|
37
60
|
requestPath = process.env.BUILDCHAIN_SIGNING_REQUEST_PATH,
|
|
38
61
|
signedPayload = process.env.BUILDCHAIN_SIGNED_PAYLOAD,
|
|
39
62
|
evidencePath = process.env.BUILDCHAIN_SIGNING_EVIDENCE,
|
|
40
|
-
credentialArtifactRoot =
|
|
41
|
-
|
|
63
|
+
credentialArtifactRoot = process.env
|
|
64
|
+
.BUILDCHAIN_SIGNING_CREDENTIAL_ARTIFACT_ROOT,
|
|
42
65
|
outputRoot = process.env.BUILDCHAIN_SIGNING_RESULT_ROOT,
|
|
43
66
|
checks = process.env.BUILDCHAIN_SIGNING_VERIFICATION_CHECKS,
|
|
67
|
+
expectedRunId = process.env.GITHUB_RUN_ID,
|
|
68
|
+
expectedRunAttempt = process.env.GITHUB_RUN_ATTEMPT,
|
|
44
69
|
} = {}) {
|
|
45
70
|
const requests = path.resolve(required(requestRoot, "signing request root"));
|
|
46
71
|
const request = JSON.parse(
|
|
@@ -77,19 +102,16 @@ export function finalizeNativeArtifactSigningResult({
|
|
|
77
102
|
fs.copyFileSync(evidenceSource, evidenceOutput, fs.constants.COPYFILE_EXCL);
|
|
78
103
|
const evidenceDocument = JSON.parse(fs.readFileSync(evidenceOutput, "utf8"));
|
|
79
104
|
const appBundleResult = request.artifact.kind === "app-bundle";
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
evidenceDocument.notarization?.diskImage?.status === "Accepted"
|
|
91
|
-
: evidenceDocument.status === "passed" &&
|
|
92
|
-
evidenceDocument.provider === request.signature.provider;
|
|
105
|
+
const expectedExecution = expectedCredentialExecution(
|
|
106
|
+
appBundleResult,
|
|
107
|
+
expectedRunId,
|
|
108
|
+
expectedRunAttempt,
|
|
109
|
+
);
|
|
110
|
+
const providerEvidencePassed = acceptedProviderEvidence(
|
|
111
|
+
evidenceDocument,
|
|
112
|
+
request,
|
|
113
|
+
expectedExecution,
|
|
114
|
+
);
|
|
93
115
|
if (!providerEvidencePassed) {
|
|
94
116
|
throw new Error(
|
|
95
117
|
"provider evidence does not prove the requested native signature",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { evaluateWorkflowCallContract } from "../packages/core/workflow-call-contract.js";
|
|
7
|
+
|
|
8
|
+
function usage() {
|
|
9
|
+
return `usage: node scripts/workflow-call-contract.mjs check \\
|
|
10
|
+
--caller-workflow <path> --job <id> --caller-repository <owner/repo> \\
|
|
11
|
+
--callee-root <checkout> --callee-workflow <path> --callee-repository <owner/repo> \\
|
|
12
|
+
--trusted-event <event[:type]> [--trusted-event ...] \\
|
|
13
|
+
[--expected-contract-root sha256:...] [--allow-dirty] [--output <path>]`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const options = { trustedEvents: [] };
|
|
18
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
19
|
+
const arg = argv[index];
|
|
20
|
+
if (arg === "--allow-dirty") {
|
|
21
|
+
options.allowDirty = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (!arg.startsWith("--") || !argv[index + 1])
|
|
25
|
+
throw new Error(`invalid argument: ${arg}`);
|
|
26
|
+
const value = argv[index + 1];
|
|
27
|
+
index += 1;
|
|
28
|
+
if (arg === "--trusted-event") options.trustedEvents.push(value);
|
|
29
|
+
else
|
|
30
|
+
options[
|
|
31
|
+
arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
|
32
|
+
] = value;
|
|
33
|
+
}
|
|
34
|
+
return options;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function git(root, ...args) {
|
|
38
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
}).trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function required(options, names) {
|
|
44
|
+
const missing = names.filter((name) => !options[name]);
|
|
45
|
+
if (missing.length)
|
|
46
|
+
throw new Error(`missing required options: ${missing.join(", ")}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function checkWorkflowCall(options) {
|
|
50
|
+
const callerRoot = path.resolve(options.callerRoot || process.cwd());
|
|
51
|
+
const calleeRoot = path.resolve(options.calleeRoot);
|
|
52
|
+
const callerStatus = git(
|
|
53
|
+
callerRoot,
|
|
54
|
+
"status",
|
|
55
|
+
"--porcelain",
|
|
56
|
+
"--untracked-files=no",
|
|
57
|
+
);
|
|
58
|
+
if (callerStatus && !options.allowDirty) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"caller checkout is dirty; use --allow-dirty only for local diagnostic validation",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const calleeStatus = git(
|
|
64
|
+
calleeRoot,
|
|
65
|
+
"status",
|
|
66
|
+
"--porcelain",
|
|
67
|
+
"--untracked-files=no",
|
|
68
|
+
);
|
|
69
|
+
if (calleeStatus) {
|
|
70
|
+
throw new Error("callee checkout is dirty; exact pinned-ref bytes are required");
|
|
71
|
+
}
|
|
72
|
+
const callerSha = git(callerRoot, "rev-parse", "HEAD");
|
|
73
|
+
const callerTree = git(callerRoot, "rev-parse", "HEAD^{tree}");
|
|
74
|
+
const calleeSha = git(calleeRoot, "rev-parse", "HEAD");
|
|
75
|
+
const report = evaluateWorkflowCallContract({
|
|
76
|
+
callerText: fs.readFileSync(
|
|
77
|
+
path.join(callerRoot, options.callerWorkflow),
|
|
78
|
+
"utf8",
|
|
79
|
+
),
|
|
80
|
+
calleeText: fs.readFileSync(
|
|
81
|
+
path.join(calleeRoot, options.calleeWorkflow),
|
|
82
|
+
"utf8",
|
|
83
|
+
),
|
|
84
|
+
callerRepository: options.callerRepository,
|
|
85
|
+
callerWorkflowPath: options.callerWorkflow,
|
|
86
|
+
callerSha,
|
|
87
|
+
callerTree,
|
|
88
|
+
callerSourceState: callerStatus ? "diagnostic-dirty" : "clean",
|
|
89
|
+
calleeRepository: options.calleeRepository,
|
|
90
|
+
calleeWorkflowPath: options.calleeWorkflow,
|
|
91
|
+
calleeSha,
|
|
92
|
+
jobId: options.job,
|
|
93
|
+
trustedEventClasses: options.trustedEvents,
|
|
94
|
+
expectedContractRoot: options.expectedContractRoot || "",
|
|
95
|
+
});
|
|
96
|
+
if (options.output) {
|
|
97
|
+
const output = path.resolve(callerRoot, options.output);
|
|
98
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
99
|
+
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
|
100
|
+
}
|
|
101
|
+
return report;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function main(argv = process.argv.slice(2)) {
|
|
105
|
+
const command = argv.shift();
|
|
106
|
+
if (command !== "check") throw new Error(usage());
|
|
107
|
+
const options = parseArgs(argv);
|
|
108
|
+
required(options, [
|
|
109
|
+
"callerWorkflow",
|
|
110
|
+
"job",
|
|
111
|
+
"callerRepository",
|
|
112
|
+
"calleeRoot",
|
|
113
|
+
"calleeWorkflow",
|
|
114
|
+
"calleeRepository",
|
|
115
|
+
]);
|
|
116
|
+
if (!options.trustedEvents.length)
|
|
117
|
+
throw new Error("at least one --trusted-event is required");
|
|
118
|
+
const report = checkWorkflowCall(options);
|
|
119
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
120
|
+
if (!report.ok) process.exitCode = 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (
|
|
124
|
+
process.argv[1] &&
|
|
125
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
126
|
+
) {
|
|
127
|
+
try {
|
|
128
|
+
main();
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error(`workflow call contract: ${error.message}`);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
}
|
|
133
|
+
}
|