@patronage/factory-ci 1.0.0-alpha.22 → 1.0.0-alpha.23

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
@@ -79,7 +79,7 @@ const core = job("core", {
79
79
 
80
80
  Migration: upgrade `@patronage/factory-ci`, apply the shared trigger types and job condition in the TypeScript workflow source, then regenerate and commit the emitted YAML. Draft `opened` and `synchronize` events will stop running substantive Verify work. Promotion through `ready_for_review`, later non-draft Candidate events, and merge-target pushes continue to run their applicable battery. A skipped draft run is presentation, not proof.
81
81
 
82
- Production impact support follows the same ownership line. A generated deploy workflow declares only its target names and consumes the returned decision step and fail-open `demandedIf(target)` expressions:
82
+ Production impact support follows the same ownership line. A generated deploy workflow declares only its target names and consumes the returned decision step and fail-open `demandedIf(target, decisionJob)` expressions:
83
83
 
84
84
  ```ts
85
85
  const impact = factoryProductionImpactWorkflow({
@@ -111,6 +111,7 @@ import {
111
111
  factoryPushIdentityProducer,
112
112
  factoryWorkflow,
113
113
  NODE_PNPM_ACTION_FAMILY_NODE24,
114
+ UPLOAD_ARTIFACT,
114
115
  } from "@patronage/factory-ci";
115
116
 
116
117
  const workflowArtifact = factoryWorkflow({
@@ -120,10 +121,7 @@ const workflowArtifact = factoryWorkflow({
120
121
  tag: "v5.0.0",
121
122
  uses: "actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0",
122
123
  },
123
- uploadArtifact: {
124
- tag: "v4.6.2",
125
- uses: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
126
- },
124
+ uploadArtifact: UPLOAD_ARTIFACT,
127
125
  },
128
126
  generated: {
129
127
  source: ".github/workflows/verify.ts",
@@ -165,7 +163,7 @@ The helper selects no action family or action version. Callers pass the explicit
165
163
 
166
164
  This is identity transport and validation only. The consumer still owns the `workflow_run` trigger and branch policy, job topology and runners, deploy credentials and environments, target declarations, commands, ordering, and convergence/no-op proof. None of those belong in the identity artifact or decision job.
167
165
 
168
- Candidate pull-request routing follows the same ownership line through `factoryCandidateImpactWorkflow` (#741). A generated Verify workflow declares only its target names and consumes the returned decision step and fail-open `demandedIf(target)` expressions, so the profile's canonical impact declarations and dependency graph route the run instead of a repository-maintained path filter:
166
+ Candidate pull-request routing follows the same ownership line through `factoryCandidateImpactWorkflow` (#741). A generated Verify workflow declares only its target names and consumes the returned decision step and fail-open `demandedIf(target, decisionJob)` expressions, so the profile's canonical impact declarations and dependency graph route the run instead of a repository-maintained path filter:
169
167
 
170
168
  ```ts
171
169
  const impact = factoryCandidateImpactWorkflow({
@@ -191,7 +189,7 @@ const core = job("core", {
191
189
  });
192
190
  ```
193
191
 
194
- `demandedIf(target, decisionJob)` emits `always()` so a failed decision job still demands work, and it checks only the decision job's result. Declare the decision job as the routed job's only `needs`. A routed job with more prerequisites must add its own `needs.<job>.result == 'success'` clauses, because `always()` suppresses GitHub's implicit success gating for every dependency. The production decision seam has the same contract.
192
+ `demandedIf(target, decisionJob)` builds the cross-job condition. The decision job id is required, so a routed job cannot receive a same-job condition by accident. It emits `always()` so a failed decision job still demands work, and it checks only the decision job's result. Declare the decision job as the routed job's only `needs`. A routed job with more prerequisites must add its own `needs.<job>.result == 'success'` clauses, because `always()` suppresses GitHub's implicit success gating for every dependency. `demandedIfAtStep(target)` builds the same fail-open condition for a step inside the decision job, where the decision is read from `steps.<stepId>`. The production decision seam has the same contract.
195
193
 
196
194
  Trust boundary: candidate routing is a cost control inside the pull request's own CI, not a merge-security control. A `pull_request` run executes the PR's workflow file and install scripts, so a hostile PR can already shape any of its own job outputs — with or without this seam, exactly as with a repository path filter. Merge admission never trusts these outputs: merge-target push verification stays full and unrouted, and the factory proof chain binds to commits, not to job conditions.
197
195
 
@@ -379,7 +377,7 @@ import { bundleAlchemyEntry, executeAlchemyEntry } from "@patronage/factory-ci";
379
377
 
380
378
  `alias` is a specifier-to-target map handed to esbuild. Substitution runs before the `packages` and `external` decisions, so an aliased bare specifier is inlined even under `packages: "external"` — that is how a repo points a workspace-only or duplicated package at one file. Give absolute paths or package names; which aliases a repo needs is consumer policy and this package ships no defaults. `executeAlchemyEntry` passes its whole `bundle` option through, so the map is available there too. Because substitution runs first, an alias on `alchemy`, `effect`, or any of their subpaths would silently defeat the externals and bundle a second copy, so those keys are rejected outright. Targets are not string-matched — no rule over how a path is spelled can survive `..` segments or symlinks — so instead the build's metafile is checked afterwards and the bundle is rejected if it carries any input from a reserved package, however that file was reached. A consumer shim that re-exports `effect` by bare specifier stays external naturally and is allowed.
381
379
 
382
- `executeAlchemyEntry({ from, bundle, args, ... })` owns the repeated choreography: resolve the consumer's Alchemy CLI from `from`, bundle the entry, run that CLI under the current Node binary with the absolute bundled entry appended, and throw on spawn errors, signals, missing statuses, or non-zero exits. It returns only after status 0. Pass `bundledEntry` (absolute or cwd-relative) to skip the bundle step and reuse a path from a prior `bundleAlchemyEntry` call; `bundle` is then optional. Captured output (`stdio: "pipe"`) honors `maxBuffer` only when the caller supplies it (Node's `spawnSync` default is 1 MiB; pass `EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER` for 64 MiB) and, when supplied, a `redact: (text: string) => string` hook applied to stdout and stderr before they are returned. Default `stdio: "inherit"` still streams the child unredacted. This package does not own a secret-name policy.
380
+ `executeAlchemyEntry({ from, bundle, args, ... })` owns the repeated choreography: resolve the consumer's Alchemy CLI from `from`, bundle the entry, run that CLI under the current Node binary with the absolute bundled entry appended, and throw on spawn errors, signals, missing statuses, or non-zero exits. It returns only after status 0. Options carry exactly one entry source. Pass `bundle` to bundle now, or `bundledEntry` (absolute or cwd-relative) to reuse a path from a prior `bundleAlchemyEntry` call. Passing both, or neither, does not type-check. Captured output (`stdio: "pipe"`) honors `maxBuffer` only when the caller supplies it (Node's `spawnSync` default is 1 MiB; pass `EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER` for 64 MiB) and, when supplied, a `redact: (text: string) => string` hook applied to stdout and stderr before they are returned. Default `stdio: "inherit"` still streams the child unredacted. This package does not own a secret-name policy.
383
381
 
384
382
  The caller still owns deploy/destroy/plan arguments, stage admission, credentials, environment shaping, destructive-plan guards, and convergence. Failure messages never repeat arguments or environment values.
385
383
 
@@ -406,7 +404,7 @@ import { previewProofLifecycle } from "@patronage/factory-ci";
406
404
  Events:
407
405
 
408
406
  - `begin` — the pre-deploy gate. Refuses a draft, a closed PR, a dirty or mismatched worktree, and any stage the disposable grammar does not own for this PR head. Does not create a registration.
409
- - `register` — the same identity gate, plus passed smoke, passed convergence, and a passing three-way-bound evidence envelope at this head. Writes a registration with `cleanupStatus: "pending"`. Machine evidence is that envelope; this module never posts a PR comment.
407
+ - `register` — the same identity gate, plus passed smoke, passed convergence, and a passing three-way-bound evidence envelope at this head. Writes a registration with `cleanup.outcome: "pending"`. Cleanup, smoke, and convergence status live only on their nested objects, so no top-level copy can diverge from them. Machine evidence is that envelope; this module never posts a PR comment.
410
408
  - `cleanup` — records an explicit passed or failed outcome onto an existing registration. Cleanup is never inferred from whether the stage still exists. `recover` is the failed-to-resolved re-entry of this event.
411
409
 
412
410
  Proof runs entirely on one side of GitHub's draft/ready boundary: both `begin` and `register` refuse `isDraft: true`. Undraft first, then prove. Callers still own deploy, destroy, credentials, smoke probes, envelope emission, and stack names.
package/dist/index.d.ts CHANGED
@@ -53,6 +53,18 @@ declare const NODE_PNPM_ACTION_FAMILY_NODE24: {
53
53
  readonly uses: "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271";
54
54
  };
55
55
  };
56
+ /**
57
+ * The one `actions/upload-artifact` pin every workflow generator shares
58
+ * (#887). Two callers pinned two different commits of the same tag family;
59
+ * naming the pin here makes that drift impossible the same way the Node/pnpm
60
+ * family does. A caller passes this through `factoryWorkflow`'s
61
+ * `additionalActions.uploadArtifact` explicitly — the catalog names the pin,
62
+ * it does not inject the upload step.
63
+ */
64
+ declare const UPLOAD_ARTIFACT: {
65
+ readonly tag: "v4.6.2";
66
+ readonly uses: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02";
67
+ };
56
68
  //#endregion
57
69
  //#region src/candidate-lifecycle.d.ts
58
70
  /**
@@ -205,12 +217,10 @@ interface PreviewProofRegistration {
205
217
  readonly outcome: PreviewProofCleanupStatus;
206
218
  readonly runUrl?: string;
207
219
  };
208
- readonly cleanupStatus: PreviewProofCleanupStatus;
209
220
  readonly convergence: {
210
221
  readonly detail: string;
211
222
  readonly status: "passed";
212
223
  };
213
- readonly convergenceStatus: "passed";
214
224
  readonly headSha: string;
215
225
  readonly pr: number;
216
226
  readonly proof: {
@@ -223,7 +233,6 @@ interface PreviewProofRegistration {
223
233
  readonly detail: string;
224
234
  readonly outcome: "passed";
225
235
  };
226
- readonly smokeStatus: "passed";
227
236
  readonly source: "local-self-certified";
228
237
  readonly stack: string;
229
238
  readonly stage: LocalPreviewStage;
@@ -518,22 +527,12 @@ declare const mintInstallationToken: (input: {
518
527
  //#region src/execute-alchemy-entry.d.ts
519
528
  /** Node's spawnSync default is 1 MiB; Alchemy plans routinely exceed that. */
520
529
  declare const EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER: number;
521
- interface ExecuteAlchemyEntryOptions {
530
+ interface ExecuteAlchemyEntryCommon {
522
531
  /**
523
532
  * CLI arguments before the bundled entry. The absolute entry path is always
524
533
  * appended as the final argument.
525
534
  */
526
535
  readonly args: readonly string[];
527
- /**
528
- * Bundle the consumer entry before execution. Required unless `bundledEntry`
529
- * is set. Ignored when `bundledEntry` is set.
530
- */
531
- readonly bundle?: BundleAlchemyEntryOptions;
532
- /**
533
- * Already-bundled entry path. When set, skips the internal bundle step and
534
- * appends this path as the CLI's final argument.
535
- */
536
- readonly bundledEntry?: string;
537
536
  /** Child working directory. Defaults to the bundle root, then process.cwd. */
538
537
  readonly cwd?: string;
539
538
  /** Child environment. Defaults to process.env. */
@@ -556,6 +555,21 @@ interface ExecuteAlchemyEntryOptions {
556
555
  /** Child stdio. Default "inherit". */
557
556
  readonly stdio?: "inherit" | "pipe";
558
557
  }
558
+ /**
559
+ * Exactly one entry source. The union refuses `bundle` and `bundledEntry`
560
+ * together, and refuses neither, so no runtime check is needed.
561
+ */
562
+ type ExecuteAlchemyEntryOptions = ExecuteAlchemyEntryCommon & ({
563
+ /** Bundle the consumer entry before execution. */readonly bundle: BundleAlchemyEntryOptions;
564
+ readonly bundledEntry?: never;
565
+ } | {
566
+ readonly bundle?: never;
567
+ /**
568
+ * Already-bundled entry path. Skips the internal bundle step and
569
+ * appends this path as the CLI's final argument.
570
+ */
571
+ readonly bundledEntry: string;
572
+ });
559
573
  interface ExecuteAlchemyEntryResult {
560
574
  readonly bundledEntry: string;
561
575
  readonly stderr: string | null;
@@ -565,8 +579,8 @@ interface ExecuteAlchemyEntryResult {
565
579
  * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
566
580
  * run it under the current Node binary, and return only on a successful exit.
567
581
  *
568
- * Pass `bundledEntry` to skip the bundle step and reuse an already-bundled
569
- * path. Captured stdout and stderr (`stdio: "pipe"`) can be redacted through
582
+ * Options carry exactly one entry source: `bundle` to bundle now, or
583
+ * `bundledEntry` to reuse an already-bundled path. Captured stdout and stderr (`stdio: "pipe"`) can be redacted through
570
584
  * `redact` before they are returned; `stdio: "inherit"` still streams the
571
585
  * child unredacted.
572
586
  *
@@ -939,8 +953,13 @@ interface FactoryCandidateImpactWorkflow {
939
953
  /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
940
954
  readonly decisionJobOutputs: Readonly<Record<string, string>>;
941
955
  readonly decisionStep: WorkflowStep;
942
- /** A fail-open condition: only an explicit usable withdrawal skips work. */
943
- readonly demandedIf: (targetName: string, decisionJob?: string) => string;
956
+ /**
957
+ * A fail-open cross-job condition: only an explicit usable withdrawal skips
958
+ * work. The decision job id is required.
959
+ */
960
+ readonly demandedIf: (targetName: string, decisionJob: string) => string;
961
+ /** The same fail-open condition for a step in the decision job. */
962
+ readonly demandedIfAtStep: (targetName: string) => string;
944
963
  readonly targetOutputs: Readonly<Record<string, string>>;
945
964
  }
946
965
  /**
@@ -1010,6 +1029,13 @@ declare const factoryLifecycleContractLane: (declaration?: FactoryLifecycleContr
1010
1029
  * `gh` and parses the emitted payload with `validateMergeFreezeState`, so
1011
1030
  * writer and reader cannot drift silently.
1012
1031
  *
1032
+ * Those two API calls are what the reader's two settling phases observe
1033
+ * (#890). Before the POST the merge target carries no generation at all, which
1034
+ * a reader reports as `awaiting-generation` while the pushed hosted verify run
1035
+ * is still pending. Between the POST and the PATCH the generation exists with
1036
+ * no `output.text`, which a reader reports as `running-generation`. Only the
1037
+ * PATCH writes a verdict, so neither phase is one.
1038
+ *
1013
1039
  * Failure posture is deliberately fail-closed and the opposite of the
1014
1040
  * proof-reuse gate's fail-open shell: this step runs under the runner's
1015
1041
  * default `bash -e {0}`, so any API refusal aborts the step, the generation
@@ -1213,8 +1239,13 @@ interface FactoryProductionImpactWorkflow {
1213
1239
  /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
1214
1240
  readonly decisionJobOutputs: Readonly<Record<string, string>>;
1215
1241
  readonly decisionStep: WorkflowStep;
1216
- /** A fail-open condition: only an explicit usable withdrawal skips work. */
1217
- readonly demandedIf: (targetName: string, decisionJob?: string) => string;
1242
+ /**
1243
+ * A fail-open cross-job condition: only an explicit usable withdrawal skips
1244
+ * work. The decision job id is required.
1245
+ */
1246
+ readonly demandedIf: (targetName: string, decisionJob: string) => string;
1247
+ /** The same fail-open condition for a step in the decision job. */
1248
+ readonly demandedIfAtStep: (targetName: string) => string;
1218
1249
  readonly targetOutputs: Readonly<Record<string, string>>;
1219
1250
  }
1220
1251
  /**
@@ -1237,7 +1268,12 @@ interface FactoryPushIdentityEnvelope {
1237
1268
  }
1238
1269
  type FactoryPushIdentityDisposition = "refused" | "usable";
1239
1270
  interface FactoryPushIdentityProducerOptions {
1240
- /** Explicit caller-owned upload-artifact pin from its workflow artifact. */
1271
+ /**
1272
+ * Explicit caller-owned upload-artifact pin from its workflow artifact.
1273
+ * Pass factory-ci's `UPLOAD_ARTIFACT` catalog export (or another
1274
+ * `actions/upload-artifact` pin); the caller wires the step, the catalog
1275
+ * only names the pin.
1276
+ */
1241
1277
  readonly uploadArtifact: PinnedAction;
1242
1278
  /** Optional consumer trigger policy combined with the required push event. */
1243
1279
  readonly if?: string;
@@ -1556,4 +1592,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
1556
1592
  readonly source: string;
1557
1593
  }) => void;
1558
1594
  //#endregion
1559
- 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_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, 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_PULL_REQUEST_IF, 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 FactoryMergeFreezeJob, type FactoryMergeFreezeJobOptions, type FactoryPrStatusHudWorkflow, type FactoryPrStatusHudWorkflowOptions, type FactoryPreviewCleanupAuditJob, type FactoryPreviewCleanupDestroyJob, type FactoryPreviewCleanupTopology, type FactoryPreviewCleanupTopologyOptions, type FactoryProductionImpactWorkflow, type FactoryProductionImpactWorkflowOptions, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateReuse, 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, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, 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, factoryMergeFreezeJob, factoryMergeFreezeScript, 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 };
1595
+ 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_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, 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_PULL_REQUEST_IF, 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 FactoryMergeFreezeJob, type FactoryMergeFreezeJobOptions, type FactoryPrStatusHudWorkflow, type FactoryPrStatusHudWorkflowOptions, type FactoryPreviewCleanupAuditJob, type FactoryPreviewCleanupDestroyJob, type FactoryPreviewCleanupTopology, type FactoryPreviewCleanupTopologyOptions, type FactoryProductionImpactWorkflow, type FactoryProductionImpactWorkflowOptions, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateReuse, 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, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, 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, UPLOAD_ARTIFACT, 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, factoryMergeFreezeJob, factoryMergeFreezeScript, 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
@@ -32,6 +32,18 @@ const NODE_PNPM_ACTION_FAMILY_NODE24 = {
32
32
  uses: "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271"
33
33
  }
34
34
  };
35
+ /**
36
+ * The one `actions/upload-artifact` pin every workflow generator shares
37
+ * (#887). Two callers pinned two different commits of the same tag family;
38
+ * naming the pin here makes that drift impossible the same way the Node/pnpm
39
+ * family does. A caller passes this through `factoryWorkflow`'s
40
+ * `additionalActions.uploadArtifact` explicitly — the catalog names the pin,
41
+ * it does not inject the upload step.
42
+ */
43
+ const UPLOAD_ARTIFACT = {
44
+ tag: "v4.6.2",
45
+ uses: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"
46
+ };
35
47
  //#endregion
36
48
  //#region src/candidate-lifecycle.ts
37
49
  /**
@@ -251,12 +263,10 @@ const transition = (state, event) => {
251
263
  evidence: [],
252
264
  outcome: "pending"
253
265
  },
254
- cleanupStatus: "pending",
255
266
  convergence: {
256
267
  detail: event.convergence.detail,
257
268
  status: "passed"
258
269
  },
259
- convergenceStatus: "passed",
260
270
  headSha: event.candidate.headSha,
261
271
  pr: event.candidate.pr,
262
272
  proof: {
@@ -269,7 +279,6 @@ const transition = (state, event) => {
269
279
  detail: event.smoke.detail,
270
280
  outcome: "passed"
271
281
  },
272
- smokeStatus: "passed",
273
282
  source: "local-self-certified",
274
283
  stack: event.stack,
275
284
  stage,
@@ -288,12 +297,11 @@ const transition = (state, event) => {
288
297
  };
289
298
  return replaceRegistration$1(state, {
290
299
  ...existing,
291
- cleanup,
292
- cleanupStatus: event.outcome
300
+ cleanup
293
301
  });
294
302
  };
295
303
  const recover = (state, event) => {
296
- if (state.registrations.find((row) => row.pr === event.pr && row.stack === event.stack && row.stage === event.stage)?.cleanupStatus !== "failed") throw new Error("Preview proof recovery re-enters cleanup only from a failed outcome.");
304
+ if (state.registrations.find((row) => row.pr === event.pr && row.stack === event.stack && row.stage === event.stage)?.cleanup.outcome !== "failed") throw new Error("Preview proof recovery re-enters cleanup only from a failed outcome.");
297
305
  return transition(state, event);
298
306
  };
299
307
  const previewProofLifecycle = {
@@ -586,7 +594,7 @@ const COMMAND_IDENTITY_MAX_LENGTH = 120;
586
594
  * cannot subtract hosted work. This is intentionally fail-closed and moves in
587
595
  * lockstep with the proof/check-payload producer.
588
596
  */
589
- const TRUSTED_IMPACT_STAMP_VERSION = 3;
597
+ const TRUSTED_IMPACT_STAMP_VERSION = 4;
590
598
  const isProofReuseCommand = (value) => {
591
599
  if (!(value && typeof value === "object")) return false;
592
600
  const entry = value;
@@ -779,7 +787,9 @@ def releaseAuthorized($release; $commands; $stamp):
779
787
  type == "object"
780
788
  and (.name | nonemptyString)
781
789
  and (.basis | nonemptyString)
782
- and ((.impact == "affected") or (.impact == "not-affected"))))
790
+ and ((.impact == "affected") or (.impact == "not-affected"))
791
+ and ((.subscribedPaths | type) == "array")
792
+ and (.subscribedPaths | all(.[]; type == "string"))))
783
793
  and ($stamp.targets | distinctNames)
784
794
  and (([$releases[].name] - $executed | length) == ($releases | length))
785
795
  and ($releases | all(.[]; releaseAuthorized(.; $commands; $stamp)))
@@ -1280,7 +1290,7 @@ const readIdentity = (value) => {
1280
1290
  const readCleanup = (value) => {
1281
1291
  if (!isRecord$1(value.cleanup) || !isCleanupStatus(value.cleanup.outcome)) return null;
1282
1292
  const evidence = readStringArray(value.cleanup.evidence);
1283
- if (evidence === null || value.cleanupStatus !== value.cleanup.outcome) return null;
1293
+ if (evidence === null) return null;
1284
1294
  return typeof value.cleanup.runUrl === "string" && value.cleanup.runUrl.length > 0 ? {
1285
1295
  evidence,
1286
1296
  outcome: value.cleanup.outcome,
@@ -1292,7 +1302,7 @@ const readCleanup = (value) => {
1292
1302
  };
1293
1303
  const readPassingChecks = (value) => {
1294
1304
  if (!isRecord$1(value.convergence) || !isRecord$1(value.smoke)) return null;
1295
- if (value.convergence.status !== "passed" || value.convergenceStatus !== "passed" || typeof value.convergence.detail !== "string" || value.smoke.outcome !== "passed" || value.smokeStatus !== "passed" || typeof value.smoke.detail !== "string") return null;
1305
+ if (value.convergence.status !== "passed" || typeof value.convergence.detail !== "string" || value.smoke.outcome !== "passed" || typeof value.smoke.detail !== "string") return null;
1296
1306
  return {
1297
1307
  convergence: {
1298
1308
  detail: value.convergence.detail,
@@ -1327,14 +1337,11 @@ const readPreviewProofRegistration = (value) => {
1327
1337
  if (identity === null || cleanup === null || passing === null || proof === null) return null;
1328
1338
  return {
1329
1339
  cleanup,
1330
- cleanupStatus: cleanup.outcome,
1331
1340
  convergence: passing.convergence,
1332
- convergenceStatus: "passed",
1333
1341
  headSha: identity.headSha,
1334
1342
  pr: identity.pr,
1335
1343
  proof,
1336
1344
  smoke: passing.smoke,
1337
- smokeStatus: "passed",
1338
1345
  source: "local-self-certified",
1339
1346
  stack: identity.stack,
1340
1347
  stage: identity.stage,
@@ -1492,7 +1499,7 @@ const registrationFromCheckRun = (run, expectedHeadSha) => {
1492
1499
  const checkRunOutput = (registration) => {
1493
1500
  const title = `Preview proof ${registration.stack}/${registration.stage}`.slice(0, 255);
1494
1501
  return {
1495
- summary: `cleanup ${registration.cleanupStatus}`,
1502
+ summary: `cleanup ${registration.cleanup.outcome}`,
1496
1503
  text: JSON.stringify({
1497
1504
  kind: INVENTORY_KIND,
1498
1505
  registration,
@@ -1852,9 +1859,8 @@ const factoryWorkflow = (options) => {
1852
1859
  const EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER = 64 * 1024 * 1024;
1853
1860
  let spawnImplementation = spawnSync;
1854
1861
  const resolveBundledEntry = async (options) => {
1855
- if (options.bundledEntry !== void 0) return path.resolve(options.cwd ?? options.bundle?.absWorkingDir ?? process.cwd(), options.bundledEntry);
1856
- if (options.bundle === void 0) throw new Error("executeAlchemyEntry requires bundle or bundledEntry.");
1857
- return await bundleAlchemyEntry(options.bundle);
1862
+ if (options.bundledEntry === void 0) return await bundleAlchemyEntry(options.bundle);
1863
+ return path.resolve(options.cwd ?? process.cwd(), options.bundledEntry);
1858
1864
  };
1859
1865
  const captured = (text, redact) => {
1860
1866
  if (text === null || redact === void 0) return text;
@@ -1864,8 +1870,8 @@ const captured = (text, redact) => {
1864
1870
  * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
1865
1871
  * run it under the current Node binary, and return only on a successful exit.
1866
1872
  *
1867
- * Pass `bundledEntry` to skip the bundle step and reuse an already-bundled
1868
- * path. Captured stdout and stderr (`stdio: "pipe"`) can be redacted through
1873
+ * Options carry exactly one entry source: `bundle` to bundle now, or
1874
+ * `bundledEntry` to reuse an already-bundled path. Captured stdout and stderr (`stdio: "pipe"`) can be redacted through
1869
1875
  * `redact` before they are returned; `stdio: "inherit"` still streams the
1870
1876
  * child unredacted.
1871
1877
  *
@@ -1971,6 +1977,24 @@ const factoryProofReuseSummaryStep = (options) => Object.freeze({
1971
1977
  run: factoryProofReuseSummaryScript(options)
1972
1978
  });
1973
1979
  //#endregion
1980
+ //#region src/impact-demand-condition.ts
1981
+ const impactDemandConditions = (options) => {
1982
+ const targetOutput = (targetName) => {
1983
+ const output = options.targetOutputs[targetName];
1984
+ if (output === void 0) throw new Error(`Unknown ${options.label} impact target "${targetName}".`);
1985
+ return output;
1986
+ };
1987
+ const condition = (source, outcomeKey, output) => `${source}.${outcomeKey} != 'success' || ${source}.outputs.${options.decisionOutput} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
1988
+ return Object.freeze({
1989
+ demandedIf: (targetName, decisionJob) => {
1990
+ const output = targetOutput(targetName);
1991
+ if (decisionJob.trim().length === 0) throw new Error(`A cross-job ${options.label} impact condition requires the decision job id.`);
1992
+ return `always() && (${condition(`needs.${decisionJob}`, "result", output)})`;
1993
+ },
1994
+ demandedIfAtStep: (targetName) => condition(`steps.${options.stepId}`, "outcome", targetOutput(targetName))
1995
+ });
1996
+ };
1997
+ //#endregion
1974
1998
  //#region src/production-impact-workflow.ts
1975
1999
  const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
1976
2000
  const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
@@ -2007,6 +2031,12 @@ const factoryProductionImpactWorkflow = (options) => {
2007
2031
  name: "Classify production impact",
2008
2032
  run: `${cli} production:impact --before "$FACTORY_BEFORE_SHA" --after "$FACTORY_AFTER_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`
2009
2033
  };
2034
+ const conditions = impactDemandConditions({
2035
+ decisionOutput: FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT,
2036
+ label: "production",
2037
+ stepId: FACTORY_PRODUCTION_IMPACT_STEP_ID,
2038
+ targetOutputs
2039
+ });
2010
2040
  return Object.freeze({
2011
2041
  decisionJobOutputs: Object.freeze({
2012
2042
  basis: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT} }}`,
@@ -2015,13 +2045,8 @@ const factoryProductionImpactWorkflow = (options) => {
2015
2045
  ...Object.fromEntries(Object.values(targetOutputs).map((output) => [output, `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${output} }}`]))
2016
2046
  }),
2017
2047
  decisionStep: Object.freeze(decisionStep),
2018
- demandedIf: (targetName, decisionJob) => {
2019
- const output = targetOutputs[targetName];
2020
- if (output === void 0) throw new Error(`Unknown production impact target "${targetName}".`);
2021
- const source = decisionJob ? `needs.${decisionJob}` : `steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}`;
2022
- const condition = `${source}.${decisionJob ? "result" : "outcome"} != 'success' || ${source}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
2023
- return decisionJob ? `always() && (${condition})` : condition;
2024
- },
2048
+ demandedIf: conditions.demandedIf,
2049
+ demandedIfAtStep: conditions.demandedIfAtStep,
2025
2050
  targetOutputs: Object.freeze(targetOutputs)
