@patronage/factory-ci 1.0.0-alpha.19 → 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";
@@ -307,9 +316,11 @@ interface PreviewProofInventoryTransport {
307
316
  /** Per-request timeout; defaults to five seconds. */
308
317
  timeoutMs?: number;
309
318
  /**
310
- * Bearer token. `list` accepts a workflow `GITHUB_TOKEN` (read). `persist`
311
- * and `recordCleanup` must use a Factory App installation token: GitHub
312
- * refuses GITHUB_TOKEN writes to App-owned check-runs.
319
+ * Bearer token. `list` accepts a workflow `GITHUB_TOKEN` (read) and needs
320
+ * commit (contents) read for the force-push timeline; the contents-less
321
+ * Factory App token cannot serve it (#825). `persist` and `recordCleanup`
322
+ * must use a Factory App installation token: GitHub refuses GITHUB_TOKEN
323
+ * writes to App-owned check-runs.
313
324
  */
314
325
  token: string;
315
326
  }
@@ -342,6 +353,21 @@ interface PreviewProofInventoryCleanupInput {
342
353
  stack: string;
343
354
  stage: string;
344
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
+ }
345
371
  declare const previewProofInventory: {
346
372
  readonly githubStore: (transport: PreviewProofInventoryTransport) => PreviewProofInventoryStore;
347
373
  readonly list: (input: PreviewProofInventoryListInput, access: PreviewProofInventoryAccess) => Promise<readonly PreviewProofRegistration[]>;
@@ -919,6 +945,16 @@ interface FactoryPrStatusHudWorkflowOptions {
919
945
  readonly cli?: string;
920
946
  readonly createGithubAppToken: PinnedAction;
921
947
  }
948
+ /**
949
+ * Least privilege for the job that runs the present step. A `permissions`
950
+ * block zeroes every unlisted GITHUB_TOKEN scope, and the inventory list
951
+ * reads pull requests, commits, and check-runs with that token (#825).
952
+ */
953
+ declare const FACTORY_PR_STATUS_HUD_PERMISSIONS: Readonly<{
954
+ readonly checks: "read";
955
+ readonly contents: "read";
956
+ readonly "pull-requests": "read";
957
+ }>;
922
958
  declare const FACTORY_PR_STATUS_HUD_CONCURRENCY: Readonly<{
923
959
  cancelInProgress: false;
924
960
  group: "status-hud-${{ github.repository }}-${{ github.event.pull_request.number }}";
@@ -928,6 +964,7 @@ interface FactoryPrStatusHudWorkflow {
928
964
  readonly if: typeof FACTORY_PR_STATUS_HUD_IF;
929
965
  readonly jobId: typeof FACTORY_PR_STATUS_HUD_JOB_ID;
930
966
  readonly jobName: typeof FACTORY_PR_STATUS_HUD_JOB_NAME;
967
+ readonly permissions: typeof FACTORY_PR_STATUS_HUD_PERMISSIONS;
931
968
  readonly presentSteps: readonly WorkflowStep[];
932
969
  }
933
970
  declare const factoryPrStatusHudWorkflow: (options: FactoryPrStatusHudWorkflowOptions) => FactoryPrStatusHudWorkflow;
@@ -1329,4 +1366,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
1329
1366
  readonly source: string;
1330
1367
  }) => void;
1331
1368
  //#endregion
1332
- 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_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, {
@@ -1333,7 +1420,10 @@ const githubGraphql = async (request, token, timeoutMs, query, variables) => {
1333
1420
  method: "POST"
1334
1421
  }, timeoutMs);
1335
1422
  if (!isRecord$1(served)) throw new TypeError("GitHub GraphQL response was not an object");
1336
- if (served.errors !== void 0) throw new Error("Preview proof inventory GraphQL query failed.");
1423
+ if (served.errors !== void 0) {
1424
+ const details = Array.isArray(served.errors) ? served.errors.flatMap((entry) => isRecord$1(entry) ? [[entry.type, entry.message].filter((part) => typeof part === "string").join(": ")] : []).filter((detail) => detail.length > 0).join("; ") : "";
1425
+ throw new Error(details.length > 0 ? `Preview proof inventory GraphQL query failed: ${details}` : "Preview proof inventory GraphQL query failed.");
1426
+ }
1337
1427
  return served.data;
