@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 +2 -0
- package/dist/index.d.ts +41 -4
- package/dist/index.js +136 -24
- package/package.json +5 -3
- package/src/index.ts +2 -0
- package/src/pr-status-hud-workflow.ts +17 -0
- package/src/preview-proof-inventory.ts +219 -28
- 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";
|
|
@@ -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)
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
-
|
|
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, {
|
|
@@ -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)
|
|
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,
|
|
1452
|
-
* pull-commits endpoint silently stops at 250 and
|
|
1453
|
-
* head, so those two cases use compare pagination
|
|
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.
|
|
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
|
@@ -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)
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
641
|
-
* pull-commits endpoint silently stops at 250 and
|
|
642
|
-
* head, so those two cases use compare pagination
|
|
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
|
|
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 [
|
|
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
|
};
|