2026
2051
  });
2027
2052
  };
@@ -2064,6 +2089,12 @@ const factoryCandidateImpactWorkflow = (options) => {
2064
2089
  name: "Classify candidate impact",
2065
2090
  run: `${cli} candidate:impact --base "$FACTORY_BASE_SHA" --head "$FACTORY_HEAD_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`
2066
2091
  };
2092
+ const conditions = impactDemandConditions({
2093
+ decisionOutput: FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT,
2094
+ label: "candidate",
2095
+ stepId: FACTORY_CANDIDATE_IMPACT_STEP_ID,
2096
+ targetOutputs
2097
+ });
2067
2098
  return Object.freeze({
2068
2099
  decisionJobOutputs: Object.freeze({
2069
2100
  basis: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT} }}`,
@@ -2073,13 +2104,8 @@ const factoryCandidateImpactWorkflow = (options) => {
2073
2104
  ...Object.fromEntries(Object.values(targetOutputs).map((output) => [output, `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${output} }}`]))
2074
2105
  }),
2075
2106
  decisionStep: Object.freeze(decisionStep),
2076
- demandedIf: (targetName, decisionJob) => {
2077
- const output = targetOutputs[targetName];
2078
- if (output === void 0) throw new Error(`Unknown candidate impact target "${targetName}".`);
2079
- const source = decisionJob ? `needs.${decisionJob}` : `steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}`;
2080
- const condition = `${source}.${decisionJob ? "result" : "outcome"} != 'success' || ${source}.outputs.${FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
2081
- return decisionJob ? `always() && (${condition})` : condition;
2082
- },
2107
+ demandedIf: conditions.demandedIf,
2108
+ demandedIfAtStep: conditions.demandedIfAtStep,
2083
2109
  targetOutputs: Object.freeze(targetOutputs)
