@patronage/factory-ci 0.2.1 → 1.0.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -3
- package/dist/index.d.ts +522 -2
- package/dist/index.js +1530 -35
- package/package.json +6 -6
- package/src/bundle-alchemy-entry.ts +94 -1
- package/src/candidate-lifecycle.ts +29 -0
- package/src/factory-workflow.ts +27 -28
- package/src/github-app-token.ts +162 -0
- package/src/index.ts +80 -0
- package/src/pinned-action.ts +30 -0
- package/src/production-impact-workflow.ts +109 -0
- package/src/proof-reuse-gate.ts +141 -10
- package/src/proof-reuse-presentation.ts +125 -0
- package/src/push-identity-workflow.ts +448 -0
- package/src/vitest-profile-reader.test.ts +208 -0
- package/src/vitest-profile-reader.ts +220 -0
- package/src/vitest-profile.ts +631 -0
- package/src/workflow-shell-lint.ts +462 -0
package/dist/index.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { build } from "esbuild";
|
|
5
|
-
import {
|
|
5
|
+
import { createSign, randomUUID } from "node:crypto";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { once } from "node:events";
|
|
9
|
+
import { arch, availableParallelism, cpus, platform, release, totalmem } from "node:os";
|
|
10
|
+
import { performance } from "node:perf_hooks";
|
|
6
11
|
//#region src/actions.ts
|
|
7
12
|
/**
|
|
8
13
|
* The canonical Node 24 family shared by factory-project workflows.
|
|
@@ -27,6 +32,32 @@ const NODE_PNPM_ACTION_FAMILY_NODE24 = {
|
|
|
27
32
|
}
|
|
28
33
|
};
|
|
29
34
|
//#endregion
|
|
35
|
+
//#region src/candidate-lifecycle.ts
|
|
36
|
+
/**
|
|
37
|
+
* Pull-request events that can create or refresh a Factory candidate run.
|
|
38
|
+
*
|
|
39
|
+
* GitHub's draft boolean remains the lifecycle authority: these triggers make
|
|
40
|
+
* a run visible, while `factoryCandidateOrPushCondition` keeps substantive
|
|
41
|
+
* jobs idle until the pull request is a candidate.
|
|
42
|
+
*/
|
|
43
|
+
const FACTORY_CANDIDATE_PULL_REQUEST_TYPES = Object.freeze([
|
|
44
|
+
"opened",
|
|
45
|
+
"synchronize",
|
|
46
|
+
"reopened",
|
|
47
|
+
"ready_for_review"
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Build a GitHub Actions job condition for candidate PRs and merge-target
|
|
51
|
+
* pushes. The caller owns triggers, jobs, runners, permissions, and topology.
|
|
52
|
+
*/
|
|
53
|
+
const factoryCandidateOrPushCondition = (candidateCondition) => {
|
|
54
|
+
return ["github.event_name == 'push'", `(${[
|
|
55
|
+
"github.event_name == 'pull_request'",
|
|
56
|
+
"github.event.pull_request.draft != true",
|
|
57
|
+
...candidateCondition ? [candidateCondition] : []
|
|
58
|
+
].join(" && ")})`].join(" || ");
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
30
61
|
//#region src/bundle-alchemy-entry.ts
|
|
31
62
|
/**
|
|
32
63
|
* Alchemy and Effect must resolve from the *consumer's* node_modules at run
|
|
@@ -40,6 +71,50 @@ const ALCHEMY_EXTERNALS = [
|
|
|
40
71
|
"effect/*"
|
|
41
72
|
];
|
|
42
73
|
/**
|
|
74
|
+
* The package names behind `ALCHEMY_EXTERNALS`, derived rather than restated so
|
|
75
|
+
* a future external can never be guarded by only one of two lists.
|
|
76
|
+
*/
|
|
77
|
+
const RESERVED_ALIAS_PACKAGES = [...new Set(ALCHEMY_EXTERNALS.map((external) => external.replace(/\/\*$/u, "").toLowerCase()))];
|
|
78
|
+
const reservedAliasKey = (key) => RESERVED_ALIAS_PACKAGES.find((name) => key === name || key.startsWith(`${name}/`));
|
|
79
|
+
/**
|
|
80
|
+
* Refuse an alias *key* that is itself a reserved package or one of its
|
|
81
|
+
* subpaths. esbuild substitutes aliases before it decides what is external, so
|
|
82
|
+
* such a key defeats `external` outright. Keys are compared, never resolved,
|
|
83
|
+
* which is what makes string matching sound here.
|
|
84
|
+
*/
|
|
85
|
+
const assertAliasKeysAreAdmissible = (alias) => {
|
|
86
|
+
for (const key of Object.keys(alias)) {
|
|
87
|
+
const reserved = reservedAliasKey(key);
|
|
88
|
+
if (reserved) throw new Error(`bundleAlchemyEntry cannot alias "${key}": ${reserved} must stay external because its identity is shared with the consumer's runtime, and esbuild applies aliases before external matching. Point the consumer's own resolution at one copy instead.`);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Whether a bundled input lives inside a reserved package.
|
|
93
|
+
*
|
|
94
|
+
* Metafile inputs are paths esbuild resolved and normalized itself, so
|
|
95
|
+
* comparing whole segments here answers what was *bundled* rather than how the
|
|
96
|
+
* config was spelled. Segments are compared case-insensitively: on a
|
|
97
|
+
* case-insensitive filesystem `node_modules/Effect/…` resolves to the real
|
|
98
|
+
* package and the metafile keeps the caller's spelling.
|
|
99
|
+
*/
|
|
100
|
+
const isReservedInput = (input) => {
|
|
101
|
+
const segments = input.toLowerCase().split(/[/\\]+/u);
|
|
102
|
+
return segments.some((segment, position) => position > 0 && segments[position - 1] === "node_modules" && RESERVED_ALIAS_PACKAGES.includes(segment));
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Assert the externals contract against the bundle esbuild actually produced.
|
|
106
|
+
*
|
|
107
|
+
* Checking alias *values* instead was unsound by construction: `..` segments,
|
|
108
|
+
* symlinks, and every other spelling of the same file each need another string
|
|
109
|
+
* rule, and the scanner loses. The metafile records the inputs after esbuild's
|
|
110
|
+
* own resolution and normalization, so one check closes the whole class —
|
|
111
|
+
* whatever route reached an identity-sensitive package, it shows up here.
|
|
112
|
+
*/
|
|
113
|
+
const assertNoReservedInputs = (inputs) => {
|
|
114
|
+
const offenders = inputs.filter(isReservedInput);
|
|
115
|
+
if (offenders.length > 0) throw new Error(`bundleAlchemyEntry refused a bundle carrying a second copy of an identity-sensitive package: ${offenders.join(", ")}. Those packages must resolve from the consumer's runtime, so nothing may pull their files into the bundle — an alias that re-exports them by bare specifier stays external and is fine.`);
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
43
118
|
* Pre-bundle an Alchemy entry to a single ESM file, keeping `alchemy` and
|
|
44
119
|
* `effect` external (#268).
|
|
45
120
|
*
|
|
@@ -53,22 +128,26 @@ const ALCHEMY_EXTERNALS = [
|
|
|
53
128
|
* Returns the absolute path of the file written.
|
|
54
129
|
*/
|
|
55
130
|
const bundleAlchemyEntry = async (options) => {
|
|
131
|
+
if (options.alias) assertAliasKeysAreAdmissible(options.alias);
|
|
56
132
|
const root = options.absWorkingDir ? path.resolve(options.absWorkingDir) : process.cwd();
|
|
57
133
|
const outfile = path.resolve(root, options.outfile);
|
|
58
134
|
await mkdir(path.dirname(outfile), { recursive: true });
|
|
59
|
-
await build({
|
|
135
|
+
const result = await build({
|
|
60
136
|
absWorkingDir: root,
|
|
61
137
|
bundle: true,
|
|
62
138
|
entryPoints: [path.resolve(root, options.entry)],
|
|
63
139
|
external: ALCHEMY_EXTERNALS,
|
|
64
140
|
format: "esm",
|
|
141
|
+
metafile: true,
|
|
65
142
|
outfile,
|
|
66
143
|
packages: options.packages ?? "external",
|
|
67
144
|
platform: "node",
|
|
68
145
|
sourcemap: options.sourcemap ?? false,
|
|
69
146
|
target: options.target ?? "node24",
|
|
147
|
+
...options.alias ? { alias: { ...options.alias } } : {},
|
|
70
148
|
...options.tsconfig ? { tsconfig: path.resolve(root, options.tsconfig) } : {}
|
|
71
149
|
});
|
|
150
|
+
assertNoReservedInputs(Object.keys(result.metafile.inputs));
|
|
72
151
|
return outfile;
|
|
73
152
|
};
|
|
74
153
|
//#endregion
|
|
@@ -122,9 +201,18 @@ const localPreviewStage = (options) => {
|
|
|
122
201
|
return `local-pr-${pr}-${normalizedHead.slice(0, shaLength)}`;
|
|
123
202
|
};
|
|
124
203
|
//#endregion
|
|
125
|
-
//#region src/
|
|
204
|
+
//#region src/pinned-action.ts
|
|
126
205
|
const PINNED_ACTION_PATTERN = /^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/u;
|
|
127
206
|
const ACTION_TAG_PATTERN = /^v\d+(?:\.\d+){0,2}$/u;
|
|
207
|
+
const actionRepository = (action) => action.uses.slice(0, action.uses.indexOf("@"));
|
|
208
|
+
/** Shared validator for every action pin emitted by factory-ci helpers. */
|
|
209
|
+
const assertPinnedAction = (name, action, expectedRepository) => {
|
|
210
|
+
if (!PINNED_ACTION_PATTERN.test(action.uses)) throw new Error(`${name} must use owner/repository@<40-character commit SHA>, got "${action.uses}".`);
|
|
211
|
+
if (expectedRepository && actionRepository(action) !== expectedRepository) throw new Error(`${name} must pin ${expectedRepository}, got ${actionRepository(action)}.`);
|
|
212
|
+
if (!ACTION_TAG_PATTERN.test(action.tag)) throw new Error(`${name} tag must be vN, vN.N, or vN.N.N, got "${action.tag}".`);
|
|
213
|
+
};
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/factory-workflow.ts
|
|
128
216
|
const RESERVED_ACTION_NAMES = new Set([
|
|
129
217
|
"checkout",
|
|
130
218
|
"setupNode",
|
|
@@ -133,12 +221,6 @@ const RESERVED_ACTION_NAMES = new Set([
|
|
|
133
221
|
const assertSingleLine = (name, value) => {
|
|
134
222
|
if (value.trim() !== value || value.length === 0 || /[\r\n]/u.test(value)) throw new Error(`${name} must be a single non-empty line.`);
|
|
135
223
|
};
|
|
136
|
-
const actionRepository = (action) => action.uses.slice(0, action.uses.indexOf("@"));
|
|
137
|
-
const assertPinnedAction = (name, action, expectedRepository) => {
|
|
138
|
-
if (!PINNED_ACTION_PATTERN.test(action.uses)) throw new Error(`${name} must use owner/repository@<40-character commit SHA>, got "${action.uses}".`);
|
|
139
|
-
if (expectedRepository && actionRepository(action) !== expectedRepository) throw new Error(`${name} must pin ${expectedRepository}, got ${actionRepository(action)}.`);
|
|
140
|
-
if (!ACTION_TAG_PATTERN.test(action.tag)) throw new Error(`${name} tag must be vN, vN.N, or vN.N.N, got "${action.tag}".`);
|
|
141
|
-
};
|
|
142
224
|
const usesStep = (fallbackName, action) => ({
|
|
143
225
|
name: fallbackName,
|
|
144
226
|
uses: action.uses
|
|
@@ -155,15 +237,26 @@ const assertAdditionalActions = (actions) => {
|
|
|
155
237
|
assertPinnedAction(name, action);
|
|
156
238
|
}
|
|
157
239
|
};
|
|
240
|
+
const checkoutInputs = (checkout) => {
|
|
241
|
+
if (checkout === void 0) return;
|
|
242
|
+
const inputs = {};
|
|
243
|
+
if (checkout.fetchDepth !== void 0) {
|
|
244
|
+
if (!Number.isSafeInteger(checkout.fetchDepth) || checkout.fetchDepth < 0) throw new Error("checkout.fetchDepth must be a non-negative integer.");
|
|
245
|
+
inputs["fetch-depth"] = String(checkout.fetchDepth);
|
|
246
|
+
}
|
|
247
|
+
if (checkout.ref) inputs.ref = checkout.ref;
|
|
248
|
+
return Object.keys(inputs).length > 0 ? inputs : void 0;
|
|
249
|
+
};
|
|
158
250
|
const setupSteps = (family, setup = {}) => {
|
|
159
251
|
const installRun = setup.install?.run ?? "pnpm install --frozen-lockfile";
|
|
160
252
|
if (/(?:^|\s)--ignore-scripts(?:\s|$)/u.test(installRun)) throw new Error("The install step must not use --ignore-scripts; workspace prepare scripts build required package artifacts.");
|
|
161
253
|
const { checkout, setupNode } = setup;
|
|
254
|
+
const checkoutWith = checkoutInputs(checkout);
|
|
162
255
|
return Object.freeze([
|
|
163
256
|
{
|
|
164
257
|
name: checkout?.name ?? "Checkout",
|
|
165
258
|
uses: family.checkout.uses,
|
|
166
|
-
...
|
|
259
|
+
...checkoutWith ? { with: checkoutWith } : {}
|
|
167
260
|
},
|
|
168
261
|
usesStep("Setup pnpm", family.setupPnpm),
|
|
169
262
|
{
|
|
@@ -210,6 +303,95 @@ const factoryWorkflow = (options) => {
|
|
|
210
303
|
});
|
|
211
304
|
};
|
|
212
305
|
//#endregion
|
|
306
|
+
//#region src/github-app-token.ts
|
|
307
|
+
/**
|
|
308
|
+
* Minting a GitHub App installation token: the RS256 app JWT, the optional
|
|
309
|
+
* installation lookup, and the token exchange (#617).
|
|
310
|
+
*
|
|
311
|
+
* Two projects had grown the same three steps independently — the factory's
|
|
312
|
+
* check-run publisher and paitronage's proof-comment publisher — which is the
|
|
313
|
+
* admitted-on-repetition bar. Only the *mechanism* lives here. Where the
|
|
314
|
+
* private key comes from, how the app id is configured, and what the token is
|
|
315
|
+
* then used for stay with each consumer: this module is handed credentials and
|
|
316
|
+
* returns a token.
|
|
317
|
+
*/
|
|
318
|
+
/** The default request budget, matching the factory's other GitHub writes. */
|
|
319
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
320
|
+
/** Nine-minute JWT lifetime, backdated a minute against runner clock skew. */
|
|
321
|
+
const JWT_BACKDATE_SECONDS = 60;
|
|
322
|
+
const JWT_LIFETIME_SECONDS = 600;
|
|
323
|
+
/** Carries the HTTP status so a caller can tell a retryable failure apart. */
|
|
324
|
+
var GitHubApiError = class extends Error {
|
|
325
|
+
status;
|
|
326
|
+
constructor(status, statusText) {
|
|
327
|
+
super(`GitHub API ${status} ${statusText}`);
|
|
328
|
+
this.name = "GitHubApiError";
|
|
329
|
+
this.status = status;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const base64url = (value) => Buffer.from(value).toString("base64url");
|
|
333
|
+
/**
|
|
334
|
+
* The signed app JWT GitHub accepts as `Authorization: Bearer` for the App
|
|
335
|
+
* endpoints. `iss` is stringified because GitHub accepts either spelling and a
|
|
336
|
+
* numeric app id must not depend on JSON's number formatting.
|
|
337
|
+
*
|
|
338
|
+
* The signature is produced from the key on disk and returned; the key
|
|
339
|
+
* material itself never leaves this call.
|
|
340
|
+
*/
|
|
341
|
+
const githubAppJwt = (credentials, options = {}) => {
|
|
342
|
+
const nowMs = (options.now ?? Date.now)();
|
|
343
|
+
const issuedAt = Math.floor(nowMs / 1e3) - JWT_BACKDATE_SECONDS;
|
|
344
|
+
const unsigned = `${base64url(JSON.stringify({
|
|
345
|
+
alg: "RS256",
|
|
346
|
+
typ: "JWT"
|
|
347
|
+
}))}.${base64url(JSON.stringify({
|
|
348
|
+
exp: issuedAt + JWT_LIFETIME_SECONDS,
|
|
349
|
+
iat: issuedAt,
|
|
350
|
+
iss: String(credentials.appId)
|
|
351
|
+
}))}`;
|
|
352
|
+
const readKey = options.readPrivateKey ?? readFileSync;
|
|
353
|
+
const signer = createSign("RSA-SHA256");
|
|
354
|
+
signer.update(unsigned);
|
|
355
|
+
signer.end();
|
|
356
|
+
return `${unsigned}.${signer.sign(readKey(credentials.privateKeyPath), "base64url")}`;
|
|
357
|
+
};
|
|
358
|
+
const githubAppJson = async (request, url, jwt, method, timeoutMs) => {
|
|
359
|
+
const response = await request(url, {
|
|
360
|
+
headers: {
|
|
361
|
+
Accept: "application/vnd.github+json",
|
|
362
|
+
Authorization: `Bearer ${jwt}`,
|
|
363
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
364
|
+
},
|
|
365
|
+
method,
|
|
366
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
367
|
+
});
|
|
368
|
+
if (!response.ok) throw new GitHubApiError(response.status, response.statusText);
|
|
369
|
+
return await response.json();
|
|
370
|
+
};
|
|
371
|
+
/**
|
|
372
|
+
* Mint an installation access token for one repository.
|
|
373
|
+
*
|
|
374
|
+
* When the credentials omit `installationId`, the installation is discovered
|
|
375
|
+
* from the repository first — the same call every consumer had written for
|
|
376
|
+
* itself. Nothing is cached: the token is returned to the caller and this
|
|
377
|
+
* module keeps no copy.
|
|
378
|
+
*/
|
|
379
|
+
const mintInstallationToken = async (input, options = {}) => {
|
|
380
|
+
const { credentials } = input;
|
|
381
|
+
const request = options.fetch ?? fetch;
|
|
382
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
383
|
+
const jwt = githubAppJwt(credentials, options);
|
|
384
|
+
let { installationId } = credentials;
|
|
385
|
+
if (installationId === void 0) {
|
|
386
|
+
const installation = await githubAppJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/installation`, jwt, "GET", timeoutMs);
|
|
387
|
+
if (typeof installation.id !== "number") throw new TypeError("GitHub App installation response omitted id");
|
|
388
|
+
installationId = installation.id;
|
|
389
|
+
}
|
|
390
|
+
const minted = await githubAppJson(request, `https://api.github.com/app/installations/${installationId}/access_tokens`, jwt, "POST", timeoutMs);
|
|
391
|
+
if (typeof minted.token !== "string") throw new TypeError("GitHub App token response omitted token");
|
|
392
|
+
return minted.token;
|
|
393
|
+
};
|
|
394
|
+
//#endregion
|
|
213
395
|
//#region src/execute-alchemy-entry.ts
|
|
214
396
|
/**
|
|
215
397
|
* Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
|
|
@@ -261,9 +443,9 @@ const executeAlchemyEntry = async (options) => {
|
|
|
261
443
|
* Escaped so the emitted text carries a GitHub Actions expression rather than
|
|
262
444
|
* this file carrying a JavaScript template hole.
|
|
263
445
|
*/
|
|
264
|
-
const githubExpression = (expression) => `\${{ ${expression} }}`;
|
|
446
|
+
const githubExpression$1 = (expression) => `\${{ ${expression} }}`;
|
|
265
447
|
/** A braced shell expansion that has to survive TypeScript interpolation. */
|
|
266
|
-
const shellExpansion = (expression) => `\${${expression}}`;
|
|
448
|
+
const shellExpansion$1 = (expression) => `\${${expression}}`;
|
|
267
449
|
/**
|
|
268
450
|
* Single-quote a value for the emitted script. Every non-literal value that
|
|
269
451
|
* reaches the script goes through this.
|
|
@@ -293,6 +475,13 @@ const FACTORY_PROOF_GATE_APP_ID = "4314840";
|
|
|
293
475
|
/** Step id the guard condition refers to. */
|
|
294
476
|
const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
|
|
295
477
|
/**
|
|
478
|
+
* Human-visible name of the gate step as the Actions jobs API serves it. A
|
|
479
|
+
* read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
|
|
480
|
+
* classify a run as proof-reuse versus full fallback, so it is exported from
|
|
481
|
+
* exactly the module that writes it — matching on a re-typed copy would drift.
|
|
482
|
+
*/
|
|
483
|
+
const FACTORY_PROOF_GATE_STEP_NAME = "Check for factory proof of this head";
|
|
484
|
+
/**
|
|
296
485
|
* The shell the gate runs under, and it is a correctness requirement rather
|
|
297
486
|
* than a preference.
|
|
298
487
|
*
|
|
@@ -329,6 +518,8 @@ const FACTORY_PROOF_GATE_REASON_OUTPUT = "reason";
|
|
|
329
518
|
* proof that executed every command the surface requires is reusable.
|
|
330
519
|
*/
|
|
331
520
|
const FACTORY_PROOF_GATE_MODE_OUTPUT = "mode";
|
|
521
|
+
/** Exact Checks API URL of the proof generation selected by the gate. */
|
|
522
|
+
const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
|
|
332
523
|
/**
|
|
333
524
|
* Guard for every step the gate protects. Deliberately `!= 'true'` and not
|
|
334
525
|
* `== 'false'`: an unset, empty, or garbled output must run the suite.
|
|
@@ -349,7 +540,8 @@ const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
|
|
|
349
540
|
* - `pending` the newest generation had not completed when this job read it
|
|
350
541
|
* - `failed` the newest generation records no pass
|
|
351
542
|
* - `unreadable` it passed but carries no binding for this repository and head
|
|
352
|
-
* - `incomplete`
|
|
543
|
+
* - `incomplete` its executed plus stamp-authorized released commands do not
|
|
544
|
+
* cover every required command
|
|
353
545
|
* - `ambiguous` two newest generations share the greatest start time
|
|
354
546
|
* - `error` the gate could not reach a decision (fail open)
|
|
355
547
|
*
|
|
@@ -383,6 +575,13 @@ const FACTORY_PROOF_GATE_REASONS = [
|
|
|
383
575
|
*/
|
|
384
576
|
const COMMAND_IDENTITY = /^\w[\w.:@/-]*$/u;
|
|
385
577
|
const COMMAND_IDENTITY_MAX_LENGTH = 120;
|
|
578
|
+
/**
|
|
579
|
+
* The impact-stamp interpretation the paired factory packages currently
|
|
580
|
+
* share. A release recorded under any other version is an unmodelled input and
|
|
581
|
+
* cannot subtract hosted work. This is intentionally fail-closed and moves in
|
|
582
|
+
* lockstep with the proof/check-payload producer.
|
|
583
|
+
*/
|
|
584
|
+
const TRUSTED_IMPACT_STAMP_VERSION = 3;
|
|
386
585
|
const isProofReuseCommand = (value) => {
|
|
387
586
|
if (!(value && typeof value === "object")) return false;
|
|
388
587
|
const entry = value;
|
|
@@ -411,8 +610,46 @@ const proofReuseRequiredCommands = (commands) => {
|
|
|
411
610
|
return names.toSorted();
|
|
412
611
|
};
|
|
413
612
|
/**
|
|
613
|
+
* Resolve command *identities* to their `ProofReuseCommand` objects (ADR
|
|
614
|
+
* 0021).
|
|
615
|
+
*
|
|
616
|
+
* Selection by name is deliberately a consumer decision (proof-surfaces.ts in
|
|
617
|
+
* software-factory-hq, the `paitronage:verify` filter in paitronage's
|
|
618
|
+
* `verify.ts`): only the consumer knows which named commands a guarded
|
|
619
|
+
* surface requires. What both of those implementations independently
|
|
620
|
+
* hand-rolled is the same lookup — find each name in the profile's command
|
|
621
|
+
* catalog, and refuse to silently shrink the required set when a name has no
|
|
622
|
+
* entry. That lookup is what this function is: the mechanic, not the
|
|
623
|
+
* selection.
|
|
624
|
+
*
|
|
625
|
+
* Throwing at generation time (rather than returning `undefined` or an empty
|
|
626
|
+
* array) is deliberate: a name with no catalog entry is a mistake in the
|
|
627
|
+
* generator source, not a runtime condition a consumer should have to check
|
|
628
|
+
* for, and a required set that quietly loses an entry is exactly what makes a
|
|
629
|
+
* passing proof trivially "covering".
|
|
630
|
+
*
|
|
631
|
+
* `selectionLabel` names the failure, nothing else: it is not part of the
|
|
632
|
+
* selection this function resolves, only prose a consumer supplies for its
|
|
633
|
+
* own thrown error (e.g. HQ's surface name, "core" or "docs"). The message
|
|
634
|
+
* deliberately says "the profile's command catalog" rather than naming
|
|
635
|
+
* `software-factory.profile.json`: a fleet-generic library must not assume
|
|
636
|
+
* every consumer's catalog is that exact file, so this wording differs
|
|
637
|
+
* on purpose from the HQ-local message it replaced.
|
|
638
|
+
*/
|
|
639
|
+
const resolveProofReuseCommands = (catalog, names, selectionLabel) => names.map((name) => {
|
|
640
|
+
const command = catalog.find((entry) => entry.name === name);
|
|
641
|
+
if (!command) {
|
|
642
|
+
const location = selectionLabel ? `Proof-reuse selection for the ${selectionLabel} surface names` : "Proof-reuse selection names";
|
|
643
|
+
throw new Error(`${location} "${name}", which the profile's command catalog does not define.`);
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
command: command.command,
|
|
647
|
+
name: command.name
|
|
648
|
+
};
|
|
649
|
+
});
|
|
650
|
+
/**
|
|
414
651
|
* jq program: every page of the Checks API result in, three sanitized lines
|
|
415
|
-
* (`reason`, `mode`,
|
|
652
|
+
* (`reason`, `mode`, uncovered commands) out.
|
|
416
653
|
*
|
|
417
654
|
* The input is what `gh api --paginate` actually writes: the pages
|
|
418
655
|
* *concatenated* as a stream of top-level response objects, not merged into
|
|
@@ -469,6 +706,20 @@ def startedAt: (.started_at // "") | tostring;
|
|
|
469
706
|
def rankable:
|
|
470
707
|
test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$");
|
|
471
708
|
|
|
709
|
+
def nonemptyString:
|
|
710
|
+
type == "string" and length > 0;
|
|
711
|
+
|
|
712
|
+
def distinctNames:
|
|
713
|
+
(map(.name) | length) == (map(.name) | unique | length);
|
|
714
|
+
|
|
715
|
+
def releaseAuthorized($release; $commands; $stamp):
|
|
716
|
+
([ $commands[] | select(.name == $release.name) ]) as $commandRows
|
|
717
|
+
| ([ $stamp.targets[] | select(.name == $release.impactTarget) ]) as $targetRows
|
|
718
|
+
| ($commandRows | length) == 1
|
|
719
|
+
and $commandRows[0].impactTarget == $release.impactTarget
|
|
720
|
+
and ($targetRows | length) == 1
|
|
721
|
+
and $targetRows[0].impact == "not-affected";
|
|
722
|
+
|
|
472
723
|
[ .[]
|
|
473
724
|
| (.check_runs // [])[]
|
|
474
725
|
| select(.name == $name)
|
|
@@ -476,20 +727,60 @@ def rankable:
|
|
|
476
727
|
| { started: startedAt,
|
|
477
728
|
status: (.status // ""),
|
|
478
729
|
conclusion: (.conclusion // ""),
|
|
730
|
+
url: ((.html_url // "") | tostring),
|
|
479
731
|
binding: binding }
|
|
480
732
|
] as $runs
|
|
481
733
|
| ($runs | map(.started | rankable) | all) as $orderable
|
|
482
734
|
| ($runs | map(.started | sub("\\.[0-9]+Z$"; "Z")) | max) as $newest
|
|
483
735
|
| [ $runs[] | select((.started | sub("\\.[0-9]+Z$"; "Z")) == $newest) ] as $generation
|
|
484
|
-
| (if ($runs | length) == 0 then ["none", "", ""]
|
|
485
|
-
elif ($orderable | not) then ["ambiguous", "", ""]
|
|
486
|
-
elif ($generation | length) != 1 then ["ambiguous", "", ""]
|
|
736
|
+
| (if ($runs | length) == 0 then ["none", "", "", ""]
|
|
737
|
+
elif ($orderable | not) then ["ambiguous", "", "", ""]
|
|
738
|
+
elif ($generation | length) != 1 then ["ambiguous", "", "", ""]
|
|
487
739
|
else
|
|
488
740
|
$generation[0] as $run
|
|
489
741
|
| (if ($run.binding | type) == "object" then $run.binding else {} end) as $proof
|
|
490
742
|
| (($proof.mode // "") | tostring) as $mode
|
|
491
743
|
| (($proof.executedCommands // []) | map(select(type == "string"))) as $executed
|
|
492
|
-
| ($
|
|
744
|
+
| ($proof | has("notRequiredCommands")) as $hasReleases
|
|
745
|
+
| (if $hasReleases then $proof.notRequiredCommands else [] end) as $releases
|
|
746
|
+
| ($proof.verificationCommands // null) as $commands
|
|
747
|
+
| ($proof.impactStamp // null) as $stamp
|
|
748
|
+
| (if ($hasReleases | not) then true
|
|
749
|
+
else
|
|
750
|
+
($proof.proofSchemaVersion == 4)
|
|
751
|
+
and ($releases | type) == "array"
|
|
752
|
+
and ($releases | length) > 0
|
|
753
|
+
and ($releases | all(.[];
|
|
754
|
+
type == "object"
|
|
755
|
+
and (.name | nonemptyString)
|
|
756
|
+
and (.impactTarget | nonemptyString)
|
|
757
|
+
and (.basis | nonemptyString)))
|
|
758
|
+
and ($releases | distinctNames)
|
|
759
|
+
and ($commands | type) == "array"
|
|
760
|
+
and ($commands | all(.[];
|
|
761
|
+
type == "object"
|
|
762
|
+
and (.name | nonemptyString)
|
|
763
|
+
and ((has("impactTarget") | not) or (.impactTarget | nonemptyString))))
|
|
764
|
+
and ($commands | distinctNames)
|
|
765
|
+
and ($stamp | type) == "object"
|
|
766
|
+
and ($stamp.stampVersion == ${TRUSTED_IMPACT_STAMP_VERSION})
|
|
767
|
+
and ($stamp.basis == "target-scoped")
|
|
768
|
+
and (($stamp.reasons | type) == "array")
|
|
769
|
+
and ($stamp.reasons | all(.[]; type == "string"))
|
|
770
|
+
and (($stamp.unsubscribedPaths | type) == "array")
|
|
771
|
+
and ($stamp.unsubscribedPaths | all(.[]; type == "string"))
|
|
772
|
+
and (($stamp.targets | type) == "array")
|
|
773
|
+
and ($stamp.targets | all(.[];
|
|
774
|
+
type == "object"
|
|
775
|
+
and (.name | nonemptyString)
|
|
776
|
+
and (.basis | nonemptyString)
|
|
777
|
+
and ((.impact == "affected") or (.impact == "not-affected"))))
|
|
778
|
+
and ($stamp.targets | distinctNames)
|
|
779
|
+
and (([$releases[].name] - $executed | length) == ($releases | length))
|
|
780
|
+
and ($releases | all(.[]; releaseAuthorized(.; $commands; $stamp)))
|
|
781
|
+
end) as $releasesValid
|
|
782
|
+
| (if $releasesValid then [$releases[].name] else [] end) as $released
|
|
783
|
+
| ($required - $executed - $released) as $missing
|
|
493
784
|
| (if $run.status != "completed" then "pending"
|
|
494
785
|
elif $run.conclusion != "success" then "failed"
|
|
495
786
|
elif (($run.binding | type) != "object")
|
|
@@ -499,10 +790,11 @@ def rankable:
|
|
|
499
790
|
or ($proof.repository != $repository) then "unreadable"
|
|
500
791
|
elif $proof.outcome != "passed" then "failed"
|
|
501
792
|
elif ($required | length) == 0 then "incomplete"
|
|
793
|
+
elif ($releasesValid | not) then "incomplete"
|
|
502
794
|
elif ($missing | length) != 0 then "incomplete"
|
|
503
795
|
else "proven"
|
|
504
796
|
end) as $reason
|
|
505
|
-
| [$reason, $mode, ($missing | join(", "))]
|
|
797
|
+
| [$reason, $mode, ($missing | join(", ")), $run.url]
|
|
506
798
|
end)
|
|
507
799
|
| map(gsub("[\\r\\n\\t]"; " "))
|
|
508
800
|
| join("\n")
|
|
@@ -527,9 +819,9 @@ const CORRECTIVE_LINE = "Run `psf pr:verify` before publishing to reuse local pr
|
|
|
527
819
|
const UNUSABLE_SELECTION_SCRIPT = String.raw`{
|
|
528
820
|
printf '${FACTORY_PROOF_GATE_OUTPUT}=false\n'
|
|
529
821
|
printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=error\n'
|
|
530
|
-
} >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"`;
|
|
822
|
+
} >> "${shellExpansion$1("GITHUB_OUTPUT:-/dev/null")}"`;
|
|
531
823
|
/** Surface labels reach markdown, so only a plain, bounded label survives. */
|
|
532
|
-
const safeLabel = (surface) => {
|
|
824
|
+
const safeLabel$1 = (surface) => {
|
|
533
825
|
const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
|
|
534
826
|
return cleaned.length > 0 ? cleaned : "verification";
|
|
535
827
|
};
|
|
@@ -545,14 +837,15 @@ reason=error
|
|
|
545
837
|
detail=''
|
|
546
838
|
mode=''
|
|
547
839
|
missing=''
|
|
840
|
+
source_url=''
|
|
548
841
|
|
|
549
842
|
# filter=all with full pagination is load-bearing (ADR 0022). GitHub's
|
|
550
843
|
# default "latest" filter is ordered by completion, so a newer generation
|
|
551
844
|
# that is still running can be hidden behind an older completed one — the
|
|
552
845
|
# gate would then read a stale pass as current.
|
|
553
|
-
if [ -z "${shellExpansion("HEAD_SHA:-")}" ]; then
|
|
846
|
+
if [ -z "${shellExpansion$1("HEAD_SHA:-")}" ]; then
|
|
554
847
|
detail='no pull request head SHA'
|
|
555
|
-
elif [ -z "${shellExpansion("GITHUB_REPOSITORY:-")}" ]; then
|
|
848
|
+
elif [ -z "${shellExpansion$1("GITHUB_REPOSITORY:-")}" ]; then
|
|
556
849
|
detail='no repository name'
|
|
557
850
|
elif ! response=$(gh api --method GET --paginate \
|
|
558
851
|
"repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs" \
|
|
@@ -583,6 +876,7 @@ else
|
|
|
583
876
|
IFS= read -r reason || :
|
|
584
877
|
IFS= read -r mode || :
|
|
585
878
|
IFS= read -r missing || :
|
|
879
|
+
IFS= read -r source_url || :
|
|
586
880
|
} <<< "$finding"
|
|
587
881
|
case "$reason" in
|
|
588
882
|
proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
|
|
@@ -609,8 +903,11 @@ fi
|
|
|
609
903
|
# line so it cannot restructure the summary it is written into.
|
|
610
904
|
detail=$(printf '%s' "$detail" | tr '\n\r\t' ' ' | cut -c1-240)
|
|
611
905
|
missing=$(printf '%s' "$missing" | tr '\n\r\t' ' ' | cut -c1-240)
|
|
906
|
+
if ! [[ "$source_url" =~ ^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/runs/[0-9]+$ ]]; then
|
|
907
|
+
source_url=''
|
|
908
|
+
fi
|
|
612
909
|
|
|
613
|
-
SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
|
|
910
|
+
SUMMARY="${shellExpansion$1("GITHUB_STEP_SUMMARY:-/dev/null")}"
|
|
614
911
|
say() { printf '%s\n' "$1" >> "$SUMMARY"; }
|
|
615
912
|
|
|
616
913
|
say "## Proof reuse: $SURFACE"
|
|
@@ -621,7 +918,11 @@ case "$reason" in
|
|
|
621
918
|
echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
|
|
622
919
|
say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
|
|
623
920
|
say ''
|
|
624
|
-
|
|
921
|
+
if [ -n "$source_url" ]; then
|
|
922
|
+
say "- Reused proof: [\`$CHECK_NAME\`]($source_url), published by the pinned factory GitHub App."
|
|
923
|
+
else
|
|
924
|
+
say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
|
|
925
|
+
fi
|
|
625
926
|
say "- Covers head: \`$HEAD_SHA\`"
|
|
626
927
|
say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
|
|
627
928
|
;;
|
|
@@ -656,7 +957,7 @@ case "$reason" in
|
|
|
656
957
|
incomplete)
|
|
657
958
|
say "Ran the full suite. The factory proof for this commit does not cover every command the $SURFACE surface requires."
|
|
658
959
|
say ''
|
|
659
|
-
say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its \`
|
|
960
|
+
say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its executed commands plus stamp-authorized \`notRequiredCommands\` do not cover this surface. Missing: \`$missing\`"
|
|
660
961
|
say ''
|
|
661
962
|
say '${CORRECTIVE_LINE}'
|
|
662
963
|
;;
|
|
@@ -678,7 +979,7 @@ esac
|
|
|
678
979
|
say ''
|
|
679
980
|
|
|
680
981
|
if [ "$verdict" != 'true' ]; then
|
|
681
|
-
echo "No reusable factory proof for ${shellExpansion("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
|
|
982
|
+
echo "No reusable factory proof for ${shellExpansion$1("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
|
|
682
983
|
fi
|
|
683
984
|
|
|
684
985
|
# The only write. A crash before this line leaves the output unset, the guard
|
|
@@ -687,7 +988,8 @@ fi
|
|
|
687
988
|
printf '${FACTORY_PROOF_GATE_OUTPUT}=%s\n' "$verdict"
|
|
688
989
|
printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=%s\n' "$reason"
|
|
689
990
|
printf '${FACTORY_PROOF_GATE_MODE_OUTPUT}=%s\n' "$mode"
|
|
690
|
-
}
|
|
991
|
+
printf '${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}=%s\n' "$source_url"
|
|
992
|
+
} >> "${shellExpansion$1("GITHUB_OUTPUT:-/dev/null")}"
|
|
691
993
|
`.trim();
|
|
692
994
|
/**
|
|
693
995
|
* The gate script. Exported so it can be executed directly under test against
|
|
@@ -703,7 +1005,7 @@ fi
|
|
|
703
1005
|
*/
|
|
704
1006
|
const factoryProofGateScript = ({ commands, surface }) => {
|
|
705
1007
|
const required = proofReuseRequiredCommands(commands);
|
|
706
|
-
return required ? gateScript(required, safeLabel(surface)) : UNUSABLE_SELECTION_SCRIPT;
|
|
1008
|
+
return required ? gateScript(required, safeLabel$1(surface)) : UNUSABLE_SELECTION_SCRIPT;
|
|
707
1009
|
};
|
|
708
1010
|
/**
|
|
709
1011
|
* The step itself, structurally accepted by gagen's `step()` without adding a
|
|
@@ -721,12 +1023,12 @@ const factoryProofGateScript = ({ commands, surface }) => {
|
|
|
721
1023
|
const factoryProofGateStep = (options) => Object.freeze({
|
|
722
1024
|
continueOnError: true,
|
|
723
1025
|
env: Object.freeze({
|
|
724
|
-
GH_TOKEN: githubExpression("secrets.GITHUB_TOKEN"),
|
|
725
|
-
HEAD_SHA: githubExpression("github.event.pull_request.head.sha")
|
|
1026
|
+
GH_TOKEN: githubExpression$1("secrets.GITHUB_TOKEN"),
|
|
1027
|
+
HEAD_SHA: githubExpression$1("github.event.pull_request.head.sha")
|
|
726
1028
|
}),
|
|
727
1029
|
id: FACTORY_PROOF_GATE_STEP_ID,
|
|
728
1030
|
if: FACTORY_PROOF_GATE_IF,
|
|
729
|
-
name:
|
|
1031
|
+
name: FACTORY_PROOF_GATE_STEP_NAME,
|
|
730
1032
|
run: factoryProofGateScript(options),
|
|
731
1033
|
shell: FACTORY_PROOF_GATE_SHELL
|
|
732
1034
|
});
|
|
@@ -754,9 +1056,1202 @@ const proofReuseCoverage = ({ commands, skipped }) => {
|
|
|
754
1056
|
const assertProofReuseCoverage = (input) => {
|
|
755
1057
|
const report = proofReuseCoverage(input);
|
|
756
1058
|
if (report.covered) return report;
|
|
757
|
-
const surface = safeLabel(input.surface ?? "");
|
|
1059
|
+
const surface = safeLabel$1(input.surface ?? "");
|
|
758
1060
|
const problem = report.requiredCommands.length === 0 ? "selects no usable profile commands, so any passing proof would trivially cover it" : `skips work no selected profile command runs: ${report.uncovered.join(", ")}`;
|
|
759
1061
|
throw new Error(`Proof-reuse coverage failed: the ${surface} surface ${problem}. Add the command to software-factory.profile.json (and to this surface's selection), or stop skipping it.`);
|
|
760
1062
|
};
|
|
761
1063
|
//#endregion
|
|
762
|
-
|
|
1064
|
+
//#region src/proof-reuse-presentation.ts
|
|
1065
|
+
/** Generic timing and presentation steps around the proof-reuse gate (#652). */
|
|
1066
|
+
const githubExpression = (expression) => `\${{ ${expression} }}`;
|
|
1067
|
+
const shellExpansion = (expression) => `\${${expression}}`;
|
|
1068
|
+
const safeLabel = (surface) => {
|
|
1069
|
+
const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
|
|
1070
|
+
return cleaned.length > 0 ? cleaned : "verification";
|
|
1071
|
+
};
|
|
1072
|
+
const FACTORY_PROOF_TIMING_STEP_ID = "ci-timing";
|
|
1073
|
+
const FACTORY_PROOF_TIMING_START_STEP_NAME = "Start CI timing";
|
|
1074
|
+
const FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME = "Record proof-reuse timing";
|
|
1075
|
+
const factoryProofTimingStartStep = () => Object.freeze({
|
|
1076
|
+
continueOnError: true,
|
|
1077
|
+
id: FACTORY_PROOF_TIMING_STEP_ID,
|
|
1078
|
+
name: FACTORY_PROOF_TIMING_START_STEP_NAME,
|
|
1079
|
+
run: String.raw`started_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
|
|
1080
|
+
printf 'started_ms=%s\n' "$started_ms" >> "$GITHUB_OUTPUT"`
|
|
1081
|
+
});
|
|
1082
|
+
const factoryProofReuseSummaryScript = ({ surface }) => {
|
|
1083
|
+
const label = safeLabel(surface);
|
|
1084
|
+
return String.raw`now_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
|
|
1085
|
+
ci_started_ms="${shellExpansion("CI_STARTED_MS:-")}"
|
|
1086
|
+
|
|
1087
|
+
SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
|
|
1088
|
+
say() { printf '%s\n' "$1" >> "$SUMMARY"; }
|
|
1089
|
+
|
|
1090
|
+
say '## ${label} timing'
|
|
1091
|
+
say ''
|
|
1092
|
+
|
|
1093
|
+
if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
|
|
1094
|
+
say '- Path: merge-target full execution'
|
|
1095
|
+
say '- Proof-reuse gate: not applicable on merge-target runs; the full suite ran.'
|
|
1096
|
+
say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
|
|
1097
|
+
echo "::notice title=Factory proof reuse::Proof reuse is not applicable on merge-target runs; the full ${label} suite executed."
|
|
1098
|
+
elif [ "${shellExpansion("PROOF_REUSED:-")}" = 'true' ]; then
|
|
1099
|
+
say '- Path: trusted local proof reused'
|
|
1100
|
+
if [ -n "${shellExpansion("PROOF_SOURCE_URL:-")}" ]; then
|
|
1101
|
+
say "- Reused proof: [exact source check](${shellExpansion("PROOF_SOURCE_URL")})"
|
|
1102
|
+
fi
|
|
1103
|
+
say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
|
|
1104
|
+
say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
|
|
1105
|
+
echo "::notice title=Factory proof reuse::Reused trusted local proof for ${label} at ${shellExpansion("PROOF_HEAD_SHA:-unknown")}."
|
|
1106
|
+
else
|
|
1107
|
+
say "- Path: full GitHub CI fallback (${shellExpansion("PROOF_REASON:-error")})"
|
|
1108
|
+
say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
|
|
1109
|
+
say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
|
|
1110
|
+
echo "::notice title=Factory proof reuse::No reusable proof for ${label}; the full hosted suite executed (${shellExpansion("PROOF_REASON:-error")})."
|
|
1111
|
+
fi
|
|
1112
|
+
|
|
1113
|
+
if [[ "$ci_started_ms" =~ ^[0-9]+$ ]]; then
|
|
1114
|
+
say "- GitHub workflow execution after the proof gate: $((now_ms - ci_started_ms)) ms"
|
|
1115
|
+
else
|
|
1116
|
+
say '- GitHub workflow execution after the proof gate: unavailable'
|
|
1117
|
+
fi
|
|
1118
|
+
say ''`;
|
|
1119
|
+
};
|
|
1120
|
+
const factoryProofReuseSummaryStep = (options) => Object.freeze({
|
|
1121
|
+
continueOnError: true,
|
|
1122
|
+
env: Object.freeze({
|
|
1123
|
+
CI_STARTED_MS: githubExpression(`steps.${FACTORY_PROOF_TIMING_STEP_ID}.outputs.started_ms`),
|
|
1124
|
+
PROOF_HEAD_SHA: githubExpression("github.event.pull_request.head.sha"),
|
|
1125
|
+
PROOF_MODE: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_MODE_OUTPUT}`),
|
|
1126
|
+
PROOF_REASON: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_REASON_OUTPUT}`),
|
|
1127
|
+
PROOF_REUSED: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT}`),
|
|
1128
|
+
PROOF_SOURCE_URL: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}`)
|
|
1129
|
+
}),
|
|
1130
|
+
if: "always()",
|
|
1131
|
+
name: FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME,
|
|
1132
|
+
run: factoryProofReuseSummaryScript(options)
|
|
1133
|
+
});
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/production-impact-workflow.ts
|
|
1136
|
+
const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
|
|
1137
|
+
const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
|
|
1138
|
+
const FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT = "basis";
|
|
1139
|
+
const FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT = "unsubscribed_paths";
|
|
1140
|
+
const OUTPUT_NAME_PATTERN = /[^a-z0-9_]+/gu;
|
|
1141
|
+
const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
1142
|
+
/** Stable GitHub-output key for one declared target. */
|
|
1143
|
+
const productionImpactTargetOutput = (targetName) => {
|
|
1144
|
+
const normalized = targetName.toLowerCase().replaceAll(OUTPUT_NAME_PATTERN, "_").replaceAll(/^_+|_+$/gu, "");
|
|
1145
|
+
if (normalized.length === 0) throw new Error(`Impact target name "${targetName}" has no output-safe characters.`);
|
|
1146
|
+
return `target_${normalized}`;
|
|
1147
|
+
};
|
|
1148
|
+
/**
|
|
1149
|
+
* Generate the small factory-owned decision seam for a consumer production
|
|
1150
|
+
* workflow. Consumers retain jobs, deploy commands, credentials, topology,
|
|
1151
|
+
* and convergence checks; this artifact supplies only the decision step and
|
|
1152
|
+
* per-target withdrawal conditions.
|
|
1153
|
+
*/
|
|
1154
|
+
const factoryProductionImpactWorkflow = (options) => {
|
|
1155
|
+
const targetOutputs = Object.fromEntries(options.targets.map((target) => [target, productionImpactTargetOutput(target)]));
|
|
1156
|
+
if (new Set(Object.values(targetOutputs)).size !== options.targets.length) throw new Error("Impact target names must map to distinct GitHub output keys.");
|
|
1157
|
+
const before = options.before ?? `\${{ github.event.before }}`;
|
|
1158
|
+
const after = options.after ?? `\${{ github.sha }}`;
|
|
1159
|
+
const cli = options.cli ?? "pnpm exec psf";
|
|
1160
|
+
const profile = options.profilePath ? ` --profile ${shellQuote(options.profilePath)}` : "";
|
|
1161
|
+
const decisionStep = {
|
|
1162
|
+
continueOnError: true,
|
|
1163
|
+
env: {
|
|
1164
|
+
FACTORY_AFTER_SHA: after,
|
|
1165
|
+
FACTORY_BEFORE_SHA: before
|
|
1166
|
+
},
|
|
1167
|
+
id: FACTORY_PRODUCTION_IMPACT_STEP_ID,
|
|
1168
|
+
name: "Classify production impact",
|
|
1169
|
+
run: `${cli} production:impact --before "$FACTORY_BEFORE_SHA" --after "$FACTORY_AFTER_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`
|
|
1170
|
+
};
|
|
1171
|
+
return Object.freeze({
|
|
1172
|
+
decisionJobOutputs: Object.freeze({
|
|
1173
|
+
basis: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT} }}`,
|
|
1174
|
+
decision: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} }}`,
|
|
1175
|
+
unsubscribed_paths: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT} }}`,
|
|
1176
|
+
...Object.fromEntries(Object.values(targetOutputs).map((output) => [output, `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${output} }}`]))
|
|
1177
|
+
}),
|
|
1178
|
+
decisionStep: Object.freeze(decisionStep),
|
|
1179
|
+
demandedIf: (targetName, decisionJob) => {
|
|
1180
|
+
const output = targetOutputs[targetName];
|
|
1181
|
+
if (output === void 0) throw new Error(`Unknown production impact target "${targetName}".`);
|
|
1182
|
+
const source = decisionJob ? `needs.${decisionJob}` : `steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}`;
|
|
1183
|
+
const condition = `${source}.${decisionJob ? "result" : "outcome"} != 'success' || ${source}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
|
|
1184
|
+
return decisionJob ? `always() && (${condition})` : condition;
|
|
1185
|
+
},
|
|
1186
|
+
targetOutputs: Object.freeze(targetOutputs)
|
|
1187
|
+
});
|
|
1188
|
+
};
|
|
1189
|
+
//#endregion
|
|
1190
|
+
//#region src/push-identity-workflow.ts
|
|
1191
|
+
const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
|
|
1192
|
+
const FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX = "factory-push-identity";
|
|
1193
|
+
const FACTORY_PUSH_IDENTITY_RECORD_STEP_ID = "factory_push_identity_record";
|
|
1194
|
+
const FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID = "factory_push_identity_lookup";
|
|
1195
|
+
const FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID = "factory_push_identity_download";
|
|
1196
|
+
const FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID = "factory_push_identity_checkout";
|
|
1197
|
+
const FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID = "factory_push_identity";
|
|
1198
|
+
const ARTIFACT_MAX_BYTES = 16384;
|
|
1199
|
+
const ENVELOPE_MAX_BYTES = 4096;
|
|
1200
|
+
const expression = (value) => `\${{ ${value} }}`;
|
|
1201
|
+
const artifactName = (runId) => `${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-${runId}`;
|
|
1202
|
+
const producerScript = String.raw`identity="$RUNNER_TEMP/factory-push-identity.json"
|
|
1203
|
+
produced=false
|
|
1204
|
+
reason='producer_error'
|
|
1205
|
+
before=''
|
|
1206
|
+
event_after=''
|
|
1207
|
+
rm -f "$identity"
|
|
1208
|
+
|
|
1209
|
+
is_hex_sha() {
|
|
1210
|
+
[ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
|
|
1211
|
+
case "$1" in *[!0-9a-f]*) false;; *) true;; esac
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
is_nonzero_sha() {
|
|
1215
|
+
is_hex_sha "$1" && [ "$1" != '0000000000000000000000000000000000000000' ]
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if [ "$GITHUB_EVENT_NAME" != 'push' ]; then
|
|
1219
|
+
reason='not_push_event'
|
|
1220
|
+
elif ! printf '%s' "$GITHUB_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
|
|
1221
|
+
reason='repository_invalid'
|
|
1222
|
+
elif ! printf '%s' "$GITHUB_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
|
|
1223
|
+
reason='run_id_invalid'
|
|
1224
|
+
elif ! before="$(jq -er '.before | select(type == "string")' "$GITHUB_EVENT_PATH" 2>/dev/null)"; then
|
|
1225
|
+
reason='event_before_missing'
|
|
1226
|
+
elif ! event_after="$(jq -er '.after | select(type == "string")' "$GITHUB_EVENT_PATH" 2>/dev/null)"; then
|
|
1227
|
+
reason='event_after_missing'
|
|
1228
|
+
elif ! is_hex_sha "$before"; then
|
|
1229
|
+
reason='event_before_invalid'
|
|
1230
|
+
elif ! is_nonzero_sha "$event_after" || ! is_nonzero_sha "$GITHUB_SHA"; then
|
|
1231
|
+
reason='event_after_invalid'
|
|
1232
|
+
elif [ "$event_after" != "$GITHUB_SHA" ]; then
|
|
1233
|
+
reason='event_after_mismatch'
|
|
1234
|
+
elif jq -cn \
|
|
1235
|
+
--argjson schemaVersion '${1}' \
|
|
1236
|
+
--arg repository "$GITHUB_REPOSITORY" \
|
|
1237
|
+
--arg runId "$GITHUB_RUN_ID" \
|
|
1238
|
+
--arg before "$before" \
|
|
1239
|
+
--arg after "$event_after" \
|
|
1240
|
+
'{schemaVersion: $schemaVersion, repository: $repository, runId: $runId, before: $before, after: $after}' > "$identity"; then
|
|
1241
|
+
produced=true
|
|
1242
|
+
reason='produced'
|
|
1243
|
+
fi
|
|
1244
|
+
|
|
1245
|
+
printf 'produced=%s\nreason=%s\n' "$produced" "$reason" >> "$GITHUB_OUTPUT"
|
|
1246
|
+
{
|
|
1247
|
+
printf '## Exact push identity producer\n\n'
|
|
1248
|
+
printf -- '- Status: %s\n' "$reason"
|
|
1249
|
+
printf -- '- Repository/run: %s / %s\n' "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID"
|
|
1250
|
+
if [ "$produced" = true ]; then
|
|
1251
|
+
printf -- '- Bound push: %s → %s\n' "$before" "$event_after"
|
|
1252
|
+
else
|
|
1253
|
+
printf -- '- Identity transport was not produced; downstream classification will refuse withdrawal.\n'
|
|
1254
|
+
fi
|
|
1255
|
+
} >> "$GITHUB_STEP_SUMMARY"`;
|
|
1256
|
+
const lookupScript = String.raw`status='refused'
|
|
1257
|
+
reason='artifact_lookup_failed'
|
|
1258
|
+
artifact_id=''
|
|
1259
|
+
matches="$RUNNER_TEMP/factory-push-identity-artifacts.tsv"
|
|
1260
|
+
artifact_name='${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-'"$EXPECTED_RUN_ID"
|
|
1261
|
+
rm -f "$matches"
|
|
1262
|
+
|
|
1263
|
+
is_sha() {
|
|
1264
|
+
[ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
|
|
1265
|
+
[ "$1" != '0000000000000000000000000000000000000000' ] &&
|
|
1266
|
+
case "$1" in *[!0-9a-f]*) false;; *) true;; esac
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if [ "$EXPECTED_EVENT_NAME" != 'workflow_run' ]; then
|
|
1270
|
+
reason='event_not_workflow_run'
|
|
1271
|
+
elif [ "$EXPECTED_WORKFLOW_EVENT" != 'push' ]; then
|
|
1272
|
+
reason='triggering_workflow_not_push'
|
|
1273
|
+
elif [ "$EXPECTED_CONCLUSION" != 'success' ]; then
|
|
1274
|
+
reason='triggering_workflow_not_successful'
|
|
1275
|
+
elif [ "$EXPECTED_WORKFLOW_REPOSITORY" != "$EXPECTED_REPOSITORY" ]; then
|
|
1276
|
+
reason='triggering_repository_mismatch'
|
|
1277
|
+
elif ! printf '%s' "$EXPECTED_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
|
|
1278
|
+
reason='triggering_repository_invalid'
|
|
1279
|
+
elif ! printf '%s' "$EXPECTED_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
|
|
1280
|
+
reason='triggering_run_id_invalid'
|
|
1281
|
+
elif ! is_sha "$EXPECTED_AFTER_SHA"; then
|
|
1282
|
+
reason='triggering_head_sha_invalid'
|
|
1283
|
+
elif ! gh api --paginate \
|
|
1284
|
+
"repos/$EXPECTED_REPOSITORY/actions/runs/$EXPECTED_RUN_ID/artifacts?per_page=100" \
|
|
1285
|
+
--jq ".artifacts[] | select(.name == \"$artifact_name\") | [.id, .size_in_bytes, .expired] | @tsv" \
|
|
1286
|
+
> "$matches" 2>/dev/null; then
|
|
1287
|
+
reason='artifact_lookup_failed'
|
|
1288
|
+
else
|
|
1289
|
+
count="$(awk 'END { print NR + 0 }' "$matches")"
|
|
1290
|
+
if [ "$count" -eq 0 ]; then
|
|
1291
|
+
reason='artifact_missing'
|
|
1292
|
+
elif [ "$count" -ne 1 ]; then
|
|
1293
|
+
reason='artifact_duplicate'
|
|
1294
|
+
else
|
|
1295
|
+
IFS="$(printf '\t')" read -r artifact_id artifact_size artifact_expired < "$matches" || true
|
|
1296
|
+
if ! printf '%s' "$artifact_id" | grep -Eq '^[1-9][0-9]*$' ||
|
|
1297
|
+
! printf '%s' "$artifact_size" | grep -Eq '^[0-9]+$' ||
|
|
1298
|
+
{ [ "$artifact_expired" != 'false' ] && [ "$artifact_expired" != 'true' ]; }; then
|
|
1299
|
+
reason='artifact_metadata_invalid'
|
|
1300
|
+
artifact_id=''
|
|
1301
|
+
elif [ "$artifact_expired" = 'true' ]; then
|
|
1302
|
+
reason='artifact_expired'
|
|
1303
|
+
artifact_id=''
|
|
1304
|
+
elif [ "$artifact_size" -gt '${ARTIFACT_MAX_BYTES}' ]; then
|
|
1305
|
+
reason='artifact_oversized'
|
|
1306
|
+
artifact_id=''
|
|
1307
|
+
else
|
|
1308
|
+
status='available'
|
|
1309
|
+
reason='artifact_available'
|
|
1310
|
+
fi
|
|
1311
|
+
fi
|
|
1312
|
+
fi
|
|
1313
|
+
|
|
1314
|
+
printf 'status=%s\nreason=%s\nartifact_id=%s\n' "$status" "$reason" "$artifact_id" >> "$GITHUB_OUTPUT"`;
|
|
1315
|
+
const validationScript = String.raw`disposition='refused'
|
|
1316
|
+
reason='validation_error'
|
|
1317
|
+
before=''
|
|
1318
|
+
after=''
|
|
1319
|
+
identity_directory="$RUNNER_TEMP/factory-push-identity"
|
|
1320
|
+
identity="$identity_directory/factory-push-identity.json"
|
|
1321
|
+
artifact_name='${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-'"$EXPECTED_RUN_ID"
|
|
1322
|
+
|
|
1323
|
+
is_sha() {
|
|
1324
|
+
[ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
|
|
1325
|
+
[ "$1" != '0000000000000000000000000000000000000000' ] &&
|
|
1326
|
+
case "$1" in *[!0-9a-f]*) false;; *) true;; esac
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if [ "$EXPECTED_EVENT_NAME" != 'workflow_run' ]; then
|
|
1330
|
+
reason='event_not_workflow_run'
|
|
1331
|
+
elif [ "$EXPECTED_WORKFLOW_EVENT" != 'push' ]; then
|
|
1332
|
+
reason='triggering_workflow_not_push'
|
|
1333
|
+
elif [ "$EXPECTED_CONCLUSION" != 'success' ]; then
|
|
1334
|
+
reason='triggering_workflow_not_successful'
|
|
1335
|
+
elif [ "$EXPECTED_WORKFLOW_REPOSITORY" != "$EXPECTED_REPOSITORY" ]; then
|
|
1336
|
+
reason='triggering_repository_mismatch'
|
|
1337
|
+
elif ! printf '%s' "$EXPECTED_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
|
|
1338
|
+
reason='triggering_repository_invalid'
|
|
1339
|
+
elif ! printf '%s' "$EXPECTED_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
|
|
1340
|
+
reason='triggering_run_id_invalid'
|
|
1341
|
+
elif ! is_sha "$EXPECTED_AFTER_SHA"; then
|
|
1342
|
+
reason='triggering_head_sha_invalid'
|
|
1343
|
+
elif [ "$LOOKUP_STATUS" != 'available' ]; then
|
|
1344
|
+
if [ -n "$LOOKUP_REASON" ]; then
|
|
1345
|
+
reason="$LOOKUP_REASON"
|
|
1346
|
+
else
|
|
1347
|
+
reason='artifact_lookup_failed'
|
|
1348
|
+
fi
|
|
1349
|
+
elif [ "$DOWNLOAD_OUTCOME" != 'success' ]; then
|
|
1350
|
+
reason='artifact_download_failed'
|
|
1351
|
+
elif [ "$CHECKOUT_OUTCOME" != 'success' ]; then
|
|
1352
|
+
reason='checkout_failed'
|
|
1353
|
+
elif [ ! -d "$identity_directory" ]; then
|
|
1354
|
+
reason='artifact_contents_missing'
|
|
1355
|
+
else
|
|
1356
|
+
entry_count="$(find "$identity_directory" -mindepth 1 -maxdepth 1 -print 2>/dev/null | awk 'END { print NR + 0 }')"
|
|
1357
|
+
if [ "$entry_count" -ne 1 ] || [ ! -f "$identity" ] || [ -L "$identity" ]; then
|
|
1358
|
+
reason='artifact_contents_invalid'
|
|
1359
|
+
else
|
|
1360
|
+
envelope_size="$(wc -c < "$identity" | tr -d '[:space:]')"
|
|
1361
|
+
if ! printf '%s' "$envelope_size" | grep -Eq '^[0-9]+$'; then
|
|
1362
|
+
reason='envelope_size_unreadable'
|
|
1363
|
+
elif [ "$envelope_size" -gt '${ENVELOPE_MAX_BYTES}' ]; then
|
|
1364
|
+
reason='envelope_oversized'
|
|
1365
|
+
elif ! jq -e --argjson schemaVersion '${1}' '
|
|
1366
|
+
type == "object" and
|
|
1367
|
+
keys == ["after", "before", "repository", "runId", "schemaVersion"] and
|
|
1368
|
+
.schemaVersion == $schemaVersion and
|
|
1369
|
+
(.repository | type == "string") and
|
|
1370
|
+
(.runId | type == "string" and test("^[1-9][0-9]*$")) and
|
|
1371
|
+
(.before | type == "string" and test("^[0-9a-f]{40}$") and (test("^0{40}$") | not)) and
|
|
1372
|
+
(.after | type == "string" and test("^[0-9a-f]{40}$") and (test("^0{40}$") | not))
|
|
1373
|
+
' "$identity" >/dev/null 2>&1; then
|
|
1374
|
+
reason='envelope_malformed'
|
|
1375
|
+
else
|
|
1376
|
+
repository="$(jq -r '.repository' "$identity")"
|
|
1377
|
+
run_id="$(jq -r '.runId' "$identity")"
|
|
1378
|
+
before="$(jq -r '.before' "$identity")"
|
|
1379
|
+
after="$(jq -r '.after' "$identity")"
|
|
1380
|
+
|
|
1381
|
+
if [ "$repository" != "$EXPECTED_REPOSITORY" ]; then
|
|
1382
|
+
reason='envelope_repository_mismatch'
|
|
1383
|
+
elif [ "$run_id" != "$EXPECTED_RUN_ID" ]; then
|
|
1384
|
+
reason='envelope_run_id_mismatch'
|
|
1385
|
+
elif [ "$after" != "$EXPECTED_AFTER_SHA" ]; then
|
|
1386
|
+
reason='envelope_after_mismatch'
|
|
1387
|
+
elif ! actual_head="$(git rev-parse --verify HEAD 2>/dev/null)" || [ "$actual_head" != "$EXPECTED_AFTER_SHA" ]; then
|
|
1388
|
+
reason='checkout_head_mismatch'
|
|
1389
|
+
elif ! git cat-file -e "$before^{commit}" 2>/dev/null || ! git cat-file -e "$after^{commit}" 2>/dev/null; then
|
|
1390
|
+
reason='envelope_commit_unreachable'
|
|
1391
|
+
elif [ "$before" = "$after" ]; then
|
|
1392
|
+
reason='envelope_commits_contradictory'
|
|
1393
|
+
elif ! git merge-base --is-ancestor "$before" "$after"; then
|
|
1394
|
+
reason='before_not_ancestor'
|
|
1395
|
+
else
|
|
1396
|
+
disposition='usable'
|
|
1397
|
+
reason='identity_bound'
|
|
1398
|
+
fi
|
|
1399
|
+
fi
|
|
1400
|
+
fi
|
|
1401
|
+
fi
|
|
1402
|
+
|
|
1403
|
+
if [ "$disposition" != 'usable' ]; then
|
|
1404
|
+
before=''
|
|
1405
|
+
after=''
|
|
1406
|
+
fi
|
|
1407
|
+
|
|
1408
|
+
provenance="$(jq -cn \
|
|
1409
|
+
--argjson schemaVersion '${1}' \
|
|
1410
|
+
--arg disposition "$disposition" \
|
|
1411
|
+
--arg reason "$reason" \
|
|
1412
|
+
--arg repository "$EXPECTED_REPOSITORY" \
|
|
1413
|
+
--arg runId "$EXPECTED_RUN_ID" \
|
|
1414
|
+
--arg expectedAfter "$EXPECTED_AFTER_SHA" \
|
|
1415
|
+
--arg artifactName "$artifact_name" \
|
|
1416
|
+
--arg artifactId "$ARTIFACT_ID" \
|
|
1417
|
+
--arg before "$before" \
|
|
1418
|
+
--arg after "$after" \
|
|
1419
|
+
'{schemaVersion: $schemaVersion, disposition: $disposition, reason: $reason, repository: $repository, runId: $runId, expectedAfter: $expectedAfter, artifactName: $artifactName, artifactId: (if $artifactId == "" then null else $artifactId end), before: (if $before == "" then null else $before end), after: (if $after == "" then null else $after end)}')"
|
|
1420
|
+
|
|
1421
|
+
printf 'disposition=%s\nreason=%s\nbefore=%s\nafter=%s\nprovenance=%s\n' \
|
|
1422
|
+
"$disposition" "$reason" "$before" "$after" "$provenance" >> "$GITHUB_OUTPUT"
|
|
1423
|
+
{
|
|
1424
|
+
printf '## Exact push identity\n\n'
|
|
1425
|
+
printf -- '- Disposition: %s\n' "$disposition"
|
|
1426
|
+
printf -- '- Reason: %s\n' "$reason"
|
|
1427
|
+
printf -- '- Repository/run: %s / %s\n' "$EXPECTED_REPOSITORY" "$EXPECTED_RUN_ID"
|
|
1428
|
+
printf -- '- Expected verified head: %s\n' "$EXPECTED_AFTER_SHA"
|
|
1429
|
+
if [ "$disposition" = 'usable' ]; then
|
|
1430
|
+
printf -- '- Bound push: %s → %s\n' "$before" "$after"
|
|
1431
|
+
else
|
|
1432
|
+
printf -- '- Production impact classification refused; every consumer-owned production target remains demanded.\n'
|
|
1433
|
+
fi
|
|
1434
|
+
printf '\nFactory telemetry provenance: %s\n' "$provenance"
|
|
1435
|
+
} >> "$GITHUB_STEP_SUMMARY"`;
|
|
1436
|
+
/**
|
|
1437
|
+
* Record and upload the exact push-event identity without changing the Verify
|
|
1438
|
+
* result when transport is unavailable. Place these steps after verification.
|
|
1439
|
+
*/
|
|
1440
|
+
const factoryPushIdentityProducer = (options) => {
|
|
1441
|
+
assertPinnedAction("uploadArtifact", options.uploadArtifact, "actions/upload-artifact");
|
|
1442
|
+
const condition = options.if ? `github.event_name == 'push' && (${options.if})` : "github.event_name == 'push'";
|
|
1443
|
+
const name = artifactName(expression("github.run_id"));
|
|
1444
|
+
return Object.freeze({
|
|
1445
|
+
artifactName: name,
|
|
1446
|
+
steps: Object.freeze([{
|
|
1447
|
+
continueOnError: true,
|
|
1448
|
+
id: FACTORY_PUSH_IDENTITY_RECORD_STEP_ID,
|
|
1449
|
+
if: condition,
|
|
1450
|
+
name: "Record exact push identity",
|
|
1451
|
+
run: producerScript
|
|
1452
|
+
}, {
|
|
1453
|
+
continueOnError: true,
|
|
1454
|
+
if: `${condition} && steps.${FACTORY_PUSH_IDENTITY_RECORD_STEP_ID}.outputs.produced == 'true'`,
|
|
1455
|
+
name: "Upload exact push identity",
|
|
1456
|
+
uses: options.uploadArtifact.uses,
|
|
1457
|
+
with: {
|
|
1458
|
+
"if-no-files-found": "error",
|
|
1459
|
+
name,
|
|
1460
|
+
path: `${expression("runner.temp")}/factory-push-identity.json`,
|
|
1461
|
+
"retention-days": "7"
|
|
1462
|
+
}
|
|
1463
|
+
}])
|
|
1464
|
+
});
|
|
1465
|
+
};
|
|
1466
|
+
/**
|
|
1467
|
+
* Download and validate identity from exactly the triggering workflow run in
|
|
1468
|
+
* the current repository. Every failure becomes a typed refusal; consumers
|
|
1469
|
+
* retain deploy policy, credentials, commands, topology, and convergence.
|
|
1470
|
+
*/
|
|
1471
|
+
const factoryPushIdentityConsumer = (options) => {
|
|
1472
|
+
assertPinnedAction("checkout", options.checkout, "actions/checkout");
|
|
1473
|
+
assertPinnedAction("downloadArtifact", options.downloadArtifact, "actions/download-artifact");
|
|
1474
|
+
const currentRepository = expression("github.repository");
|
|
1475
|
+
const triggeringRun = expression("github.event.workflow_run.id");
|
|
1476
|
+
const triggeringHead = expression("github.event.workflow_run.head_sha");
|
|
1477
|
+
const lookupOutput = (name) => expression(`steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.${name}`);
|
|
1478
|
+
const validateOutput = (name) => expression(`steps.${FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID}.outputs.${name}`);
|
|
1479
|
+
const contextEnvironment = {
|
|
1480
|
+
EXPECTED_AFTER_SHA: triggeringHead,
|
|
1481
|
+
EXPECTED_CONCLUSION: expression("github.event.workflow_run.conclusion"),
|
|
1482
|
+
EXPECTED_EVENT_NAME: expression("github.event_name"),
|
|
1483
|
+
EXPECTED_REPOSITORY: currentRepository,
|
|
1484
|
+
EXPECTED_RUN_ID: triggeringRun,
|
|
1485
|
+
EXPECTED_WORKFLOW_EVENT: expression("github.event.workflow_run.event"),
|
|
1486
|
+
EXPECTED_WORKFLOW_REPOSITORY: expression("github.event.workflow_run.repository.full_name")
|
|
1487
|
+
};
|
|
1488
|
+
const steps = [
|
|
1489
|
+
{
|
|
1490
|
+
continueOnError: true,
|
|
1491
|
+
env: {
|
|
1492
|
+
GH_TOKEN: expression("github.token"),
|
|
1493
|
+
...contextEnvironment
|
|
1494
|
+
},
|
|
1495
|
+
id: FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID,
|
|
1496
|
+
name: "Resolve exact push identity artifact",
|
|
1497
|
+
run: lookupScript
|
|
1498
|
+
},
|
|
1499
|
+
{
|
|
1500
|
+
continueOnError: true,
|
|
1501
|
+
id: FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID,
|
|
1502
|
+
if: `steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.status == 'available'`,
|
|
1503
|
+
name: "Download exact push identity",
|
|
1504
|
+
uses: options.downloadArtifact.uses,
|
|
1505
|
+
with: {
|
|
1506
|
+
"artifact-ids": lookupOutput("artifact_id"),
|
|
1507
|
+
"github-token": expression("github.token"),
|
|
1508
|
+
path: `${expression("runner.temp")}/factory-push-identity`,
|
|
1509
|
+
repository: currentRepository,
|
|
1510
|
+
"run-id": triggeringRun
|
|
1511
|
+
}
|
|
1512
|
+
},
|
|
1513
|
+
{
|
|
1514
|
+
continueOnError: true,
|
|
1515
|
+
id: FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID,
|
|
1516
|
+
if: `steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.status == 'available'`,
|
|
1517
|
+
name: "Checkout exact verified head",
|
|
1518
|
+
uses: options.checkout.uses,
|
|
1519
|
+
with: {
|
|
1520
|
+
"fetch-depth": "0",
|
|
1521
|
+
ref: triggeringHead
|
|
1522
|
+
}
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
continueOnError: true,
|
|
1526
|
+
env: {
|
|
1527
|
+
ARTIFACT_ID: lookupOutput("artifact_id"),
|
|
1528
|
+
CHECKOUT_OUTCOME: expression(`steps.${FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID}.outcome`),
|
|
1529
|
+
DOWNLOAD_OUTCOME: expression(`steps.${FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID}.outcome`),
|
|
1530
|
+
LOOKUP_REASON: lookupOutput("reason"),
|
|
1531
|
+
LOOKUP_STATUS: lookupOutput("status"),
|
|
1532
|
+
...contextEnvironment
|
|
1533
|
+
},
|
|
1534
|
+
id: FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID,
|
|
1535
|
+
if: "always()",
|
|
1536
|
+
name: "Validate exact push identity",
|
|
1537
|
+
run: validationScript
|
|
1538
|
+
}
|
|
1539
|
+
];
|
|
1540
|
+
return Object.freeze({
|
|
1541
|
+
outputs: Object.freeze({
|
|
1542
|
+
after: validateOutput("after"),
|
|
1543
|
+
before: validateOutput("before"),
|
|
1544
|
+
disposition: validateOutput("disposition"),
|
|
1545
|
+
provenance: validateOutput("provenance"),
|
|
1546
|
+
reason: validateOutput("reason")
|
|
1547
|
+
}),
|
|
1548
|
+
requiredPermissions: Object.freeze({
|
|
1549
|
+
actions: "read",
|
|
1550
|
+
contents: "read"
|
|
1551
|
+
}),
|
|
1552
|
+
steps: Object.freeze(steps),
|
|
1553
|
+
usableIf: `steps.${FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID}.outputs.disposition == 'usable'`
|
|
1554
|
+
});
|
|
1555
|
+
};
|
|
1556
|
+
//#endregion
|
|
1557
|
+
//#region src/vitest-profile.ts
|
|
1558
|
+
/**
|
|
1559
|
+
* Vitest suite profiling — the measurement mechanics behind a CI runner
|
|
1560
|
+
* comparison (#640, #647).
|
|
1561
|
+
*
|
|
1562
|
+
* A profile is a machine-readable record of N serial Vitest runs at one worker
|
|
1563
|
+
* count, carrying per-file and per-test timings, aggregate duration statistics,
|
|
1564
|
+
* and the hardware the samples actually ran on. The hardware capture is the
|
|
1565
|
+
* point: the #640 Depot comparison only resolved because every sample recorded
|
|
1566
|
+
* its `cpuModel`, which split otherwise-identical 4-CPU runs into two
|
|
1567
|
+
* non-overlapping populations.
|
|
1568
|
+
*
|
|
1569
|
+
* Everything a repository decides stays with the repository: worker counts,
|
|
1570
|
+
* sample counts, output paths, runner labels, and which suite to run at all.
|
|
1571
|
+
* This module owns the schema, the report parsing, the environment capture, and
|
|
1572
|
+
* the atomic write.
|
|
1573
|
+
*/
|
|
1574
|
+
/** Schema version of the emitted profile document. */
|
|
1575
|
+
const VITEST_PROFILE_SCHEMA_VERSION = 1;
|
|
1576
|
+
/** `tool` discriminator every emitted profile carries. */
|
|
1577
|
+
const VITEST_PROFILE_TOOL = "factory-ci-vitest-profile";
|
|
1578
|
+
const RUN_TIMEOUT_MS = 15 * 6e4;
|
|
1579
|
+
const TERMINATION_GRACE_MS = 5e3;
|
|
1580
|
+
/**
|
|
1581
|
+
* A sample failed. The partial profile is already on disk; `exitCode` is the
|
|
1582
|
+
* status a caller should exit with.
|
|
1583
|
+
*/
|
|
1584
|
+
var VitestProfileError = class extends Error {
|
|
1585
|
+
exitCode;
|
|
1586
|
+
constructor(message, exitCode) {
|
|
1587
|
+
super(message);
|
|
1588
|
+
this.name = "VitestProfileError";
|
|
1589
|
+
this.exitCode = exitCode;
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
const durationSummary = (durations) => {
|
|
1593
|
+
const sorted = durations.toSorted((left, right) => left - right);
|
|
1594
|
+
const middle = Math.floor(sorted.length / 2);
|
|
1595
|
+
const median = sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 : sorted[middle] ?? 0;
|
|
1596
|
+
return {
|
|
1597
|
+
maximum: sorted.at(-1) ?? 0,
|
|
1598
|
+
mean: durations.reduce((sum, value) => sum + value, 0) / durations.length,
|
|
1599
|
+
median,
|
|
1600
|
+
minimum: sorted[0] ?? 0
|
|
1601
|
+
};
|
|
1602
|
+
};
|
|
1603
|
+
const relativePath = (cwd, file) => path.relative(cwd, file).split(path.sep).join("/");
|
|
1604
|
+
/**
|
|
1605
|
+
* Fold one Vitest JSON report into a profile sample: file and test timings,
|
|
1606
|
+
* both sorted slowest first, plus the run's counts. A missing report (crash,
|
|
1607
|
+
* timeout, unwritable output) yields a sample with `reportAvailable: false`
|
|
1608
|
+
* rather than nothing at all.
|
|
1609
|
+
*/
|
|
1610
|
+
const normalizeVitestProfileSample = (report, input) => {
|
|
1611
|
+
const files = (report?.testResults ?? []).map((file) => ({
|
|
1612
|
+
durationMs: Math.max(0, file.endTime - file.startTime),
|
|
1613
|
+
path: relativePath(input.cwd, file.name),
|
|
1614
|
+
status: file.status
|
|
1615
|
+
})).toSorted((left, right) => right.durationMs - left.durationMs);
|
|
1616
|
+
const tests = (report?.testResults ?? []).flatMap((file) => file.assertionResults.map((test) => ({
|
|
1617
|
+
durationMs: Math.max(0, test.duration ?? 0),
|
|
1618
|
+
file: relativePath(input.cwd, file.name),
|
|
1619
|
+
name: test.fullName,
|
|
1620
|
+
status: test.status
|
|
1621
|
+
}))).toSorted((left, right) => right.durationMs - left.durationMs);
|
|
1622
|
+
return {
|
|
1623
|
+
counts: report ? {
|
|
1624
|
+
failed: report.numFailedTests,
|
|
1625
|
+
passed: report.numPassedTests,
|
|
1626
|
+
pending: report.numPendingTests,
|
|
1627
|
+
suites: report.numTotalTestSuites,
|
|
1628
|
+
tests: report.numTotalTests,
|
|
1629
|
+
todo: report.numTodoTests
|
|
1630
|
+
} : null,
|
|
1631
|
+
durationMs: input.durationMs,
|
|
1632
|
+
endedAt: input.endedAt.toISOString(),
|
|
1633
|
+
exitCode: input.exitCode,
|
|
1634
|
+
failure: input.failure ?? null,
|
|
1635
|
+
files,
|
|
1636
|
+
reportAvailable: report !== null,
|
|
1637
|
+
sample: input.sample,
|
|
1638
|
+
startedAt: input.startedAt.toISOString(),
|
|
1639
|
+
tests
|
|
1640
|
+
};
|
|
1641
|
+
};
|
|
1642
|
+
const aggregateSlowFiles = (runs, limit) => {
|
|
1643
|
+
const durations = /* @__PURE__ */ new Map();
|
|
1644
|
+
for (const file of runs.flatMap((run) => run.files)) {
|
|
1645
|
+
const recorded = durations.get(file.path) ?? [];
|
|
1646
|
+
recorded.push(file.durationMs);
|
|
1647
|
+
durations.set(file.path, recorded);
|
|
1648
|
+
}
|
|
1649
|
+
return [...durations].map(([filePath, values]) => ({
|
|
1650
|
+
durationMs: durationSummary(values),
|
|
1651
|
+
path: filePath,
|
|
1652
|
+
samples: values.length
|
|
1653
|
+
})).toSorted((left, right) => right.durationMs.median - left.durationMs.median).slice(0, limit);
|
|
1654
|
+
};
|
|
1655
|
+
const aggregateSlowTests = (runs, limit) => {
|
|
1656
|
+
const timings = /* @__PURE__ */ new Map();
|
|
1657
|
+
for (const test of runs.flatMap((run) => run.tests)) {
|
|
1658
|
+
const key = `${test.file}\0${test.name}`;
|
|
1659
|
+
const recorded = timings.get(key) ?? {
|
|
1660
|
+
durations: [],
|
|
1661
|
+
file: test.file,
|
|
1662
|
+
name: test.name
|
|
1663
|
+
};
|
|
1664
|
+
recorded.durations.push(test.durationMs);
|
|
1665
|
+
timings.set(key, recorded);
|
|
1666
|
+
}
|
|
1667
|
+
return [...timings.values()].map(({ durations, file, name }) => ({
|
|
1668
|
+
durationMs: durationSummary(durations),
|
|
1669
|
+
file,
|
|
1670
|
+
name,
|
|
1671
|
+
samples: durations.length
|
|
1672
|
+
})).toSorted((left, right) => right.durationMs.median - left.durationMs.median).slice(0, limit);
|
|
1673
|
+
};
|
|
1674
|
+
const gitValue = (cwd, args) => {
|
|
1675
|
+
try {
|
|
1676
|
+
return execFileSync("git", args, {
|
|
1677
|
+
cwd,
|
|
1678
|
+
encoding: "utf-8",
|
|
1679
|
+
stdio: [
|
|
1680
|
+
"ignore",
|
|
1681
|
+
"pipe",
|
|
1682
|
+
"ignore"
|
|
1683
|
+
]
|
|
1684
|
+
}).trim();
|
|
1685
|
+
} catch {
|
|
1686
|
+
return null;
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
const resolveVitestPackage = (cwd) => createRequire(path.join(path.resolve(cwd), "noop.js")).resolve("vitest/package.json");
|
|
1690
|
+
/**
|
|
1691
|
+
* Record the machine and commit a profile was taken on. Vitest's version is
|
|
1692
|
+
* resolved from `cwd`, so it is the consumer's Vitest and not this package's.
|
|
1693
|
+
* Git failures degrade to `null` — an artifact from a tarball checkout is still
|
|
1694
|
+
* a usable measurement.
|
|
1695
|
+
*/
|
|
1696
|
+
const captureVitestProfileEnvironment = async (options) => {
|
|
1697
|
+
const gitDirectory = options.gitDirectory ?? options.cwd;
|
|
1698
|
+
let vitestVersion = "unknown";
|
|
1699
|
+
try {
|
|
1700
|
+
vitestVersion = JSON.parse(await readFile(resolveVitestPackage(options.cwd), "utf-8")).version ?? "unknown";
|
|
1701
|
+
} catch {
|
|
1702
|
+
vitestVersion = "unknown";
|
|
1703
|
+
}
|
|
1704
|
+
const processors = cpus();
|
|
1705
|
+
const status = gitValue(gitDirectory, ["status", "--porcelain"]);
|
|
1706
|
+
return {
|
|
1707
|
+
arch: arch(),
|
|
1708
|
+
availableParallelism: availableParallelism(),
|
|
1709
|
+
cpuCount: processors.length,
|
|
1710
|
+
cpuModel: processors[0]?.model ?? null,
|
|
1711
|
+
gitDirty: status === null ? null : status.length > 0,
|
|
1712
|
+
gitHead: gitValue(gitDirectory, ["rev-parse", "HEAD"]),
|
|
1713
|
+
node: process.version,
|
|
1714
|
+
osRelease: release(),
|
|
1715
|
+
platform: platform(),
|
|
1716
|
+
totalMemoryBytes: totalmem(),
|
|
1717
|
+
vitest: vitestVersion
|
|
1718
|
+
};
|
|
1719
|
+
};
|
|
1720
|
+
const spawnVitest = async ({ cwd, maxWorkers, reportPath, stdio }) => {
|
|
1721
|
+
const vitestCli = path.join(path.dirname(resolveVitestPackage(cwd)), "vitest.mjs");
|
|
1722
|
+
const started = performance.now();
|
|
1723
|
+
const child = spawn(process.execPath, [
|
|
1724
|
+
vitestCli,
|
|
1725
|
+
"run",
|
|
1726
|
+
"--reporter=json",
|
|
1727
|
+
`--outputFile=${reportPath}`,
|
|
1728
|
+
`--maxWorkers=${maxWorkers}`
|
|
1729
|
+
], {
|
|
1730
|
+
cwd,
|
|
1731
|
+
stdio
|
|
1732
|
+
});
|
|
1733
|
+
let timedOut = false;
|
|
1734
|
+
let forceKillTimer;
|
|
1735
|
+
const timeoutTimer = setTimeout(() => {
|
|
1736
|
+
timedOut = true;
|
|
1737
|
+
child.kill("SIGTERM");
|
|
1738
|
+
forceKillTimer = setTimeout(() => {
|
|
1739
|
+
child.kill("SIGKILL");
|
|
1740
|
+
}, TERMINATION_GRACE_MS);
|
|
1741
|
+
}, RUN_TIMEOUT_MS);
|
|
1742
|
+
let exitCode = 0;
|
|
1743
|
+
let failure = null;
|
|
1744
|
+
try {
|
|
1745
|
+
const [code, signal] = await once(child, "exit");
|
|
1746
|
+
exitCode = code ?? (signal ? 1 : 0);
|
|
1747
|
+
} catch (error) {
|
|
1748
|
+
exitCode = 1;
|
|
1749
|
+
failure = `Vitest could not start: ${error instanceof Error ? error.message : String(error)}`;
|
|
1750
|
+
} finally {
|
|
1751
|
+
clearTimeout(timeoutTimer);
|
|
1752
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
1753
|
+
}
|
|
1754
|
+
if (timedOut) failure = `Vitest profile sample exceeded ${RUN_TIMEOUT_MS}ms and was terminated.`;
|
|
1755
|
+
let report = null;
|
|
1756
|
+
try {
|
|
1757
|
+
report = JSON.parse(await readFile(reportPath, "utf-8"));
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
const reportFailure = `Vitest JSON report unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
1760
|
+
failure = failure ? `${failure} ${reportFailure}` : reportFailure;
|
|
1761
|
+
}
|
|
1762
|
+
return {
|
|
1763
|
+
durationMs: Math.max(0, performance.now() - started),
|
|
1764
|
+
exitCode: timedOut ? 1 : exitCode,
|
|
1765
|
+
failure,
|
|
1766
|
+
report
|
|
1767
|
+
};
|
|
1768
|
+
};
|
|
1769
|
+
/**
|
|
1770
|
+
* Write a profile document atomically: a partial file must never be readable
|
|
1771
|
+
* as a complete measurement, and the profile is rewritten after every sample.
|
|
1772
|
+
*/
|
|
1773
|
+
const writeVitestProfile = async (outputPath, profile) => {
|
|
1774
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
1775
|
+
const temporaryPath = `${outputPath}.tmp`;
|
|
1776
|
+
await writeFile(temporaryPath, `${JSON.stringify(profile, null, 2)}\n`, "utf-8");
|
|
1777
|
+
await rename(temporaryPath, outputPath);
|
|
1778
|
+
};
|
|
1779
|
+
/**
|
|
1780
|
+
* Take `samples` serial Vitest runs at one worker count and persist the profile
|
|
1781
|
+
* after each one. Samples never overlap: concurrent runs would measure CPU and
|
|
1782
|
+
* I/O contention instead of the worker count under test. A failing sample
|
|
1783
|
+
* throws `VitestProfileError` with the partial profile already written.
|
|
1784
|
+
*/
|
|
1785
|
+
const runVitestProfile = async (options, dependencies = {}) => {
|
|
1786
|
+
if (!Number.isSafeInteger(options.samples) || options.samples < 1) throw new Error("runVitestProfile: samples must be a positive integer.");
|
|
1787
|
+
const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
1788
|
+
const runSample = dependencies.runSample ?? spawnVitest;
|
|
1789
|
+
const writeResult = dependencies.writeResult ?? writeVitestProfile;
|
|
1790
|
+
const startedAt = now();
|
|
1791
|
+
const rawRoot = path.join(`${options.outputPath}.raw`, randomUUID());
|
|
1792
|
+
await mkdir(rawRoot, { recursive: true });
|
|
1793
|
+
const profile = {
|
|
1794
|
+
command: [
|
|
1795
|
+
"vitest",
|
|
1796
|
+
"run",
|
|
1797
|
+
"--reporter=json",
|
|
1798
|
+
`--maxWorkers=${options.maxWorkers}`
|
|
1799
|
+
],
|
|
1800
|
+
endedAt: startedAt.toISOString(),
|
|
1801
|
+
environment: await captureVitestProfileEnvironment({
|
|
1802
|
+
cwd: options.cwd,
|
|
1803
|
+
gitDirectory: options.gitDirectory
|
|
1804
|
+
}),
|
|
1805
|
+
options: {
|
|
1806
|
+
maxWorkers: options.maxWorkers,
|
|
1807
|
+
samples: options.samples,
|
|
1808
|
+
slowLimit: options.slowLimit
|
|
1809
|
+
},
|
|
1810
|
+
rawReportDirectory: rawRoot,
|
|
1811
|
+
runs: [],
|
|
1812
|
+
schemaVersion: 1,
|
|
1813
|
+
startedAt: startedAt.toISOString(),
|
|
1814
|
+
summary: {
|
|
1815
|
+
durationMs: {
|
|
1816
|
+
maximum: 0,
|
|
1817
|
+
mean: 0,
|
|
1818
|
+
median: 0,
|
|
1819
|
+
minimum: 0
|
|
1820
|
+
},
|
|
1821
|
+
slowFiles: [],
|
|
1822
|
+
slowTests: []
|
|
1823
|
+
},
|
|
1824
|
+
tool: VITEST_PROFILE_TOOL
|
|
1825
|
+
};
|
|
1826
|
+
for (let sample = 1; sample <= options.samples; sample += 1) {
|
|
1827
|
+
let hookFailure;
|
|
1828
|
+
try {
|
|
1829
|
+
options.onSampleStart?.({
|
|
1830
|
+
maxWorkers: options.maxWorkers,
|
|
1831
|
+
sample,
|
|
1832
|
+
samples: options.samples
|
|
1833
|
+
});
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
hookFailure = { error };
|
|
1836
|
+
}
|
|
1837
|
+
const sampleStartedAt = now();
|
|
1838
|
+
const reportPath = path.join(rawRoot, `sample-${sample}.json`);
|
|
1839
|
+
const execution = await runSample({
|
|
1840
|
+
cwd: options.cwd,
|
|
1841
|
+
maxWorkers: options.maxWorkers,
|
|
1842
|
+
reportPath,
|
|
1843
|
+
sample,
|
|
1844
|
+
stdio: options.stdio ?? "inherit"
|
|
1845
|
+
});
|
|
1846
|
+
const sampleEndedAt = now();
|
|
1847
|
+
const normalized = normalizeVitestProfileSample(execution.report, {
|
|
1848
|
+
cwd: options.cwd,
|
|
1849
|
+
durationMs: execution.durationMs,
|
|
1850
|
+
endedAt: sampleEndedAt,
|
|
1851
|
+
exitCode: execution.exitCode,
|
|
1852
|
+
failure: execution.failure,
|
|
1853
|
+
sample,
|
|
1854
|
+
startedAt: sampleStartedAt
|
|
1855
|
+
});
|
|
1856
|
+
profile.runs.push(normalized);
|
|
1857
|
+
profile.endedAt = sampleEndedAt.toISOString();
|
|
1858
|
+
profile.summary = {
|
|
1859
|
+
durationMs: durationSummary(profile.runs.map((run) => run.durationMs)),
|
|
1860
|
+
slowFiles: aggregateSlowFiles(profile.runs, options.slowLimit),
|
|
1861
|
+
slowTests: aggregateSlowTests(profile.runs, options.slowLimit)
|
|
1862
|
+
};
|
|
1863
|
+
await writeResult(options.outputPath, profile);
|
|
1864
|
+
try {
|
|
1865
|
+
options.onSampleComplete?.({
|
|
1866
|
+
result: normalized,
|
|
1867
|
+
sample,
|
|
1868
|
+
samples: options.samples
|
|
1869
|
+
});
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
hookFailure ??= { error };
|
|
1872
|
+
}
|
|
1873
|
+
if (execution.exitCode !== 0 || execution.report?.success !== true) throw new VitestProfileError(`Vitest profile sample ${sample} failed; partial results saved.`, execution.exitCode || 1);
|
|
1874
|
+
if (hookFailure) throw hookFailure.error;
|
|
1875
|
+
}
|
|
1876
|
+
return profile;
|
|
1877
|
+
};
|
|
1878
|
+
//#endregion
|
|
1879
|
+
//#region src/vitest-profile-reader.ts
|
|
1880
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1881
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
1882
|
+
const durationSummaryFailure = (value, where) => {
|
|
1883
|
+
if (!isRecord(value)) return `${where} is not an object`;
|
|
1884
|
+
const missing = [
|
|
1885
|
+
"maximum",
|
|
1886
|
+
"mean",
|
|
1887
|
+
"median",
|
|
1888
|
+
"minimum"
|
|
1889
|
+
].find((field) => !isFiniteNumber(value[field]));
|
|
1890
|
+
return missing === void 0 ? void 0 : `${where}.${missing} is not a finite number`;
|
|
1891
|
+
};
|
|
1892
|
+
const environmentFailure = (value) => {
|
|
1893
|
+
if (!isRecord(value)) return "environment is not an object";
|
|
1894
|
+
if (!(typeof value.cpuModel === "string" || value.cpuModel === null)) return "environment.cpuModel is neither a string nor null";
|
|
1895
|
+
if (!isFiniteNumber(value.cpuCount)) return "environment.cpuCount is not a finite number";
|
|
1896
|
+
};
|
|
1897
|
+
/** The count fields the writer emits; every one must be a finite number. */
|
|
1898
|
+
const COUNT_FIELDS = [
|
|
1899
|
+
"failed",
|
|
1900
|
+
"passed",
|
|
1901
|
+
"pending",
|
|
1902
|
+
"suites",
|
|
1903
|
+
"tests",
|
|
1904
|
+
"todo"
|
|
1905
|
+
];
|
|
1906
|
+
const runFailure = (value, index) => {
|
|
1907
|
+
if (!isRecord(value)) return `runs[${index}] is not an object`;
|
|
1908
|
+
if (!isFiniteNumber(value.durationMs)) return `runs[${index}].durationMs is not a finite number`;
|
|
1909
|
+
if (value.counts === null) return;
|
|
1910
|
+
if (!isRecord(value.counts)) return `runs[${index}].counts is neither an object nor null`;
|
|
1911
|
+
const { counts } = value;
|
|
1912
|
+
const missing = COUNT_FIELDS.find((field) => !isFiniteNumber(counts[field]));
|
|
1913
|
+
return missing === void 0 ? void 0 : `runs[${index}].counts.${missing} is not a finite number`;
|
|
1914
|
+
};
|
|
1915
|
+
const slowEntryFailure = (value, where, labelField) => {
|
|
1916
|
+
if (!isRecord(value)) return `${where} is not an object`;
|
|
1917
|
+
if (typeof value[labelField] !== "string") return `${where}.${labelField} is not a string`;
|
|
1918
|
+
if (labelField === "name" && typeof value.file !== "string") return `${where}.file is not a string`;
|
|
1919
|
+
return durationSummaryFailure(value.durationMs, `${where}.durationMs`);
|
|
1920
|
+
};
|
|
1921
|
+
const summaryFailure = (value) => {
|
|
1922
|
+
if (!isRecord(value)) return "summary is not an object";
|
|
1923
|
+
const durations = durationSummaryFailure(value.durationMs, "summary.durationMs");
|
|
1924
|
+
if (durations !== void 0) return durations;
|
|
1925
|
+
if (!Array.isArray(value.slowFiles)) return "summary.slowFiles is not an array";
|
|
1926
|
+
for (const [index, entry] of value.slowFiles.entries()) {
|
|
1927
|
+
const failure = slowEntryFailure(entry, `summary.slowFiles[${index}]`, "path");
|
|
1928
|
+
if (failure !== void 0) return failure;
|
|
1929
|
+
}
|
|
1930
|
+
if (!Array.isArray(value.slowTests)) return "summary.slowTests is not an array";
|
|
1931
|
+
for (const [index, entry] of value.slowTests.entries()) {
|
|
1932
|
+
const failure = slowEntryFailure(entry, `summary.slowTests[${index}]`, "name");
|
|
1933
|
+
if (failure !== void 0) return failure;
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
/**
|
|
1937
|
+
* Check one parsed JSON document against the profile contract the writer
|
|
1938
|
+
* emits. Returns the typed profile on success and the first mismatch reason
|
|
1939
|
+
* otherwise — never a partially-usable value.
|
|
1940
|
+
*/
|
|
1941
|
+
const readVitestProfileDocument = (value) => {
|
|
1942
|
+
if (!isRecord(value)) return {
|
|
1943
|
+
kind: "unrecognized",
|
|
1944
|
+
reason: "the document is not an object"
|
|
1945
|
+
};
|
|
1946
|
+
if (value.tool !== "factory-ci-vitest-profile") return {
|
|
1947
|
+
kind: "unrecognized",
|
|
1948
|
+
reason: `tool is ${JSON.stringify(value.tool)} rather than "${VITEST_PROFILE_TOOL}"`
|
|
1949
|
+
};
|
|
1950
|
+
if (value.schemaVersion !== 1) return {
|
|
1951
|
+
kind: "unrecognized",
|
|
1952
|
+
reason: `schemaVersion is ${JSON.stringify(value.schemaVersion)} rather than 1`
|
|
1953
|
+
};
|
|
1954
|
+
if (!(Array.isArray(value.command) && value.command.every((part) => typeof part === "string"))) return {
|
|
1955
|
+
kind: "unrecognized",
|
|
1956
|
+
reason: "command is not an array of strings"
|
|
1957
|
+
};
|
|
1958
|
+
const environment = environmentFailure(value.environment);
|
|
1959
|
+
if (environment !== void 0) return {
|
|
1960
|
+
kind: "unrecognized",
|
|
1961
|
+
reason: environment
|
|
1962
|
+
};
|
|
1963
|
+
if (!isRecord(value.options)) return {
|
|
1964
|
+
kind: "unrecognized",
|
|
1965
|
+
reason: "options is not an object"
|
|
1966
|
+
};
|
|
1967
|
+
if (!isFiniteNumber(value.options.maxWorkers)) return {
|
|
1968
|
+
kind: "unrecognized",
|
|
1969
|
+
reason: "options.maxWorkers is not a finite number"
|
|
1970
|
+
};
|
|
1971
|
+
if (!Array.isArray(value.runs)) return {
|
|
1972
|
+
kind: "unrecognized",
|
|
1973
|
+
reason: "runs is not an array"
|
|
1974
|
+
};
|
|
1975
|
+
for (const [index, run] of value.runs.entries()) {
|
|
1976
|
+
const failure = runFailure(run, index);
|
|
1977
|
+
if (failure !== void 0) return {
|
|
1978
|
+
kind: "unrecognized",
|
|
1979
|
+
reason: failure
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
const summary = summaryFailure(value.summary);
|
|
1983
|
+
if (summary !== void 0) return {
|
|
1984
|
+
kind: "unrecognized",
|
|
1985
|
+
reason: summary
|
|
1986
|
+
};
|
|
1987
|
+
return {
|
|
1988
|
+
kind: "profile",
|
|
1989
|
+
profile: value
|
|
1990
|
+
};
|
|
1991
|
+
};
|
|
1992
|
+
//#endregion
|
|
1993
|
+
//#region src/workflow-shell-lint.ts
|
|
1994
|
+
const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
|
|
1995
|
+
/**
|
|
1996
|
+
* GitHub evaluates `${{ }}` before bash ever sees the script, and what it
|
|
1997
|
+
* substitutes is not knowable here. Neutralizing each expression to one plain
|
|
1998
|
+
* word is what the runner's *shape* looks like: a value in argument position.
|
|
1999
|
+
* Leaving them in would make every workflow fail to parse; expanding them to
|
|
2000
|
+
* nothing would silently change quoting.
|
|
2001
|
+
*
|
|
2002
|
+
* Scanned rather than matched with a lazy regex, because `}}` occurs inside
|
|
2003
|
+
* Actions string literals: `format('refs/{{0}}', github.ref_name)` escapes a
|
|
2004
|
+
* literal brace pair that way, and stopping there would leave half an
|
|
2005
|
+
* expression in the script and report a parse error the runner never sees.
|
|
2006
|
+
*/
|
|
2007
|
+
const neutralizeExpressions = (script) => {
|
|
2008
|
+
let out = "";
|
|
2009
|
+
let cursor = 0;
|
|
2010
|
+
while (cursor < script.length) {
|
|
2011
|
+
const start = script.indexOf("${{", cursor);
|
|
2012
|
+
if (start === -1) {
|
|
2013
|
+
out += script.slice(cursor);
|
|
2014
|
+
break;
|
|
2015
|
+
}
|
|
2016
|
+
out += script.slice(cursor, start);
|
|
2017
|
+
let scan = start + 3;
|
|
2018
|
+
let quote;
|
|
2019
|
+
let end = -1;
|
|
2020
|
+
while (scan < script.length) {
|
|
2021
|
+
const char = script[scan];
|
|
2022
|
+
if (quote === void 0) {
|
|
2023
|
+
if (char === "'" || char === "\"") quote = char;
|
|
2024
|
+
else if (char === "}" && script[scan + 1] === "}") {
|
|
2025
|
+
end = scan + 2;
|
|
2026
|
+
break;
|
|
2027
|
+
}
|
|
2028
|
+
} else if (char === quote) if (script[scan + 1] === quote) scan += 1;
|
|
2029
|
+
else quote = void 0;
|
|
2030
|
+
scan += 1;
|
|
2031
|
+
}
|
|
2032
|
+
if (end === -1) {
|
|
2033
|
+
out += script.slice(start);
|
|
2034
|
+
break;
|
|
2035
|
+
}
|
|
2036
|
+
out += "FACTORY_ACTIONS_EXPRESSION";
|
|
2037
|
+
cursor = end;
|
|
2038
|
+
}
|
|
2039
|
+
return out;
|
|
2040
|
+
};
|
|
2041
|
+
/** Strip one layer of YAML single quoting from a scalar value. */
|
|
2042
|
+
const unquote = (raw) => {
|
|
2043
|
+
const value = raw.trim();
|
|
2044
|
+
if (value.startsWith("'") && value.endsWith("'") && value.length > 1) return value.slice(1, -1).replaceAll("''", "'");
|
|
2045
|
+
return value;
|
|
2046
|
+
};
|
|
2047
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
2048
|
+
/**
|
|
2049
|
+
* Where a mapping key sits, counting the `- ` sequence marker as indentation:
|
|
2050
|
+
* `- name:` and the `run:` below it are siblings in the same step even though
|
|
2051
|
+
* their raw columns differ by two.
|
|
2052
|
+
*/
|
|
2053
|
+
const keyIndentOf = (line) => indentOf(line) + (line.trimStart().startsWith("- ") ? 2 : 0);
|
|
2054
|
+
/** A sibling scalar of the `run:` key under inspection, when the line is one. */
|
|
2055
|
+
const keyValueAt = (line, indent, key) => {
|
|
2056
|
+
if (keyIndentOf(line) !== indent) return;
|
|
2057
|
+
const rest = line.trimStart().replace(/^-\s+/u, "");
|
|
2058
|
+
return rest.startsWith(`${key}:`) ? rest.slice(key.length + 1) : void 0;
|
|
2059
|
+
};
|
|
2060
|
+
/**
|
|
2061
|
+
* Every `defaults: { run: { shell } }` in the document, with the span it
|
|
2062
|
+
* governs: the mapping that declares it, which is the whole workflow at the
|
|
2063
|
+
* top level and one job under `jobs:`.
|
|
2064
|
+
*
|
|
2065
|
+
* GitHub resolves a step's interpreter as step `shell:`, then the job's
|
|
2066
|
+
* default, then the workflow's, then bash. A scanner that only looked at the
|
|
2067
|
+
* step would call every step in a `defaults.run.shell: sh` workflow bash and
|
|
2068
|
+
* report a pass for scripts `sh` cannot parse — the very false negative this
|
|
2069
|
+
* control exists to close.
|
|
2070
|
+
*/
|
|
2071
|
+
const defaultShellScopes = (lines) => {
|
|
2072
|
+
const scopes = [];
|
|
2073
|
+
for (const [index, line] of lines.entries()) {
|
|
2074
|
+
if (line.trim() !== "defaults:") continue;
|
|
2075
|
+
const depth = keyIndentOf(line);
|
|
2076
|
+
const shell = (() => {
|
|
2077
|
+
let inRun = false;
|
|
2078
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
2079
|
+
const candidate = lines[cursor];
|
|
2080
|
+
if (candidate.trim().length === 0) continue;
|
|
2081
|
+
if (keyIndentOf(candidate) <= depth) return;
|
|
2082
|
+
if (keyValueAt(candidate, depth + 2, "run") !== void 0) {
|
|
2083
|
+
inRun = true;
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
if (keyIndentOf(candidate) <= depth + 2) {
|
|
2087
|
+
inRun = false;
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
const value = inRun ? keyValueAt(candidate, depth + 4, "shell") : void 0;
|
|
2091
|
+
if (value !== void 0) return unquote(value);
|
|
2092
|
+
}
|
|
2093
|
+
})();
|
|
2094
|
+
if (shell === void 0) continue;
|
|
2095
|
+
let start = 0;
|
|
2096
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) if (lines[cursor].trim().length > 0 && keyIndentOf(lines[cursor]) < depth) {
|
|
2097
|
+
start = cursor;
|
|
2098
|
+
break;
|
|
2099
|
+
}
|
|
2100
|
+
let end = lines.length;
|
|
2101
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) if (lines[cursor].trim().length > 0 && keyIndentOf(lines[cursor]) < depth) {
|
|
2102
|
+
end = cursor;
|
|
2103
|
+
break;
|
|
2104
|
+
}
|
|
2105
|
+
scopes.push({
|
|
2106
|
+
depth,
|
|
2107
|
+
end,
|
|
2108
|
+
shell,
|
|
2109
|
+
start
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
return scopes;
|
|
2113
|
+
};
|
|
2114
|
+
/** The innermost `defaults.run.shell` governing a line, if any. */
|
|
2115
|
+
const inheritedShell = (scopes, index) => scopes.filter((scope) => index >= scope.start && index < scope.end).toSorted((left, right) => right.depth - left.depth).at(0)?.shell;
|
|
2116
|
+
/**
|
|
2117
|
+
* Every `run:` block in a generated workflow, paired with the `shell:` its
|
|
2118
|
+
* step declares.
|
|
2119
|
+
*
|
|
2120
|
+
* Deliberately a scanner over the emitted text and not a YAML parse: this
|
|
2121
|
+
* package takes no dependency it does not need, and the emitted shape is one
|
|
2122
|
+
* generator's output, not arbitrary YAML. It reads both block scalars
|
|
2123
|
+
* (`run: |-`) and inline scripts.
|
|
2124
|
+
*/
|
|
2125
|
+
const workflowRunBlocks = (yaml) => {
|
|
2126
|
+
const lines = yaml.split("\n");
|
|
2127
|
+
const scopes = defaultShellScopes(lines);
|
|
2128
|
+
const blocks = [];
|
|
2129
|
+
for (const [index, line] of lines.entries()) {
|
|
2130
|
+
const match = RUN_KEY.exec(line);
|
|
2131
|
+
if (match?.groups === void 0) continue;
|
|
2132
|
+
const keyIndent = keyIndentOf(line);
|
|
2133
|
+
const inline = match.groups.inline.trim();
|
|
2134
|
+
let script;
|
|
2135
|
+
let end = index;
|
|
2136
|
+
if (inline.startsWith(">")) throw new Error(`folded (\`run: >\`) scripts are not supported: YAML folds their line breaks into spaces, so what bash parses is not what is written. Use a literal block (\`run: |\`).`);
|
|
2137
|
+
if (inline.trimStart().startsWith("\"")) throw new Error(`double-quoted \`run:\` scalars are not supported: their YAML escapes would have to be decoded before bash sees them. Use a literal block (\`run: |\`) or an unquoted scalar.`);
|
|
2138
|
+
if (inline.startsWith("|")) {
|
|
2139
|
+
const body = [];
|
|
2140
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
2141
|
+
const candidate = lines[cursor];
|
|
2142
|
+
if (candidate.trim().length > 0 && indentOf(candidate) <= keyIndent) break;
|
|
2143
|
+
body.push(candidate);
|
|
2144
|
+
end = cursor;
|
|
2145
|
+
}
|
|
2146
|
+
const strip = Math.min(...body.filter((entry) => entry.trim().length > 0).map((entry) => indentOf(entry)));
|
|
2147
|
+
script = body.map((entry) => entry.slice(strip)).join("\n");
|
|
2148
|
+
} else if (inline.length > 0) script = unquote(inline);
|
|
2149
|
+
else continue;
|
|
2150
|
+
let shell;
|
|
2151
|
+
let step;
|
|
2152
|
+
const readSibling = (line_) => {
|
|
2153
|
+
shell ??= keyValueAt(line_, keyIndent, "shell");
|
|
2154
|
+
step ??= keyValueAt(line_, keyIndent, "name");
|
|
2155
|
+
};
|
|
2156
|
+
for (let cursor = index; cursor >= 0 && keyIndentOf(lines[cursor]) >= keyIndent; cursor -= 1) {
|
|
2157
|
+
readSibling(lines[cursor]);
|
|
2158
|
+
if (lines[cursor].trimStart().startsWith("- ")) break;
|
|
2159
|
+
}
|
|
2160
|
+
for (let cursor = end + 1; cursor < lines.length; cursor += 1) {
|
|
2161
|
+
if (keyIndentOf(lines[cursor]) < keyIndent || lines[cursor].trimStart().startsWith("- ")) break;
|
|
2162
|
+
readSibling(lines[cursor]);
|
|
2163
|
+
}
|
|
2164
|
+
const effectiveShell = shell === void 0 ? inheritedShell(scopes, index) : unquote(shell);
|
|
2165
|
+
blocks.push({
|
|
2166
|
+
script,
|
|
2167
|
+
...effectiveShell === void 0 ? {} : { shell: effectiveShell },
|
|
2168
|
+
...step === void 0 ? {} : { step: unquote(step) }
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
return blocks;
|
|
2172
|
+
};
|
|
2173
|
+
/** `NAME=value` in a shell command template, as `env` takes them. */
|
|
2174
|
+
const ASSIGNMENT = /^[A-Za-z_]\w*=/u;
|
|
2175
|
+
/**
|
|
2176
|
+
* Interpreters GitHub supports that this control deliberately leaves alone.
|
|
2177
|
+
* Named rather than inferred, so an unfamiliar command fails loudly instead of
|
|
2178
|
+
* being skipped as though it had been considered.
|
|
2179
|
+
*/
|
|
2180
|
+
const NON_SHELL_INTERPRETERS = new Set([
|
|
2181
|
+
"cmd",
|
|
2182
|
+
"powershell",
|
|
2183
|
+
"pwsh",
|
|
2184
|
+
"python",
|
|
2185
|
+
"python3"
|
|
2186
|
+
]);
|
|
2187
|
+
/**
|
|
2188
|
+
* The interpreter that will parse a step's script, or `undefined` for one this
|
|
2189
|
+
* control leaves alone.
|
|
2190
|
+
*
|
|
2191
|
+
* A step declaring no shell gets bash: that is GitHub's default for `run:` on
|
|
2192
|
+
* Linux runners. A step declaring `sh` gets `sh`, because the runner runs it
|
|
2193
|
+
* with `sh` — whose grammar is narrower than bash's, so parsing it with bash
|
|
2194
|
+
* would report a pass for a script the runner cannot run. Anything else
|
|
2195
|
+
* (pwsh, python, cmd) is not this control's business.
|
|
2196
|
+
*/
|
|
2197
|
+
const parserFor = (shell) => {
|
|
2198
|
+
if (shell === void 0) return ["bash"];
|
|
2199
|
+
const argv = [];
|
|
2200
|
+
let interpreter;
|
|
2201
|
+
for (const token of shell.trim().split(/\s+/u)) {
|
|
2202
|
+
if (token === "{0}") break;
|
|
2203
|
+
if (interpreter !== void 0) {
|
|
2204
|
+
argv.push(token);
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
const executable = token.slice(token.lastIndexOf("/") + 1);
|
|
2208
|
+
if (executable === "bash" || executable === "sh") interpreter = token;
|
|
2209
|
+
else if (!(executable === "env" || token.startsWith("-") || ASSIGNMENT.test(token))) {
|
|
2210
|
+
if (NON_SHELL_INTERPRETERS.has(executable)) return;
|
|
2211
|
+
throw new Error(`unrecognized \`shell:\` command: ${shell}. This control parses bash and sh scripts and knowingly skips pwsh, powershell, python, and cmd; it refuses rather than guess at anything else.`);
|
|
2212
|
+
}
|
|
2213
|
+
argv.push(token);
|
|
2214
|
+
}
|
|
2215
|
+
if (interpreter === void 0) throw new Error(`unrecognized \`shell:\` command: ${shell}. No interpreter to parse the script with.`);
|
|
2216
|
+
return argv;
|
|
2217
|
+
};
|
|
2218
|
+
/** Every `run:` block its interpreter refuses to parse. Empty means sound. */
|
|
2219
|
+
const workflowShellParseFailures = (yaml) => {
|
|
2220
|
+
const failures = [];
|
|
2221
|
+
for (const block of workflowRunBlocks(yaml)) {
|
|
2222
|
+
const parser = parserFor(block.shell);
|
|
2223
|
+
if (parser === void 0) continue;
|
|
2224
|
+
const [executable, ...parserArgs] = parser;
|
|
2225
|
+
const script = neutralizeExpressions(block.script);
|
|
2226
|
+
try {
|
|
2227
|
+
execFileSync(executable, [...parserArgs, "-n"], {
|
|
2228
|
+
input: script,
|
|
2229
|
+
stdio: [
|
|
2230
|
+
"pipe",
|
|
2231
|
+
"ignore",
|
|
2232
|
+
"pipe"
|
|
2233
|
+
]
|
|
2234
|
+
});
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
failures.push({
|
|
2237
|
+
script,
|
|
2238
|
+
...block.step === void 0 ? {} : { step: block.step },
|
|
2239
|
+
stderr: String(error.stderr ?? error).trim()
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
return failures;
|
|
2244
|
+
};
|
|
2245
|
+
/**
|
|
2246
|
+
* Fail the generated-workflow lint when any embedded `run:` block is not
|
|
2247
|
+
* parseable bash. Call it on the YAML a generator is about to write, so the
|
|
2248
|
+
* defect is caught at generation rather than by the runner.
|
|
2249
|
+
*/
|
|
2250
|
+
const assertWorkflowShellParses = (yaml, options) => {
|
|
2251
|
+
const failures = workflowShellParseFailures(yaml);
|
|
2252
|
+
if (failures.length === 0) return;
|
|
2253
|
+
const detail = failures.map((failure) => ` - ${failure.step ?? "unnamed step"}: ${failure.stderr.replaceAll("\n", "\n ")}`).join("\n");
|
|
2254
|
+
throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
|
|
2255
|
+
};
|
|
2256
|
+
//#endregion
|
|
2257
|
+
export { FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT, FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT, FACTORY_PRODUCTION_IMPACT_STEP_ID, FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, FACTORY_PROOF_TIMING_START_STEP_NAME, FACTORY_PROOF_TIMING_STEP_ID, FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME, FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX, FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID, FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID, FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID, FACTORY_PUSH_IDENTITY_RECORD_STEP_ID, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateOrPushCondition, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryScript, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|