1338
1428
  };
1339
1429
  const repoApi = (owner, repo) => `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
@@ -1447,27 +1537,37 @@ const listPullRequestCommitShas = (request, token, timeoutMs, owner, repo, pr) =
1447
1537
  if (!Array.isArray(commits)) throw new TypeError("GitHub pull request commits response was not an array");
1448
1538
  return commits.flatMap((commit) => isRecord$1(commit) && typeof commit.sha === "string" ? [commit.sha] : []);
1449
1539
  }, COMMITS_PER_PAGE, MAX_COMMIT_PAGES, "Preview proof inventory commit listing exhausted pagination before GitHub returned a final page.");
1540
+ const readPullBaseSha = (pull) => {
1541
+ if (!isRecord$1(pull.base) || typeof pull.base.sha !== "string") throw new TypeError("GitHub pull request response omitted base.sha");
1542
+ return pull.base.sha;
1543
+ };
1450
1544
  /**
1451
- * Current head, replaced heads from force-push, and PR commits. The
1452
- * pull-commits endpoint silently stops at 250 and never lists a rebased-away
1453
- * head, so those two cases use compare pagination and the force-push timeline.
1545
+ * Current head, replaced heads from force-push, the commits each replaced head
1546
+ * carried, and PR commits. The pull-commits endpoint silently stops at 250 and
1547
+ * never lists a rebased-away head, so those two cases use compare pagination
1548
+ * and the force-push timeline. A persist, then a fast-forward, then a rebase
1549
+ * leaves the persist SHA behind the replaced head, so each replaced head is
1550
+ * also walked back to the base (#796).
1454
1551
  */
1455
1552
  const listCommitShasForPull = async (request, token, timeoutMs, owner, repo, pr) => {
1456
1553
  const pull = await githubJson(request, `${repoApi(owner, repo)}/pulls/${pr}`, token, { method: "GET" }, timeoutMs);
1457
1554
  if (!isRecord$1(pull) || !isRecord$1(pull.head) || typeof pull.head.sha !== "string") throw new TypeError("GitHub pull request response omitted head.sha");
1458
1555
  const pullCommits = await listPullRequestCommitShas(request, token, timeoutMs, owner, repo, pr);
1459
1556
  let older = pullCommits;
1460
- if (pullCommits.length >= GITHUB_PULL_COMMITS_CAP) {
1461
- if (!isRecord$1(pull.base) || typeof pull.base.sha !== "string") throw new TypeError("GitHub pull request response omitted base.sha");
1462
- older = await listCompareCommitShas(request, token, timeoutMs, owner, repo, pull.base.sha, pull.head.sha);
1463
- }
1557
+ if (pullCommits.length >= GITHUB_PULL_COMMITS_CAP) older = await listCompareCommitShas(request, token, timeoutMs, owner, repo, readPullBaseSha(pull), pull.head.sha);
1464
1558
  const previousHeads = await listForcePushBeforeShas(request, token, timeoutMs, owner, repo, pr);
1559
+ let replaced = [];
1560
+ if (previousHeads.length > 0) {
1561
+ const baseSha = readPullBaseSha(pull);
1562
+ replaced = (await mapPool(previousHeads, 8, (previousHead) => listCompareCommitShas(request, token, timeoutMs, owner, repo, baseSha, previousHead))).flatMap((history) => [...history].toReversed());
1563
+ }
1465
1564
  const shas = [];
1466
1565
  const seen = /* @__PURE__ */ new Set();
1467
1566
  for (const sha of [
1468
1567
  pull.head.sha,
1469
1568
  ...previousHeads,
1470
- ...older.toReversed()
1569
+ ...older.toReversed(),
1570
+ ...replaced
1471
1571
  ]) if (!seen.has(sha)) {
1472
1572
  seen.add(sha);
1473
1573
  shas.push(sha);
@@ -2017,6 +2117,16 @@ const FACTORY_PR_STATUS_HUD_PLAN_PATH = ".factory-memory/preview-plan.json";
2017
2117
  * skipped or withdrawn deploy still publishes Skipped rows.
2018
2118
  */
2019
2119
  const FACTORY_PR_STATUS_HUD_IF = "always() && github.event_name == 'pull_request' && github.event.pull_request.draft != true";
2120
+ /**
2121
+ * Least privilege for the job that runs the present step. A `permissions`
2122
+ * block zeroes every unlisted GITHUB_TOKEN scope, and the inventory list
2123
+ * reads pull requests, commits, and check-runs with that token (#825).
2124
+ */
2125
+ const FACTORY_PR_STATUS_HUD_PERMISSIONS = Object.freeze({
2126
+ checks: "read",
2127
+ contents: "read",
2128
+ "pull-requests": "read"
2129
+ });
2020
2130
  const FACTORY_PR_STATUS_HUD_CONCURRENCY = Object.freeze({
2021
2131
  cancelInProgress: false,
2022
2132
  group: `status-hud-\${{ github.repository }}-\${{ github.event.pull_request.number }}`
@@ -2040,7 +2150,8 @@ const factoryPrStatusHudWorkflow = (options) => {
2040
2150
  FACTORY_HEAD_SHA: `\${{ github.event.pull_request.head.sha }}`,
2041
2151
  FACTORY_OWNER: `\${{ github.repository_owner }}`,
2042
2152
  FACTORY_PR: `\${{ github.event.pull_request.number }}`,
2043
- FACTORY_REPO: `\${{ github.event.repository.name }}`
2153
+ FACTORY_REPO: `\${{ github.event.repository.name }}`,
2154
+ GITHUB_TOKEN: `\${{ github.token }}`
2044
2155
  }),
2045
2156
  name: "Present the PR status HUD",
2046
2157
  run: `${cli} pr:status-hud --head "$FACTORY_HEAD_SHA" --owner "$FACTORY_OWNER" --repo "$FACTORY_REPO" --pr "$FACTORY_PR"`
@@ -2050,6 +2161,7 @@ const factoryPrStatusHudWorkflow = (options) => {
2050
2161
  if: FACTORY_PR_STATUS_HUD_IF,
2051
2162
  jobId: FACTORY_PR_STATUS_HUD_JOB_ID,
2052
2163
  jobName: FACTORY_PR_STATUS_HUD_JOB_NAME,
2164
+ permissions: FACTORY_PR_STATUS_HUD_PERMISSIONS,
2053
2165
  presentSteps: Object.freeze([mintStep, presentStep])
2054
2166
  });
2055
2167
  };
@@ -3174,4 +3286,4 @@ const assertWorkflowShellParses = (yaml, options) => {
3174
3286
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
3175
3287
  };
3176
3288
  //#endregion
3177
- 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_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.19",
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,
@@ -130,6 +131,7 @@ export {
130
131
  FACTORY_PR_STATUS_HUD_IF,
131
132
  FACTORY_PR_STATUS_HUD_JOB_ID,
132
133
  FACTORY_PR_STATUS_HUD_JOB_NAME,
134
+ FACTORY_PR_STATUS_HUD_PERMISSIONS,
133
135
  FACTORY_PR_STATUS_HUD_PLAN_PATH,
134
136
  type FactoryPrStatusHudWorkflow,
135
137
  type FactoryPrStatusHudWorkflowOptions,
@@ -22,6 +22,17 @@ export interface FactoryPrStatusHudWorkflowOptions {
22
22
  readonly createGithubAppToken: PinnedAction;
23
23
  }
24
24
 
25
+ /**
26
+ * Least privilege for the job that runs the present step. A `permissions`
27
+ * block zeroes every unlisted GITHUB_TOKEN scope, and the inventory list
28
+ * reads pull requests, commits, and check-runs with that token (#825).
29
+ */
30
+ export const FACTORY_PR_STATUS_HUD_PERMISSIONS = Object.freeze({
31
+ checks: "read",
32
+ contents: "read",
33
+ "pull-requests": "read",
34
+ } as const);
35
+
25
36
  export const FACTORY_PR_STATUS_HUD_CONCURRENCY = Object.freeze({
26
37
  cancelInProgress: false,
27
38
  group: `status-hud-\${{ github.repository }}-\${{ github.event.pull_request.number }}`,
@@ -32,6 +43,7 @@ export interface FactoryPrStatusHudWorkflow {
32
43
  readonly if: typeof FACTORY_PR_STATUS_HUD_IF;
33
44
  readonly jobId: typeof FACTORY_PR_STATUS_HUD_JOB_ID;
34
45
  readonly jobName: typeof FACTORY_PR_STATUS_HUD_JOB_NAME;
46
+ readonly permissions: typeof FACTORY_PR_STATUS_HUD_PERMISSIONS;
35
47
  readonly presentSteps: readonly WorkflowStep[];
36
48
  }
37
49
 
@@ -57,6 +69,9 @@ export const factoryPrStatusHudWorkflow = (
57
69
  "private-key": `\${{ secrets.FACTORY_GITHUB_APP_PRIVATE_KEY }}`,
58
70
  }),
59
71
  });
72
+ // The App token owns the HUD comment but has no contents permission, so
73
+ // the force-push timeline read (#819) is FORBIDDEN for it. The workflow
74
+ // token carries the job's `contents: read` and serves that read (#825).
60
75
  const presentStep: WorkflowStep = Object.freeze({
61
76
  env: Object.freeze({
62
77
  FACTORY_GITHUB_APP_TOKEN: `\${{ steps.${FACTORY_PR_STATUS_HUD_APP_TOKEN_STEP_ID}.outputs.token }}`,
@@ -64,6 +79,7 @@ export const factoryPrStatusHudWorkflow = (
64
79
  FACTORY_OWNER: `\${{ github.repository_owner }}`,
65
80
  FACTORY_PR: `\${{ github.event.pull_request.number }}`,
66
81
  FACTORY_REPO: `\${{ github.event.repository.name }}`,
82
+ GITHUB_TOKEN: `\${{ github.token }}`,
67
83
  }),
68
84
  name: "Present the PR status HUD",
69
85
  run: `${cli} pr:status-hud --head "$FACTORY_HEAD_SHA" --owner "$FACTORY_OWNER" --repo "$FACTORY_REPO" --pr "$FACTORY_PR"`,
@@ -74,6 +90,7 @@ export const factoryPrStatusHudWorkflow = (
74
90
  if: FACTORY_PR_STATUS_HUD_IF,
75
91
  jobId: FACTORY_PR_STATUS_HUD_JOB_ID,
76
92
  jobName: FACTORY_PR_STATUS_HUD_JOB_NAME,
93
+ permissions: FACTORY_PR_STATUS_HUD_PERMISSIONS,
77
94
  presentSteps: Object.freeze([mintStep, presentStep]),
78
95
  });
79
96
  };
@@ -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;
@@ -74,9 +89,11 @@ export interface PreviewProofInventoryTransport {
74
89
  /** Per-request timeout; defaults to five seconds. */
75
90
  timeoutMs?: number;
76
91
  /**
77
- * Bearer token. `list` accepts a workflow `GITHUB_TOKEN` (read). `persist`
78
- * and `recordCleanup` must use a Factory App installation token: GitHub
79
- * refuses GITHUB_TOKEN writes to App-owned check-runs.
92
+ * Bearer token. `list` accepts a workflow `GITHUB_TOKEN` (read) and needs
93
+ * commit (contents) read for the force-push timeline; the contents-less
94
+ * Factory App token cannot serve it (#825). `persist` and `recordCleanup`
95
+ * must use a Factory App installation token: GitHub refuses GITHUB_TOKEN
96
+ * writes to App-owned check-runs.
80
97
  */
81
98
  token: string;
82
99
  }
@@ -317,6 +334,126 @@ export const memoryStore = (): PreviewProofInventoryStore => {
317
334
  };
318
335
  };
319
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
+
320
457
  const githubJson = async (
321
458
  request: typeof fetch,
322
459
  url: string,
@@ -324,23 +461,27 @@ const githubJson = async (
324
461
  init: { body?: string; method: "GET" | "PATCH" | "POST" },
325
462
  timeoutMs: number
326
463
  ): Promise<unknown> => {
327
- const response = await request(url, {
328
- body: init.body,
329
- headers: {
330
- Accept: "application/vnd.github+json",
331
- Authorization: `Bearer ${token}`,
332
- "X-GitHub-Api-Version": GITHUB_API_VERSION,
333
- ...(init.body === undefined
334
- ? {}
335
- : { "Content-Type": "application/json" }),
336
- },
337
- method: init.method,
338
- signal: AbortSignal.timeout(timeoutMs),
339
- });
340
- if (!response.ok) {
341
- 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);
342
475
  }
343
- 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
+ );
344
485
  };
345
486
 
346
487
  const githubGraphql = async (
@@ -361,7 +502,25 @@ const githubGraphql = async (
361
502
  throw new TypeError("GitHub GraphQL response was not an object");
362
503
  }
363
504
  if (served.errors !== undefined) {
364
- throw new Error("Preview proof inventory GraphQL query failed.");
505
+ const details = Array.isArray(served.errors)
506
+ ? served.errors
507
+ .flatMap((entry) =>
508
+ isRecord(entry)
509
+ ? [
510
+ [entry.type, entry.message]
511
+ .filter((part) => typeof part === "string")
512
+ .join(": "),
513
+ ]
514
+ : []
515
+ )
516
+ .filter((detail) => detail.length > 0)
517
+ .join("; ")
518
+ : "";
519
+ throw new Error(
520
+ details.length > 0
521
+ ? `Preview proof inventory GraphQL query failed: ${details}`
522
+ : "Preview proof inventory GraphQL query failed."
523
+ );
365
524
  }
366
525
  return served.data;
367
526
  };
@@ -636,10 +795,20 @@ const listPullRequestCommitShas = (
636
795
  "Preview proof inventory commit listing exhausted pagination before GitHub returned a final page."
637
796
  );
638
797
 
798
+ const readPullBaseSha = (pull: Record<string, unknown>): string => {
799
+ if (!isRecord(pull.base) || typeof pull.base.sha !== "string") {
800
+ throw new TypeError("GitHub pull request response omitted base.sha");
801
+ }
802
+ return pull.base.sha;
803
+ };
804
+
639
805
  /**
640
- * Current head, replaced heads from force-push, and PR commits. The
641
- * pull-commits endpoint silently stops at 250 and never lists a rebased-away
642
- * head, so those two cases use compare pagination and the force-push timeline.
806
+ * Current head, replaced heads from force-push, the commits each replaced head
807
+ * carried, and PR commits. The pull-commits endpoint silently stops at 250 and
808
+ * never lists a rebased-away head, so those two cases use compare pagination
809
+ * and the force-push timeline. A persist, then a fast-forward, then a rebase
810
+ * leaves the persist SHA behind the replaced head, so each replaced head is
811
+ * also walked back to the base (#796).
643
812
  */
644
813
  const listCommitShasForPull = async (
645
814
  request: typeof fetch,
@@ -674,16 +843,13 @@ const listCommitShasForPull = async (
674
843
  );
675
844
  let older = pullCommits;
676
845
  if (pullCommits.length >= GITHUB_PULL_COMMITS_CAP) {
677
- if (!isRecord(pull.base) || typeof pull.base.sha !== "string") {
678
- throw new TypeError("GitHub pull request response omitted base.sha");
679
- }
680
846
  older = await listCompareCommitShas(
681
847
  request,
682
848
  token,
683
849
  timeoutMs,
684
850
  owner,
685
851
  repo,
686
- pull.base.sha,
852
+ readPullBaseSha(pull),
687
853
  pull.head.sha
688
854
  );
689
855
  }
@@ -697,9 +863,34 @@ const listCommitShasForPull = async (
697
863
  pr
698
864
  );
699
865
 
866
+ let replaced: readonly string[] = [];
867
+ if (previousHeads.length > 0) {
868
+ const baseSha = readPullBaseSha(pull);
869
+ const histories = await mapPool(
870
+ previousHeads,
871
+ PREVIEW_PROOF_LIST_CONCURRENCY,
872
+ (previousHead) =>
873
+ listCompareCommitShas(
874
+ request,
875
+ token,
876
+ timeoutMs,
877
+ owner,
878
+ repo,
879
+ baseSha,
880
+ previousHead
881
+ )
882
+ );
883
+ replaced = histories.flatMap((history) => [...history].toReversed());
884
+ }
885
+
700
886
  const shas: string[] = [];
701
887
  const seen = new Set<string>();
702
- for (const sha of [pull.head.sha, ...previousHeads, ...older.toReversed()]) {
888
+ for (const sha of [
889
+ pull.head.sha,
890
+ ...previousHeads,
891
+ ...older.toReversed(),
892
+ ...replaced,
893
+ ]) {
703
894
  if (!seen.has(sha)) {
704
895
  seen.add(sha);
705
896
  shas.push(sha);
@@ -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
  };