2084
2110
  });
2085
2111
  };
@@ -2172,6 +2198,13 @@ const factoryLifecycleContractLane = (declaration) => {
2172
2198
  * `gh` and parses the emitted payload with `validateMergeFreezeState`, so
2173
2199
  * writer and reader cannot drift silently.
2174
2200
  *
2201
+ * Those two API calls are what the reader's two settling phases observe
2202
+ * (#890). Before the POST the merge target carries no generation at all, which
2203
+ * a reader reports as `awaiting-generation` while the pushed hosted verify run
2204
+ * is still pending. Between the POST and the PATCH the generation exists with
2205
+ * no `output.text`, which a reader reports as `running-generation`. Only the
2206
+ * PATCH writes a verdict, so neither phase is one.
2207
+ *
2175
2208
  * Failure posture is deliberately fail-closed and the opposite of the
2176
2209
  * proof-reuse gate's fail-open shell: this step runs under the runner's
2177
2210
  * default `bash -e {0}`, so any API refusal aborts the step, the generation
@@ -2428,6 +2461,10 @@ const factoryPreviewCleanupTopology = (options) => {
2428
2461
  };
2429
2462
  //#endregion
2430
2463
  //#region src/push-identity-workflow.ts
2464
+ /** `owner/repo` slice of the catalog's `UPLOAD_ARTIFACT` pin, derived rather
2465
+ * than repeated as a literal, so a repository rename here cannot drift from
2466
+ * the pin it validates against. */
2467
+ const UPLOAD_ARTIFACT_REPOSITORY = UPLOAD_ARTIFACT.uses.slice(0, UPLOAD_ARTIFACT.uses.indexOf("@"));
2431
2468
  const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
2432
2469
  /**
2433
2470
  * The artifact prefix and the five step IDs are module-private. Alpha.13
@@ -2683,7 +2720,7 @@ printf 'disposition=%s\nreason=%s\nbefore=%s\nafter=%s\nprovenance=%s\n' \
2683
2720
  * result when transport is unavailable. Place these steps after verification.
2684
2721
  */
2685
2722
  const factoryPushIdentityProducer = (options) => {
2686
- assertPinnedAction("uploadArtifact", options.uploadArtifact, "actions/upload-artifact");
2723
+ assertPinnedAction("uploadArtifact", options.uploadArtifact, UPLOAD_ARTIFACT_REPOSITORY);
2687
2724
  const condition = options.if ? `github.event_name == 'push' && (${options.if})` : "github.event_name == 'push'";
2688
2725
  const name = artifactName(expression("github.run_id"));
2689
2726
  return Object.freeze({
@@ -3499,4 +3536,4 @@ const assertWorkflowShellParses = (yaml, options) => {
3499
3536
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
3500
3537
  };
3501
3538
  //#endregion
3502
- 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_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, 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_PULL_REQUEST_IF, 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, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, PreviewProofTransportError, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryMergeFreezeJob, factoryMergeFreezeScript, 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 };
3539
+ 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_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, 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_PULL_REQUEST_IF, 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, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, PreviewProofTransportError, UPLOAD_ARTIFACT, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryMergeFreezeJob, factoryMergeFreezeScript, 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.22",
3
+ "version": "1.0.0-alpha.23",
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",
@@ -48,7 +48,7 @@
48
48
  "node": "^24.0.0"
49
49
  },
50
50
  "scripts": {
51
- "cleanup:check": "knip --files --dependencies",
51
+ "cleanup:check": "knip --files --dependencies --exports",
52
52
  "prebuild": "bash ../scripts/ensure-worktree-bootstrap.sh",
53
53
  "build": "tsdown",
54
54
  "precheck": "bash ../scripts/ensure-worktree-bootstrap.sh",
package/src/actions.ts CHANGED
@@ -52,3 +52,16 @@ export const NODE_PNPM_ACTION_FAMILY_NODE24 = {
52
52
  uses: "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271",
53
53
  },
54
54
  } as const satisfies NodePnpmActionFamily;
55
+
56
+ /**
57
+ * The one `actions/upload-artifact` pin every workflow generator shares
58
+ * (#887). Two callers pinned two different commits of the same tag family;
59
+ * naming the pin here makes that drift impossible the same way the Node/pnpm
60
+ * family does. A caller passes this through `factoryWorkflow`'s
61
+ * `additionalActions.uploadArtifact` explicitly — the catalog names the pin,
62
+ * it does not inject the upload step.
63
+ */
64
+ export const UPLOAD_ARTIFACT = {
65
+ tag: "v4.6.2",
66
+ uses: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
67
+ } as const satisfies PinnedAction;
@@ -1,4 +1,5 @@
1
1
  import type { WorkflowStep } from "./factory-workflow.ts";
2
+ import { impactDemandConditions } from "./impact-demand-condition.ts";
2
3
  import { productionImpactTargetOutput } from "./production-impact-workflow.ts";
3
4
 
4
5
  export const FACTORY_CANDIDATE_IMPACT_STEP_ID = "candidate_impact";
@@ -23,8 +24,13 @@ export interface FactoryCandidateImpactWorkflow {
23
24
  /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
24
25
  readonly decisionJobOutputs: Readonly<Record<string, string>>;
25
26
  readonly decisionStep: WorkflowStep;
26
- /** A fail-open condition: only an explicit usable withdrawal skips work. */
27
- readonly demandedIf: (targetName: string, decisionJob?: string) => string;
27
+ /**
28
+ * A fail-open cross-job condition: only an explicit usable withdrawal skips
29
+ * work. The decision job id is required.
30
+ */
31
+ readonly demandedIf: (targetName: string, decisionJob: string) => string;
32
+ /** The same fail-open condition for a step in the decision job. */
33
+ readonly demandedIfAtStep: (targetName: string) => string;
28
34
  readonly targetOutputs: Readonly<Record<string, string>>;
29
35
  }
30
36
 
@@ -74,6 +80,13 @@ export const factoryCandidateImpactWorkflow = (
74
80
  run: `${cli} candidate:impact --base "$FACTORY_BASE_SHA" --head "$FACTORY_HEAD_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`,
75
81
  };
76
82
 
83
+ const conditions = impactDemandConditions({
84
+ decisionOutput: FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT,
85
+ label: "candidate",
86
+ stepId: FACTORY_CANDIDATE_IMPACT_STEP_ID,
87
+ targetOutputs,
88
+ });
89
+
77
90
  return Object.freeze({
78
91
  decisionJobOutputs: Object.freeze({
79
92
  basis: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT} }}`,
@@ -88,18 +101,8 @@ export const factoryCandidateImpactWorkflow = (
88
101
  ),
89
102
  }),
90
103
  decisionStep: Object.freeze(decisionStep),
91
- demandedIf: (targetName: string, decisionJob?: string) => {
92
- const output = targetOutputs[targetName];
93
- if (output === undefined) {
94
- throw new Error(`Unknown candidate impact target "${targetName}".`);
95
- }
96
- const source = decisionJob
97
- ? `needs.${decisionJob}`
98
- : `steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}`;
99
- const outcome = decisionJob ? "result" : "outcome";
100
- const condition = `${source}.${outcome} != 'success' || ${source}.outputs.${FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
101
- return decisionJob ? `always() && (${condition})` : condition;
102
- },
104
+ demandedIf: conditions.demandedIf,
105
+ demandedIfAtStep: conditions.demandedIfAtStep,
103
106
  targetOutputs: Object.freeze(targetOutputs),
104
107
  });
105
108
  };
