@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 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
- const response = await request(url, {
1314
- body: init.body,
1315
- headers: {
1316
- Accept: "application/vnd.github+json",
1317
- Authorization: `Bearer ${token}`,
1318
- "X-GitHub-Api-Version": GITHUB_API_VERSION,
1319
- ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
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.20",
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.74.0",
41
+ "oxlint": "1.76.0",
41
42
  "tsdown": "0.21.10",
42
43
  "typescript": "5.9.3",
43
- "ultracite": "7.9.4",
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
@@ -50,6 +50,7 @@ export {
50
50
  type PreviewProofInventoryPersistInput,
51
51
  type PreviewProofInventoryStore,
52
52
  type PreviewProofInventoryTransport,
53
+ PreviewProofTransportError,
53
54
  } from "./preview-proof-inventory.ts";
54
55
  export {
55
56
  type CheckoutStepOptions,
@@ -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
- const response = await request(url, {
330
- body: init.body,
331
- headers: {
332
- Accept: "application/vnd.github+json",
333
- Authorization: `Bearer ${token}`,
334
- "X-GitHub-Api-Version": GITHUB_API_VERSION,
335
- ...(init.body === undefined
336
- ? {}
337
- : { "Content-Type": "application/json" }),
338
- },
339
- method: init.method,
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
- return (await response.json()) as unknown;
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
  };