@patronage/factory-ci 1.0.0-alpha.20 → 1.0.0-alpha.21
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 +2 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +101 -14
- package/package.json +5 -3
- package/src/index.ts +1 -0
- package/src/preview-proof-inventory.ts +155 -16
- package/src/vitest-profile-reader.ts +5 -0
package/README.md
CHANGED
|
@@ -397,6 +397,8 @@ Persistence is an injectable GitHub transport in the same style as `mintInstalla
|
|
|
397
397
|
|
|
398
398
|
Pass `{ store }` to substitute the GitHub adapter. `previewProofInventory.memoryStore()` is an in-memory `list`/`put` port for tests. `githubStore({ token, fetch, timeoutMs? })` is the GitHub-backed implementation. Stack names stay caller strings.
|
|
399
399
|
|
|
400
|
+
Transient transport failures — a network-level rejection such as EPIPE, a timeout, a 408/429/5xx, or a response body that dies mid-read — are retried with doubling backoff up to three transport attempts in total (the first attempt plus two retries), and then throw `PreviewProofTransportError` (attempt count, last transient status, credential-free reason); a permanent HTTP refusal throws `GitHubApiError` without retrying (#830). Either failure from `persist` or `recordCleanup` is an evidence-registration failure, not a failed deployment: retain the healthy disposable stage, record the proof BLOCKED (never PASS), and re-run registration at the same head — writes are keyed by stack and stage, so resuming needs no redeploy. Admission stays fail-closed on its own: an unregistered proof never satisfies the preview demand.
|
|
401
|
+
|
|
400
402
|
### GitHub App installation tokens
|
|
401
403
|
|
|
402
404
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -286,6 +286,15 @@ declare const previewProofLifecycle: {
|
|
|
286
286
|
* secrets. Cleanup writes go through `previewProofLifecycle.transition` with
|
|
287
287
|
* `{ type: "cleanup" }`. Outcomes are never inferred from whether a stage
|
|
288
288
|
* still exists.
|
|
289
|
+
*
|
|
290
|
+
* Transient transport failures are retried with bounded backoff and then
|
|
291
|
+
* surface as `PreviewProofTransportError` (#830). A failure from `persist` or
|
|
292
|
+
* `recordCleanup` is an evidence-registration failure, never a failed
|
|
293
|
+
* deployment: the caller retains the healthy disposable stage, records its own
|
|
294
|
+
* proof BLOCKED (never PASS), and re-runs registration at the same head —
|
|
295
|
+
* writes are keyed by stack and stage, so resuming needs no redeploy.
|
|
296
|
+
* Admission stays fail-closed on its own: an unregistered proof never
|
|
297
|
+
* satisfies the preview demand.
|
|
289
298
|
*/
|
|
290
299
|
/** Check-run name that carries one registration JSON payload. Not a gate. */
|
|
291
300
|
declare const PREVIEW_PROOF_INVENTORY_CHECK_NAME = "patronage-factory/preview-proof";
|
|
@@ -344,6 +353,21 @@ interface PreviewProofInventoryCleanupInput {
|
|
|
344
353
|
stack: string;
|
|
345
354
|
stage: string;
|
|
346
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* The transport gave out — a network-level rejection (EPIPE, DNS, an aborted
|
|
358
|
+
* or timed-out request) or a transient HTTP status — and bounded retries did
|
|
359
|
+
* not recover it. GitHub never judged the content, so this is never a
|
|
360
|
+
* validation verdict and never a deployment failure. Only a bounded reason
|
|
361
|
+
* string is retained: the raw transport error can hold the request and its
|
|
362
|
+
* `Authorization` header, so it is deliberately discarded.
|
|
363
|
+
*/
|
|
364
|
+
declare class PreviewProofTransportError extends Error {
|
|
365
|
+
/** How many transport attempts were made before giving up. */
|
|
366
|
+
readonly attempts: number;
|
|
367
|
+
/** The last transient HTTP status, when the server answered at all. */
|
|
368
|
+
readonly status?: number;
|
|
369
|
+
constructor(reason: string, attempts: number, status?: number);
|
|
370
|
+
}
|
|
347
371
|
declare const previewProofInventory: {
|
|
348
372
|
readonly githubStore: (transport: PreviewProofInventoryTransport) => PreviewProofInventoryStore;
|
|
349
373
|
readonly list: (input: PreviewProofInventoryListInput, access: PreviewProofInventoryAccess) => Promise<readonly PreviewProofRegistration[]>;
|
|
@@ -1342,4 +1366,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
|
|
|
1342
1366
|
readonly source: string;
|
|
1343
1367
|
}) => void;
|
|
1344
1368
|
//#endregion
|
|
1345
|
-
export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, 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_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, type FactoryCandidateImpactWorkflow, type FactoryCandidateImpactWorkflowOptions, type FactoryLifecycleContractLane, type FactoryLifecycleContractLaneOptions, type FactoryPrStatusHudWorkflow, type FactoryPrStatusHudWorkflowOptions, type FactoryPreviewCleanupAuditJob, type FactoryPreviewCleanupDestroyJob, type FactoryPreviewCleanupTopology, type FactoryPreviewCleanupTopologyOptions, type FactoryProductionImpactWorkflow, type FactoryProductionImpactWorkflowOptions, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, type FactoryProofReusePresentationOptions, type FactoryProofReuseSummaryStep, type FactoryProofTimingStartStep, type FactoryPushIdentityConsumer, type FactoryPushIdentityConsumerOptions, type FactoryPushIdentityDisposition, type FactoryPushIdentityEnvelope, type FactoryPushIdentityProducer, type FactoryPushIdentityProducerOptions, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, PREVIEW_PROOF_INVENTORY_CHECK_NAME, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type PreviewProofCandidate, type PreviewProofCleanupOutcome, type PreviewProofCleanupStatus, type PreviewProofEnvelopeEvidence, type PreviewProofInventoryAccess, type PreviewProofInventoryCleanupInput, type PreviewProofInventoryListInput, type PreviewProofInventoryPersistInput, type PreviewProofInventoryStore, type PreviewProofInventoryTransport, type PreviewProofLifecycleEvent, type PreviewProofLifecycleState, type PreviewProofRegistration, type PreviewProofResolveQuery, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, type VitestJsonReport, type VitestProfile, type VitestProfileDependencies, type VitestProfileDurationSummary, type VitestProfileEnvironment, VitestProfileError, type VitestProfileOptions, type VitestProfileReadResult, type VitestProfileSample, type VitestProfileSampleExecution, type VitestTestStatus, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|
|
1369
|
+
export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, 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_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, type FactoryCandidateImpactWorkflow, type FactoryCandidateImpactWorkflowOptions, type FactoryLifecycleContractLane, type FactoryLifecycleContractLaneOptions, type FactoryPrStatusHudWorkflow, type FactoryPrStatusHudWorkflowOptions, type FactoryPreviewCleanupAuditJob, type FactoryPreviewCleanupDestroyJob, type FactoryPreviewCleanupTopology, type FactoryPreviewCleanupTopologyOptions, type FactoryProductionImpactWorkflow, type FactoryProductionImpactWorkflowOptions, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, type FactoryProofReusePresentationOptions, type FactoryProofReuseSummaryStep, type FactoryProofTimingStartStep, type FactoryPushIdentityConsumer, type FactoryPushIdentityConsumerOptions, type FactoryPushIdentityDisposition, type FactoryPushIdentityEnvelope, type FactoryPushIdentityProducer, type FactoryPushIdentityProducerOptions, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, PREVIEW_PROOF_INVENTORY_CHECK_NAME, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type PreviewProofCandidate, type PreviewProofCleanupOutcome, type PreviewProofCleanupStatus, type PreviewProofEnvelopeEvidence, type PreviewProofInventoryAccess, type PreviewProofInventoryCleanupInput, type PreviewProofInventoryListInput, type PreviewProofInventoryPersistInput, type PreviewProofInventoryStore, type PreviewProofInventoryTransport, type PreviewProofLifecycleEvent, type PreviewProofLifecycleState, type PreviewProofRegistration, type PreviewProofResolveQuery, PreviewProofTransportError, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, type VitestJsonReport, type VitestProfile, type VitestProfileDependencies, type VitestProfileDurationSummary, type VitestProfileEnvironment, VitestProfileError, type VitestProfileOptions, type VitestProfileReadResult, type VitestProfileSample, type VitestProfileSampleExecution, type VitestTestStatus, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
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 { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
5
6
|
import { createSign, randomUUID } from "node:crypto";
|
|
6
7
|
import { readFileSync } from "node:fs";
|
|
7
8
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -1172,12 +1173,25 @@ const assertProofReuseCoverage = (input) => {
|
|
|
1172
1173
|
* secrets. Cleanup writes go through `previewProofLifecycle.transition` with
|
|
1173
1174
|
* `{ type: "cleanup" }`. Outcomes are never inferred from whether a stage
|
|
1174
1175
|
* still exists.
|
|
1176
|
+
*
|
|
1177
|
+
* Transient transport failures are retried with bounded backoff and then
|
|
1178
|
+
* surface as `PreviewProofTransportError` (#830). A failure from `persist` or
|
|
1179
|
+
* `recordCleanup` is an evidence-registration failure, never a failed
|
|
1180
|
+
* deployment: the caller retains the healthy disposable stage, records its own
|
|
1181
|
+
* proof BLOCKED (never PASS), and re-runs registration at the same head —
|
|
1182
|
+
* writes are keyed by stack and stage, so resuming needs no redeploy.
|
|
1183
|
+
* Admission stays fail-closed on its own: an unregistered proof never
|
|
1184
|
+
* satisfies the preview demand.
|
|
1175
1185
|
*/
|
|
1176
1186
|
/** Check-run name that carries one registration JSON payload. Not a gate. */
|
|
1177
1187
|
const PREVIEW_PROOF_INVENTORY_CHECK_NAME = "patronage-factory/preview-proof";
|
|
1178
1188
|
const INVENTORY_KIND = "patronage-factory-preview-proof";
|
|
1179
1189
|
const INVENTORY_SCHEMA_VERSION = 1;
|
|
1180
1190
|
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
1191
|
+
/** Total transport attempts per request, the first one included. */
|
|
1192
|
+
const TRANSPORT_RETRY_ATTEMPTS = 3;
|
|
1193
|
+
/** Delay before the first retry; each further retry doubles it. */
|
|
1194
|
+
const TRANSPORT_RETRY_BACKOFF_MS = 500;
|
|
1181
1195
|
const CHECK_RUNS_PER_PAGE = 100;
|
|
1182
1196
|
const COMMITS_PER_PAGE = 100;
|
|
1183
1197
|
const MAX_CHECK_RUN_PAGES = 10;
|
|
@@ -1309,20 +1323,93 @@ const memoryStore = () => {
|
|
|
1309
1323
|
}
|
|
1310
1324
|
};
|
|
1311
1325
|
};
|
|
1326
|
+
/**
|
|
1327
|
+
* The transport gave out — a network-level rejection (EPIPE, DNS, an aborted
|
|
1328
|
+
* or timed-out request) or a transient HTTP status — and bounded retries did
|
|
1329
|
+
* not recover it. GitHub never judged the content, so this is never a
|
|
1330
|
+
* validation verdict and never a deployment failure. Only a bounded reason
|
|
1331
|
+
* string is retained: the raw transport error can hold the request and its
|
|
1332
|
+
* `Authorization` header, so it is deliberately discarded.
|
|
1333
|
+
*/
|
|
1334
|
+
var PreviewProofTransportError = class extends Error {
|
|
1335
|
+
/** How many transport attempts were made before giving up. */
|
|
1336
|
+
attempts;
|
|
1337
|
+
/** The last transient HTTP status, when the server answered at all. */
|
|
1338
|
+
status;
|
|
1339
|
+
constructor(reason, attempts, status) {
|
|
1340
|
+
super(`Preview proof inventory transport failed after ${attempts} attempt(s): ${reason}`);
|
|
1341
|
+
this.name = "PreviewProofTransportError";
|
|
1342
|
+
this.attempts = attempts;
|
|
1343
|
+
if (status !== void 0) this.status = status;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
/**
|
|
1347
|
+
* Transient statuses are the ones where GitHub never judged the content — a
|
|
1348
|
+
* request timeout (408), a rate limit (429), or the server failing (5xx) — so
|
|
1349
|
+
* the same bytes can still succeed. Every other non-2xx is GitHub refusing
|
|
1350
|
+
* this request and repeats on retry.
|
|
1351
|
+
*/
|
|
1352
|
+
const isTransientStatus = (status) => status === 408 || status === 429 || status >= 500;
|
|
1353
|
+
/**
|
|
1354
|
+
* A bounded, credential-free description of a transport failure. The raw
|
|
1355
|
+
* error may retain the request and its `Authorization` header; only the name
|
|
1356
|
+
* and a truncated message survive.
|
|
1357
|
+
*/
|
|
1358
|
+
const boundedTransportReason = (error) => error instanceof Error ? `${error.name}: ${error.message}`.slice(0, 200) : "unknown transport error";
|
|
1359
|
+
const defaultTransportWait = (ms) => setTimeout$1(ms);
|
|
1360
|
+
let transportWait = defaultTransportWait;
|
|
1361
|
+
/**
|
|
1362
|
+
* One transport attempt. A transient failure is reported as an outcome so the
|
|
1363
|
+
* caller can retry; a permanent HTTP refusal throws `GitHubApiError` here.
|
|
1364
|
+
*/
|
|
1365
|
+
const githubJsonAttempt = async (request, url, token, init, timeoutMs) => {
|
|
1366
|
+
let response;
|
|
1367
|
+
try {
|
|
1368
|
+
response = await request(url, {
|
|
1369
|
+
body: init.body,
|
|
1370
|
+
headers: {
|
|
1371
|
+
Accept: "application/vnd.github+json",
|
|
1372
|
+
Authorization: `Bearer ${token}`,
|
|
1373
|
+
"X-GitHub-Api-Version": GITHUB_API_VERSION,
|
|
1374
|
+
...init.body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
1375
|
+
},
|
|
1376
|
+
method: init.method,
|
|
1377
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1378
|
+
});
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
return {
|
|
1381
|
+
ok: false,
|
|
1382
|
+
reason: boundedTransportReason(error)
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
if (!response.ok) {
|
|
1386
|
+
if (!isTransientStatus(response.status)) throw new GitHubApiError(response.status, response.statusText);
|
|
1387
|
+
return {
|
|
1388
|
+
ok: false,
|
|
1389
|
+
reason: `GitHub API ${response.status} ${response.statusText}`,
|
|
1390
|
+
status: response.status
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
try {
|
|
1394
|
+
return {
|
|
1395
|
+
ok: true,
|
|
1396
|
+
value: await response.json()
|
|
1397
|
+
};
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
return {
|
|
1400
|
+
ok: false,
|
|
1401
|
+
reason: boundedTransportReason(error)
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1312
1405
|
const githubJson = async (request, url, token, init, timeoutMs) => {
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
},
|
|
1321
|
-
method: init.method,
|
|
1322
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
1323
|
-
});
|
|
1324
|
-
if (!response.ok) throw new GitHubApiError(response.status, response.statusText);
|
|
1325
|
-
return await response.json();
|
|
1406
|
+
let outcome = await githubJsonAttempt(request, url, token, init, timeoutMs);
|
|
1407
|
+
for (let attempt = 2; attempt <= TRANSPORT_RETRY_ATTEMPTS && !outcome.ok; attempt += 1) {
|
|
1408
|
+
await transportWait(TRANSPORT_RETRY_BACKOFF_MS * 2 ** (attempt - 2));
|
|
1409
|
+
outcome = await githubJsonAttempt(request, url, token, init, timeoutMs);
|
|
1410
|
+
}
|
|
1411
|
+
if (outcome.ok) return outcome.value;
|
|
1412
|
+
throw new PreviewProofTransportError(outcome.reason, TRANSPORT_RETRY_ATTEMPTS, outcome.status);
|
|
1326
1413
|
};
|
|
1327
1414
|
const githubGraphql = async (request, token, timeoutMs, query, variables) => {
|
|
1328
1415
|
const served = await githubJson(request, "https://api.github.com/graphql", token, {
|
|
@@ -3199,4 +3286,4 @@ const assertWorkflowShellParses = (yaml, options) => {
|
|
|
3199
3286
|
throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
|
|
3200
3287
|
};
|
|
3201
3288
|
//#endregion
|
|
3202
|
-
export { EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, 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_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, PREVIEW_PROOF_INVENTORY_CHECK_NAME, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|
|
3289
|
+
export { EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, 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_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, PREVIEW_PROOF_INVENTORY_CHECK_NAME, PreviewProofTransportError, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patronage/factory-ci",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.21",
|
|
4
4
|
"description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"alchemy",
|
|
@@ -36,17 +36,19 @@
|
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "24.13.3",
|
|
38
38
|
"@vitest/coverage-v8": "4.1.10",
|
|
39
|
+
"knip": "6.27.0",
|
|
39
40
|
"oxfmt": "0.59.0",
|
|
40
|
-
"oxlint": "1.
|
|
41
|
+
"oxlint": "1.76.0",
|
|
41
42
|
"tsdown": "0.21.10",
|
|
42
43
|
"typescript": "5.9.3",
|
|
43
|
-
"ultracite": "7.
|
|
44
|
+
"ultracite": "7.10.5",
|
|
44
45
|
"vitest": "4.1.10"
|
|
45
46
|
},
|
|
46
47
|
"engines": {
|
|
47
48
|
"node": "^24.0.0"
|
|
48
49
|
},
|
|
49
50
|
"scripts": {
|
|
51
|
+
"cleanup:check": "knip --files --dependencies",
|
|
50
52
|
"prebuild": "bash ../scripts/ensure-worktree-bootstrap.sh",
|
|
51
53
|
"build": "tsdown",
|
|
52
54
|
"precheck": "bash ../scripts/ensure-worktree-bootstrap.sh",
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
|
|
1
3
|
import { parseLocalPreviewStage } from "./disposable-stage.ts";
|
|
2
4
|
import { GitHubApiError } from "./github-app-token.ts";
|
|
3
5
|
import type {
|
|
@@ -18,6 +20,15 @@ import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
|
|
|
18
20
|
* secrets. Cleanup writes go through `previewProofLifecycle.transition` with
|
|
19
21
|
* `{ type: "cleanup" }`. Outcomes are never inferred from whether a stage
|
|
20
22
|
* still exists.
|
|
23
|
+
*
|
|
24
|
+
* Transient transport failures are retried with bounded backoff and then
|
|
25
|
+
* surface as `PreviewProofTransportError` (#830). A failure from `persist` or
|
|
26
|
+
* `recordCleanup` is an evidence-registration failure, never a failed
|
|
27
|
+
* deployment: the caller retains the healthy disposable stage, records its own
|
|
28
|
+
* proof BLOCKED (never PASS), and re-runs registration at the same head —
|
|
29
|
+
* writes are keyed by stack and stage, so resuming needs no redeploy.
|
|
30
|
+
* Admission stays fail-closed on its own: an unregistered proof never
|
|
31
|
+
* satisfies the preview demand.
|
|
21
32
|
*/
|
|
22
33
|
|
|
23
34
|
/** Check-run name that carries one registration JSON payload. Not a gate. */
|
|
@@ -27,6 +38,10 @@ export const PREVIEW_PROOF_INVENTORY_CHECK_NAME =
|
|
|
27
38
|
const INVENTORY_KIND = "patronage-factory-preview-proof";
|
|
28
39
|
const INVENTORY_SCHEMA_VERSION = 1;
|
|
29
40
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
41
|
+
/** Total transport attempts per request, the first one included. */
|
|
42
|
+
const TRANSPORT_RETRY_ATTEMPTS = 3;
|
|
43
|
+
/** Delay before the first retry; each further retry doubles it. */
|
|
44
|
+
const TRANSPORT_RETRY_BACKOFF_MS = 500;
|
|
30
45
|
const CHECK_RUNS_PER_PAGE = 100;
|
|
31
46
|
const COMMITS_PER_PAGE = 100;
|
|
32
47
|
const MAX_CHECK_RUN_PAGES = 10;
|
|
@@ -319,6 +334,126 @@ export const memoryStore = (): PreviewProofInventoryStore => {
|
|
|
319
334
|
};
|
|
320
335
|
};
|
|
321
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The transport gave out — a network-level rejection (EPIPE, DNS, an aborted
|
|
339
|
+
* or timed-out request) or a transient HTTP status — and bounded retries did
|
|
340
|
+
* not recover it. GitHub never judged the content, so this is never a
|
|
341
|
+
* validation verdict and never a deployment failure. Only a bounded reason
|
|
342
|
+
* string is retained: the raw transport error can hold the request and its
|
|
343
|
+
* `Authorization` header, so it is deliberately discarded.
|
|
344
|
+
*/
|
|
345
|
+
export class PreviewProofTransportError extends Error {
|
|
346
|
+
/** How many transport attempts were made before giving up. */
|
|
347
|
+
readonly attempts: number;
|
|
348
|
+
/** The last transient HTTP status, when the server answered at all. */
|
|
349
|
+
readonly status?: number;
|
|
350
|
+
|
|
351
|
+
constructor(reason: string, attempts: number, status?: number) {
|
|
352
|
+
super(
|
|
353
|
+
`Preview proof inventory transport failed after ${attempts} attempt(s): ${reason}`
|
|
354
|
+
);
|
|
355
|
+
this.name = "PreviewProofTransportError";
|
|
356
|
+
this.attempts = attempts;
|
|
357
|
+
if (status !== undefined) {
|
|
358
|
+
this.status = status;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Transient statuses are the ones where GitHub never judged the content — a
|
|
365
|
+
* request timeout (408), a rate limit (429), or the server failing (5xx) — so
|
|
366
|
+
* the same bytes can still succeed. Every other non-2xx is GitHub refusing
|
|
367
|
+
* this request and repeats on retry.
|
|
368
|
+
*/
|
|
369
|
+
const isTransientStatus = (status: number): boolean =>
|
|
370
|
+
status === 408 || status === 429 || status >= 500;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A bounded, credential-free description of a transport failure. The raw
|
|
374
|
+
* error may retain the request and its `Authorization` header; only the name
|
|
375
|
+
* and a truncated message survive.
|
|
376
|
+
*/
|
|
377
|
+
const boundedTransportReason = (error: unknown): string =>
|
|
378
|
+
error instanceof Error
|
|
379
|
+
? `${error.name}: ${error.message}`.slice(0, 200)
|
|
380
|
+
: "unknown transport error";
|
|
381
|
+
|
|
382
|
+
type TransportWait = (ms: number) => Promise<void>;
|
|
383
|
+
|
|
384
|
+
const defaultTransportWait: TransportWait = (ms) => sleep(ms);
|
|
385
|
+
|
|
386
|
+
let transportWait: TransportWait = defaultTransportWait;
|
|
387
|
+
|
|
388
|
+
/** Package-test seam. Not re-exported from `index.ts`. */
|
|
389
|
+
export const setPreviewProofTransportWaitForTests = (
|
|
390
|
+
next?: TransportWait
|
|
391
|
+
): void => {
|
|
392
|
+
transportWait = next ?? defaultTransportWait;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* One GitHub JSON request with bounded retries (#830). Each attempt gets a
|
|
397
|
+
* fresh timeout signal. A transient failure — network rejection, timeout,
|
|
398
|
+
* transient HTTP status, or a body that dies mid-read — retries with doubling
|
|
399
|
+
* backoff and exhausts into `PreviewProofTransportError`; a permanent HTTP
|
|
400
|
+
* refusal throws `GitHubApiError` on its first response. Retrying a write is
|
|
401
|
+
* safe: check-run payloads are idempotent per stack and stage, and a
|
|
402
|
+
* duplicated create is resolved by the newest-run read
|
|
403
|
+
* (`compareCheckRunsNewestFirst`).
|
|
404
|
+
*/
|
|
405
|
+
type TransportOutcome =
|
|
406
|
+
| { ok: true; value: unknown }
|
|
407
|
+
| { ok: false; reason: string; status?: number };
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* One transport attempt. A transient failure is reported as an outcome so the
|
|
411
|
+
* caller can retry; a permanent HTTP refusal throws `GitHubApiError` here.
|
|
412
|
+
*/
|
|
413
|
+
const githubJsonAttempt = async (
|
|
414
|
+
request: typeof fetch,
|
|
415
|
+
url: string,
|
|
416
|
+
token: string,
|
|
417
|
+
init: { body?: string; method: "GET" | "PATCH" | "POST" },
|
|
418
|
+
timeoutMs: number
|
|
419
|
+
): Promise<TransportOutcome> => {
|
|
420
|
+
let response: Response;
|
|
421
|
+
try {
|
|
422
|
+
response = await request(url, {
|
|
423
|
+
body: init.body,
|
|
424
|
+
headers: {
|
|
425
|
+
Accept: "application/vnd.github+json",
|
|
426
|
+
Authorization: `Bearer ${token}`,
|
|
427
|
+
"X-GitHub-Api-Version": GITHUB_API_VERSION,
|
|
428
|
+
...(init.body === undefined
|
|
429
|
+
? {}
|
|
430
|
+
: { "Content-Type": "application/json" }),
|
|
431
|
+
},
|
|
432
|
+
method: init.method,
|
|
433
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
434
|
+
});
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return { ok: false, reason: boundedTransportReason(error) };
|
|
437
|
+
}
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
if (!isTransientStatus(response.status)) {
|
|
440
|
+
throw new GitHubApiError(response.status, response.statusText);
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
reason: `GitHub API ${response.status} ${response.statusText}`,
|
|
445
|
+
status: response.status,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
return { ok: true, value: (await response.json()) as unknown };
|
|
450
|
+
} catch (error) {
|
|
451
|
+
// A 2xx whose body dies mid-read is the transport failing, not GitHub
|
|
452
|
+
// refusing: the EPIPE that motivated #830 lands exactly here.
|
|
453
|
+
return { ok: false, reason: boundedTransportReason(error) };
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
322
457
|
const githubJson = async (
|
|
323
458
|
request: typeof fetch,
|
|
324
459
|
url: string,
|
|
@@ -326,23 +461,27 @@ const githubJson = async (
|
|
|
326
461
|
init: { body?: string; method: "GET" | "PATCH" | "POST" },
|
|
327
462
|
timeoutMs: number
|
|
328
463
|
): Promise<unknown> => {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
341
|
-
});
|
|
342
|
-
if (!response.ok) {
|
|
343
|
-
throw new GitHubApiError(response.status, response.statusText);
|
|
464
|
+
let outcome = await githubJsonAttempt(request, url, token, init, timeoutMs);
|
|
465
|
+
|
|
466
|
+
for (
|
|
467
|
+
let attempt = 2;
|
|
468
|
+
attempt <= TRANSPORT_RETRY_ATTEMPTS && !outcome.ok;
|
|
469
|
+
attempt += 1
|
|
470
|
+
) {
|
|
471
|
+
// oxlint-disable-next-line no-await-in-loop -- bounded backoff between sequential retries
|
|
472
|
+
await transportWait(TRANSPORT_RETRY_BACKOFF_MS * 2 ** (attempt - 2));
|
|
473
|
+
// oxlint-disable-next-line no-await-in-loop -- retries are sequential by design
|
|
474
|
+
outcome = await githubJsonAttempt(request, url, token, init, timeoutMs);
|
|
344
475
|
}
|
|
345
|
-
|
|
476
|
+
|
|
477
|
+
if (outcome.ok) {
|
|
478
|
+
return outcome.value;
|
|
479
|
+
}
|
|
480
|
+
throw new PreviewProofTransportError(
|
|
481
|
+
outcome.reason,
|
|
482
|
+
TRANSPORT_RETRY_ATTEMPTS,
|
|
483
|
+
outcome.status
|
|
484
|
+
);
|
|
346
485
|
};
|
|
347
486
|
|
|
348
487
|
const githubGraphql = async (
|
|
@@ -216,5 +216,10 @@ export const readVitestProfileDocument = (
|
|
|
216
216
|
return { kind: "unrecognized", reason: summary };
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
// Every field above is checked against the profile contract, but the checks
|
|
220
|
+
// return reasons rather than narrowing `value`. Rebuilding the document
|
|
221
|
+
// field-by-field here would copy the writer's shape into the reader and let
|
|
222
|
+
// the two drift. Keep the one double assertion; the checks are the proof.
|
|
223
|
+
// oxlint-disable-next-line anti-slop/no-chained-type-assertions
|
|
219
224
|
return { kind: "profile", profile: value as unknown as VitestProfile };
|
|
220
225
|
};
|