@@ -27,22 +27,12 @@ export const setExecuteAlchemyEntrySpawnForTests = (
27
27
  spawnImplementation = next ?? spawnSync;
28
28
  };
29
29
 
30
- export interface ExecuteAlchemyEntryOptions {
30
+ interface ExecuteAlchemyEntryCommon {
31
31
  /**
32
32
  * CLI arguments before the bundled entry. The absolute entry path is always
33
33
  * appended as the final argument.
34
34
  */
35
35
  readonly args: readonly string[];
36
- /**
37
- * Bundle the consumer entry before execution. Required unless `bundledEntry`
38
- * is set. Ignored when `bundledEntry` is set.
39
- */
40
- readonly bundle?: BundleAlchemyEntryOptions;
41
- /**
42
- * Already-bundled entry path. When set, skips the internal bundle step and
43
- * appends this path as the CLI's final argument.
44
- */
45
- readonly bundledEntry?: string;
46
36
  /** Child working directory. Defaults to the bundle root, then process.cwd. */
47
37
  readonly cwd?: string;
48
38
  /** Child environment. Defaults to process.env. */
@@ -66,6 +56,27 @@ export interface ExecuteAlchemyEntryOptions {
66
56
  readonly stdio?: "inherit" | "pipe";
67
57
  }
68
58
 
59
+ /**
60
+ * Exactly one entry source. The union refuses `bundle` and `bundledEntry`
61
+ * together, and refuses neither, so no runtime check is needed.
62
+ */
63
+ export type ExecuteAlchemyEntryOptions = ExecuteAlchemyEntryCommon &
64
+ (
65
+ | {
66
+ /** Bundle the consumer entry before execution. */
67
+ readonly bundle: BundleAlchemyEntryOptions;
68
+ readonly bundledEntry?: never;
69
+ }
70
+ | {
71
+ readonly bundle?: never;
72
+ /**
73
+ * Already-bundled entry path. Skips the internal bundle step and
74
+ * appends this path as the CLI's final argument.
75
+ */
76
+ readonly bundledEntry: string;
77
+ }
78
+ );
79
+
69
80
  export interface ExecuteAlchemyEntryResult {
70
81
  readonly bundledEntry: string;
71
82
  readonly stderr: string | null;
@@ -75,16 +86,10 @@ export interface ExecuteAlchemyEntryResult {
75
86
  const resolveBundledEntry = async (
76
87
  options: ExecuteAlchemyEntryOptions
77
88
  ): Promise<string> => {
78
- if (options.bundledEntry !== undefined) {
79
- return path.resolve(
80
- options.cwd ?? options.bundle?.absWorkingDir ?? process.cwd(),
81
- options.bundledEntry
82
- );
83
- }
84
- if (options.bundle === undefined) {
85
- throw new Error("executeAlchemyEntry requires bundle or bundledEntry.");
89
+ if (options.bundledEntry === undefined) {
90
+ return await bundleAlchemyEntry(options.bundle);
86
91
  }
87
- return await bundleAlchemyEntry(options.bundle);
92
+ return path.resolve(options.cwd ?? process.cwd(), options.bundledEntry);
88
93
  };
89
94
 
90
95
  const captured = (
@@ -101,8 +106,8 @@ const captured = (
101
106
  * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
102
107
  * run it under the current Node binary, and return only on a successful exit.
103
108
  *
104
- * Pass `bundledEntry` to skip the bundle step and reuse an already-bundled
105
- * path. Captured stdout and stderr (`stdio: "pipe"`) can be redacted through
109
+ * Options carry exactly one entry source: `bundle` to bundle now, or
110
+ * `bundledEntry` to reuse an already-bundled path. Captured stdout and stderr (`stdio: "pipe"`) can be redacted through
106
111
  * `redact` before they are returned; `stdio: "inherit"` still streams the
107
112
  * child unredacted.
108
113
  *
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Per-target withdrawal conditions for the candidate and production impact
3
+ * routing seams. Both seams emit the same condition text, so one
4
+ * implementation owns it.
5
+ *
6
+ * A cross-job condition and a same-job condition read different GitHub
7
+ * expression sources. `demandedIf` requires the decision job id and reads
8
+ * `needs.<job>`; `demandedIfAtStep` reads `steps.<stepId>` and takes no job
9
+ * id. The caller picks the seam by name, so an omitted job id can no longer
10
+ * emit a same-job condition where a cross-job condition was meant.
11
+ */
12
+
13
+ interface ImpactDemandConditionOptions {
14
+ /** Output key that carries the decision verdict. */
15
+ readonly decisionOutput: string;
16
+ /** Seam name used in the unknown-target error. */
17
+ readonly label: string;
18
+ /** Id of the decision step inside the decision job. */
19
+ readonly stepId: string;
20
+ /** Target name to GitHub output key. */
21
+ readonly targetOutputs: Readonly<Record<string, string>>;
22
+ }
23
+
24
+ interface ImpactDemandConditions {
25
+ /**
26
+ * Cross-job condition for a job that lists the decision job in `needs`.
27
+ * The decision job id is required.
28
+ */
29
+ readonly demandedIf: (targetName: string, decisionJob: string) => string;
30
+ /** Same-job condition for a step that follows the decision step. */
31
+ readonly demandedIfAtStep: (targetName: string) => string;
32
+ }
33
+
34
+ export const impactDemandConditions = (
35
+ options: ImpactDemandConditionOptions
36
+ ): ImpactDemandConditions => {
37
+ const targetOutput = (targetName: string): string => {
38
+ const output = options.targetOutputs[targetName];
39
+ if (output === undefined) {
40
+ throw new Error(
41
+ `Unknown ${options.label} impact target "${targetName}".`
42
+ );
43
+ }
44
+ return output;
45
+ };
46
+
47
+ const condition = (
48
+ source: string,
49
+ outcomeKey: string,
50
+ output: string
51
+ ): string =>
52
+ `${source}.${outcomeKey} != 'success' || ${source}.outputs.${options.decisionOutput} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
53
+
54
+ return Object.freeze({
55
+ demandedIf: (targetName: string, decisionJob: string) => {
56
+ const output = targetOutput(targetName);
57
+ if (decisionJob.trim().length === 0) {
58
+ throw new Error(
59
+ `A cross-job ${options.label} impact condition requires the decision job id.`
60
+ );
61
+ }
62
+ return `always() && (${condition(`needs.${decisionJob}`, "result", output)})`;
63
+ },
64
+ demandedIfAtStep: (targetName: string) =>
65
+ condition(`steps.${options.stepId}`, "outcome", targetOutput(targetName)),
66
+ });
67
+ };
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  NODE_PNPM_ACTION_FAMILY_NODE24,
13
13
  type NodePnpmActionFamily,
14
14
  type PinnedAction,
15
+ UPLOAD_ARTIFACT,
15
16
  } from "./actions.ts";
16
17
  export {
17
18
  FACTORY_CANDIDATE_PULL_REQUEST_TYPES,
@@ -23,6 +23,13 @@ import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
23
23
  * `gh` and parses the emitted payload with `validateMergeFreezeState`, so
24
24
  * writer and reader cannot drift silently.
25
25
  *
26
+ * Those two API calls are what the reader's two settling phases observe
27
+ * (#890). Before the POST the merge target carries no generation at all, which
28
+ * a reader reports as `awaiting-generation` while the pushed hosted verify run
29
+ * is still pending. Between the POST and the PATCH the generation exists with
30
+ * no `output.text`, which a reader reports as `running-generation`. Only the
31
+ * PATCH writes a verdict, so neither phase is one.
32
+ *
26
33
  * Failure posture is deliberately fail-closed and the opposite of the
27
34
  * proof-reuse gate's fail-open shell: this step runs under the runner's
28
35
  * default `bash -e {0}`, so any API refusal aborts the step, the generation
@@ -193,7 +193,7 @@ const readCleanup = (value: Record<string, unknown>) => {
193
193
  return null;
194
194
  }
195
195
  const evidence = readStringArray(value.cleanup.evidence);
196
- if (evidence === null || value.cleanupStatus !== value.cleanup.outcome) {
196
+ if (evidence === null) {
197
197
  return null;
198
198
  }
199
199
  return typeof value.cleanup.runUrl === "string" &&
@@ -212,10 +212,8 @@ const readPassingChecks = (value: Record<string, unknown>) => {
212
212
  }
213
213
  if (
214
214
  value.convergence.status !== "passed" ||
215
- value.convergenceStatus !== "passed" ||
216
215
  typeof value.convergence.detail !== "string" ||
217
216
  value.smoke.outcome !== "passed" ||
218
- value.smokeStatus !== "passed" ||
219
217
  typeof value.smoke.detail !== "string"
220
218
  ) {
221
219
  return null;
@@ -276,14 +274,11 @@ const readPreviewProofRegistration = (
276
274
  }
277
275
  return {
278
276
  cleanup,
279
- cleanupStatus: cleanup.outcome,
280
277
  convergence: passing.convergence,
281
- convergenceStatus: "passed",
282
278
  headSha: identity.headSha,
283
279
  pr: identity.pr,
284
280
  proof,
285
281
  smoke: passing.smoke,
286
- smokeStatus: "passed",
287
282
  source: "local-self-certified",
288
283
  stack: identity.stack,
289
284
  stage: identity.stage,
@@ -604,7 +599,7 @@ const checkRunOutput = (registration: PreviewProofRegistration) => {
604
599
  const title =
605
600
  `Preview proof ${registration.stack}/${registration.stage}`.slice(0, 255);
606
601
  return {
607
- summary: `cleanup ${registration.cleanupStatus}`,
602
+ summary: `cleanup ${registration.cleanup.outcome}`,
608
603
  text: JSON.stringify({
609
604
  kind: INVENTORY_KIND,
610
605
  registration,
@@ -49,9 +49,7 @@ export interface PreviewProofRegistration {
49
49
  readonly outcome: PreviewProofCleanupStatus;
50
50
  readonly runUrl?: string;
51
51
  };
52
- readonly cleanupStatus: PreviewProofCleanupStatus;
53
52
  readonly convergence: { readonly detail: string; readonly status: "passed" };
54
- readonly convergenceStatus: "passed";
55
53
  readonly headSha: string;
56
54
  readonly pr: number;
57
55
  readonly proof: {
@@ -61,7 +59,6 @@ export interface PreviewProofRegistration {
61
59
  readonly patchId: string;
62
60
  };
63
61
  readonly smoke: { readonly detail: string; readonly outcome: "passed" };
64
- readonly smokeStatus: "passed";
65
62
  readonly source: "local-self-certified";
66
63
  readonly stack: string;
67
64
  readonly stage: LocalPreviewStage;
@@ -239,12 +236,10 @@ const transition = (
239
236
 
240
237
  return replaceRegistration(state, {
241
238
  cleanup: { evidence: [], outcome: "pending" },
242
- cleanupStatus: "pending",
243
239
  convergence: {
244
240
  detail: event.convergence.detail,
245
241
  status: "passed",
246
242
  },
247
- convergenceStatus: "passed",
248
243
  headSha: event.candidate.headSha,
249
244
  pr: event.candidate.pr,
250
245
  proof: {
@@ -254,7 +249,6 @@ const transition = (
254
249
  patchId: event.evidence.patchId,
255
250
  },
256
251
  smoke: { detail: event.smoke.detail, outcome: "passed" },
257
- smokeStatus: "passed",
258
252
  source: "local-self-certified",
259
253
  stack: event.stack,
260
254
  stage,
@@ -286,7 +280,6 @@ const transition = (
286
280
  return replaceRegistration(state, {
287
281
  ...existing,
288
282
  cleanup,
289
- cleanupStatus: event.outcome,
290
283
  });
291
284
  };
292
285
 
@@ -300,7 +293,7 @@ const recover = (
300
293
  row.stack === event.stack &&
301
294
  row.stage === event.stage
302
295
  );
303
- if (existing?.cleanupStatus !== "failed") {
296
+ if (existing?.cleanup.outcome !== "failed") {
304
297
  throw new Error(
305
298
  "Preview proof recovery re-enters cleanup only from a failed outcome."
306
299
  );
@@ -1,4 +1,5 @@
1
1
  import type { WorkflowStep } from "./factory-workflow.ts";
2
+ import { impactDemandConditions } from "./impact-demand-condition.ts";
2
3
 
3
4
  export const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
4
5
  export const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
@@ -36,8 +37,13 @@ export interface FactoryProductionImpactWorkflow {
36
37
  /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
37
38
  readonly decisionJobOutputs: Readonly<Record<string, string>>;
38
39
  readonly decisionStep: WorkflowStep;
39
- /** A fail-open condition: only an explicit usable withdrawal skips work. */
40
- readonly demandedIf: (targetName: string, decisionJob?: string) => string;
40
+ /**
41
+ * A fail-open cross-job condition: only an explicit usable withdrawal skips
42
+ * work. The decision job id is required.
43
+ */
44
+ readonly demandedIf: (targetName: string, decisionJob: string) => string;
45
+ /** The same fail-open condition for a step in the decision job. */
46
+ readonly demandedIfAtStep: (targetName: string) => string;
41
47
  readonly targetOutputs: Readonly<Record<string, string>>;
42
48
  }
43
49
 
@@ -79,6 +85,13 @@ export const factoryProductionImpactWorkflow = (
79
85
  run: `${cli} production:impact --before "$FACTORY_BEFORE_SHA" --after "$FACTORY_AFTER_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`,
80
86
  };
81
87
 
88
+ const conditions = impactDemandConditions({
89
+ decisionOutput: FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT,
90
+ label: "production",
91
+ stepId: FACTORY_PRODUCTION_IMPACT_STEP_ID,
92
+ targetOutputs,
93
+ });
94
+
82
95
  return Object.freeze({
83
96
  decisionJobOutputs: Object.freeze({
84
97
  basis: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT} }}`,
@@ -92,18 +105,8 @@ export const factoryProductionImpactWorkflow = (
92
105
  ),
93
106
  }),
94
107
  decisionStep: Object.freeze(decisionStep),
95
- demandedIf: (targetName: string, decisionJob?: string) => {
96
- const output = targetOutputs[targetName];
97
- if (output === undefined) {
98
- throw new Error(`Unknown production impact target "${targetName}".`);
99
- }
100
- const source = decisionJob
101
- ? `needs.${decisionJob}`
102
- : `steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}`;
103
- const outcome = decisionJob ? "result" : "outcome";
104
- const condition = `${source}.${outcome} != 'success' || ${source}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
105
- return decisionJob ? `always() && (${condition})` : condition;
106
- },
108
+ demandedIf: conditions.demandedIf,
109
+ demandedIfAtStep: conditions.demandedIfAtStep,
107
110
  targetOutputs: Object.freeze(targetOutputs),
108
111
  });
109
112
  };
@@ -245,7 +245,7 @@ const COMMAND_IDENTITY_MAX_LENGTH = 120;
245
245
  * cannot subtract hosted work. This is intentionally fail-closed and moves in
246
246
  * lockstep with the proof/check-payload producer.
247
247
  */
248
- const TRUSTED_IMPACT_STAMP_VERSION = 3;
248
+ const TRUSTED_IMPACT_STAMP_VERSION = 4;
249
249
 
250
250
  const isProofReuseCommand = (value: unknown): value is ProofReuseCommand => {
251
251
  if (!(value && typeof value === "object")) {
@@ -464,7 +464,9 @@ def releaseAuthorized($release; $commands; $stamp):
464
464
  type == "object"
465
465
  and (.name | nonemptyString)
466
466
  and (.basis | nonemptyString)
467
- and ((.impact == "affected") or (.impact == "not-affected"))))
467
+ and ((.impact == "affected") or (.impact == "not-affected"))
468
+ and ((.subscribedPaths | type) == "array")
469
+ and (.subscribedPaths | all(.[]; type == "string"))))
468
470
  and ($stamp.targets | distinctNames)
469
471
  and (([$releases[].name] - $executed | length) == ($releases | length))
470
472
  and ($releases | all(.[]; releaseAuthorized(.; $commands; $stamp)))
@@ -1,7 +1,16 @@
1
+ import { UPLOAD_ARTIFACT } from "./actions.ts";
1
2
  import type { PinnedAction } from "./actions.ts";
2
3
  import type { WorkflowStep } from "./factory-workflow.ts";
3
4
  import { assertPinnedAction } from "./pinned-action.ts";
4
5
 
6
+ /** `owner/repo` slice of the catalog's `UPLOAD_ARTIFACT` pin, derived rather
7
+ * than repeated as a literal, so a repository rename here cannot drift from
8
+ * the pin it validates against. */
9
+ const UPLOAD_ARTIFACT_REPOSITORY = UPLOAD_ARTIFACT.uses.slice(
10
+ 0,
11
+ UPLOAD_ARTIFACT.uses.indexOf("@")
12
+ );
13
+
5
14
  export const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
6
15
  /**
7
16
  * The artifact prefix and the five step IDs are module-private. Alpha.13
@@ -271,7 +280,12 @@ printf 'disposition=%s\nreason=%s\nbefore=%s\nafter=%s\nprovenance=%s\n' \
271
280
  } >> "$GITHUB_STEP_SUMMARY"`;
272
281
 
273
282
  export interface FactoryPushIdentityProducerOptions {
274
- /** Explicit caller-owned upload-artifact pin from its workflow artifact. */
283
+ /**
284
+ * Explicit caller-owned upload-artifact pin from its workflow artifact.
285
+ * Pass factory-ci's `UPLOAD_ARTIFACT` catalog export (or another
286
+ * `actions/upload-artifact` pin); the caller wires the step, the catalog
287
+ * only names the pin.
288
+ */
275
289
  readonly uploadArtifact: PinnedAction;
276
290
  /** Optional consumer trigger policy combined with the required push event. */
277
291
  readonly if?: string;
@@ -292,7 +306,7 @@ export const factoryPushIdentityProducer = (
292
306
  assertPinnedAction(
293
307
  "uploadArtifact",
294
308
  options.uploadArtifact,
295
- "actions/upload-artifact"
309
+ UPLOAD_ARTIFACT_REPOSITORY
296
310
  );
297
311
  const condition = options.if
298
312
  ? `github.event_name == 'push' && (${options.if})`