@patronage/factory-ci 1.0.0-alpha.13 → 1.0.0-alpha.16

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
@@ -165,6 +165,38 @@ The helper selects no action family or action version. Callers pass the explicit
165
165
 
166
166
  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
167
 
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:
169
+
170
+ ```ts
171
+ const impact = factoryCandidateImpactWorkflow({
172
+ targets: profile.impact?.targets.map(({ name }) => name) ?? [],
173
+ });
174
+
175
+ const decide = job("impact", {
176
+ // The lifecycle contract applies to the decision job too: a draft pull
177
+ // request runs no checkout, install, or classification.
178
+ if: factoryCandidateOrPushCondition(),
179
+ outputs: impact.decisionJobOutputs,
180
+ steps: [...setupSteps, impact.decisionStep],
181
+ });
182
+
183
+ const core = job("core", {
184
+ // Wrap the impact condition in `factoryCandidateOrPushCondition` as well:
185
+ // a skipped decision job is not `success`, so promotion out of draft
186
+ // re-demands every routed target. Impact routing narrows candidate work;
187
+ // it never widens the lifecycle.
188
+ if: factoryCandidateOrPushCondition(impact.demandedIf("core", "impact")),
189
+ needs: [decide],
190
+ // consumer-owned runner, surface commands, and credentials
191
+ });
192
+ ```
193
+
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.
195
+
196
+ 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
+
198
+ The step calls `psf candidate:impact` with the pull request's exact `base` and `head` commits (`github.event.pull_request.base.sha` / `head.sha` by default) and classifies the merge-base-to-head delta. It is `continue-on-error`, and every generated target condition keeps work demanded unless the command succeeded, reported a usable decision, and explicitly withdrew that target — unreadable identity, malformed or unsupported graph data, and classifier refusal all route to full demand. The decision checkout must check out the exact head commit with full history (`fetchDepth: 0`, `ref` = the head SHA); the default merge-ref checkout refuses withdrawal because the head is not `HEAD`. Surface job names, runners, commands, and credentials stay with the consumer, and `factoryProductionImpactWorkflow` keeps its merge-push contract unchanged.
199
+
168
200
  ### Generated shell
169
201
 
170
202
  ```ts
@@ -207,7 +239,10 @@ const coreCommands = profile.verification.commands.filter(
207
239
  );
208
240
 
209
241
  const core = job("core", {
210
- permissions: { checks: "read", contents: "read" },
242
+ // `pull-requests: read` serves the gate's default-branch merge fallback
243
+ // (#611). Without it the fallback's PR lookup fails and every merge push
244
+ // silently pays the full suite.
245
+ permissions: { checks: "read", contents: "read", "pull-requests": "read" },
211
246
  steps: [
212
247
  step(factoryProofGateStep({ commands: coreCommands, surface: "core" })),
213
248
  step(factoryProofTimingStartStep()),
@@ -219,6 +254,8 @@ const core = job("core", {
219
254
 
220
255
  The **proof-reuse gate** decides whether a hosted job may reuse the local verification the factory already published for this exact head (ADR 0022). Refusing runs hosted CI; it never fails the candidate. The App identity, check name, step id, output names, guard, and every trust predicate are fixed here rather than consumer-configurable — three repositories had grown three answers to the same question and had already drifted.
221
256
 
257
+ The gate runs on pull requests and on pushes to the default branch (#611). A push run first reads proof at the pushed head. A squash merge mints a new commit, so when that direct read finds no generation at all, a **merge fallback** looks up the single merged pull request whose merge commit is the pushed head and reuses that PR head's proven check run — only when the merge commit's tree id equals the proven head's tree id, which makes the pushed content byte-identical to what was verified (a clean squash of an unchanged tip). Patch identity was considered and rejected as the comparator: `git patch-id` normalizes whitespace and ignores base motion, so an identical patch can still integrate into a tree nothing verified. No unique producing PR, an unreadable commit, a proof that is anything but proven, or tree drift from a dirty or stale merge all leave the direct refusal standing and run the full suite. The fallback needs `pull-requests: read` and `contents: read` on the job in addition to `checks: read`; a job that grants less loses only the fallback.
258
+
222
259
  A proof is reusable only when the complete Checks API result (`filter=all`, every page) establishes one unambiguous newest generation by greatest `started_at`, produced by the pinned App for the exact repository and head, completed successfully with `outcome: passed`, and covering every command identity the guarded surface requires. Coverage is the union of `executedCommands` and commands released by `notRequiredCommands` only after the gate validates each release against that same proof binding's command-to-target map and identity-bound impact stamp. Missing, malformed, duplicated, affected, unknown, or differently bound release data refuses reuse and runs the hosted surface. `mode` is reported as diagnostic metadata, never authorized on: a reduced-mode proof whose executed and stamp-authorized released commands cover a surface is reusable.
223
260
 
224
261
  The only thing a consumer chooses is **what its surface requires**, expressed as the plain profile command objects that surface selects — a repository with distinct core and docs jobs selects distinct sets and gets distinct required coverage. `proofReuseRequiredCommands()` is the single derivation both the gate and the assertion go through. Identities are baked into the emitted script, so they are held to a plain `[A-Za-z0-9_][\w.:@/-]*` allow-list and shell-quoted at the interpolation site; a selection carrying anything else is unusable and degrades to a gate that always refuses.
@@ -231,7 +268,9 @@ The gate is a **step, not a job**, marked `continue-on-error`, with no `set -e`.
231
268
 
232
269
  `assertProofReuseCoverage({ commands, skipped, surface })` is the compile-time guard in front of the runtime `incomplete` refusal: hand it the same selection and the command strings the workflow would skip, and it fails the consumer's build when the two drift apart. Coverage is exact executable coverage. The deprecated `equivalents` input remains only for patch-release source compatibility and is ignored; prose cannot authorize a skip. Extracting the skipped strings stays with the consumer — this package never parses workflow source, because establishing trust that way is what killed an earlier attempt.
233
270
 
234
- `factoryProofTimingStartStep()` and `factoryProofReuseSummaryStep()` replace consumer-local proof timing summaries without taking over workflow topology. Put the start step immediately after the gate and the summary step after the guarded work. The helpers keep the established `Start CI timing` / `Record proof-reuse timing` names and distinguish pull-request reuse, pull-request full fallback, and merge-target full execution where the PR-only gate is explicitly not applicable. Reuse links the exact source check and bound head; every path emits a cheap Actions notice. Both steps are `continue-on-error`: missing timing support or an unwritable presentation destination cannot change a required job's conclusion. These helpers change no trust predicate or guard.
271
+ `factoryProofTimingStartStep()` and `factoryProofReuseSummaryStep()` replace consumer-local proof timing summaries without taking over workflow topology. Put the start step immediately after the gate and the summary step after the guarded work. The helpers keep the established `Start CI timing` / `Record proof-reuse timing` names and distinguish proof reuse, full fallback with the gate's reason, and pushes where the gate's step condition kept it off entirely. Reuse links the exact source check and bound head; every path emits a cheap Actions notice. Both steps are `continue-on-error`: missing timing support or an unwritable presentation destination cannot change a required job's conclusion. These helpers change no trust predicate or guard.
272
+
273
+ The raw summary script builder and the internal `ci-timing` step-id and step-name constants are not exported. Only the two step builders above are public; every production caller already consumed them through the builders, so this is a same-prerelease narrowing rather than a behavior change (#686).
235
274
 
236
275
  ### Alchemy entries
237
276
 
package/dist/index.d.ts CHANGED
@@ -318,7 +318,10 @@ declare const executeAlchemyEntry: (options: ExecuteAlchemyEntryOptions) => Prom
318
318
  *
319
319
  * It answers one question, before checkout and before install: may this job
320
320
  * reuse the local verification the factory already published for *this exact
321
- * head*? Refusing runs hosted CI; it never fails the candidate. Three
321
+ * head*? On a push to the merge target one fallback extends "this exact head"
322
+ * to "this exact tree": a squash merge whose result tree is byte-identical to
323
+ * the producing pull request's proven head tree reuses that head's proof
324
+ * (#611). Refusing runs hosted CI; it never fails the candidate. Three
322
325
  * repositories had independently grown their own answer to that question and
323
326
  * had already drifted apart, so the answer lives here once, with the App
324
327
  * identity, check name, step identity, output names, guard, and every trust
@@ -396,14 +399,31 @@ declare const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
396
399
  */
397
400
  declare const FACTORY_PROOF_GATE_GUARD = "steps.factory-proof.outputs.reuse-proof != 'true'";
398
401
  /**
399
- * Pull requests only. A push to a protected branch must never skip: deploy
400
- * gates wait for a green required check at the *merged* SHA, and no local
401
- * proof was ever written for that tree.
402
+ * Pull requests, plus pushes to the merge target (#611).
403
+ *
404
+ * The gate was pull-request-only through 1.0.0-alpha.15: a push to a
405
+ * protected branch always ran the full suite, because no local proof is ever
406
+ * written for the merged SHA. That still holds for the *direct* read — a
407
+ * squash merge mints a new commit — which is why the push path exists only
408
+ * together with the merge fallback below: when no generation exists at the
409
+ * merged SHA, the gate may look up the producing pull request and reuse its
410
+ * head proof, but only when the merge result's tree is exactly the proven
411
+ * head's tree. Deploy gates keep
412
+ * waiting for a green required check at the merged SHA; a reused proof turns
413
+ * that check green through the same guarded steps a pull request uses.
414
+ *
415
+ * The push side is deliberately the merge target only — the repository's
416
+ * default branch — not every pushed branch. Which branches trigger a
417
+ * consumer's workflow at all stays repository-owned; this condition only
418
+ * keeps the gate from consulting proof on pushes that are not merges into
419
+ * the default branch.
402
420
  */
403
- declare const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
421
+ declare const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))";
404
422
  /**
405
423
  * The complete refusal vocabulary. Deliberately few, because these are the
406
- * only distinctions the gate can honestly make from one Checks API read.
424
+ * only distinctions the gate can honestly make from its Checks API reads.
425
+ * The merge fallback adds no words: a fallback that establishes nothing
426
+ * leaves the direct read's refusal standing, with the specifics in `detail`.
407
427
  *
408
428
  * - `proven` a covering, passing, unambiguous proof for this exact head
409
429
  * - `none` no App-verified factory check run at this commit
@@ -515,9 +535,13 @@ interface FactoryProofGateStep {
515
535
  }
516
536
  /**
517
537
  * The step itself, structurally accepted by gagen's `step()` without adding a
518
- * gagen runtime dependency. It belongs first in the job it guards: one Checks
519
- * API read with the default `GITHUB_TOKEN` (`checks: read`), no checkout, no
520
- * install, so a proven head costs a runner nothing beyond job startup.
538
+ * gagen runtime dependency. It belongs first in the job it guards: a few API
539
+ * reads with the default `GITHUB_TOKEN`, no checkout, no install, so a proven
540
+ * head costs a runner nothing beyond job startup. A pull request needs
541
+ * `checks: read`; the push-event merge fallback additionally reads the
542
+ * producing pull request (`pull-requests: read`) and the two commit objects
543
+ * whose tree ids it compares (`contents: read`). A job that grants less
544
+ * loses only the fallback — the failed read degrades to the full suite.
521
545
  *
522
546
  * A **step, not a job**, and that is not a style preference. A separate gate
523
547
  * job that errored would leave the guarded job `skipped`, and a summary job
@@ -586,9 +610,6 @@ interface FactoryProofTimingStartStep {
586
610
  readonly run: string;
587
611
  }
588
612
  declare const factoryProofTimingStartStep: () => FactoryProofTimingStartStep;
589
- declare const factoryProofReuseSummaryScript: ({
590
- surface
591
- }: FactoryProofReusePresentationOptions) => string;
592
613
  interface FactoryProofReuseSummaryStep {
593
614
  readonly continueOnError: true;
594
615
  readonly env: Readonly<Record<string, string>>;
@@ -598,6 +619,42 @@ interface FactoryProofReuseSummaryStep {
598
619
  }
599
620
  declare const factoryProofReuseSummaryStep: (options: FactoryProofReusePresentationOptions) => FactoryProofReuseSummaryStep;
600
621
  //#endregion
622
+ //#region src/candidate-impact-workflow.d.ts
623
+ declare const FACTORY_CANDIDATE_IMPACT_STEP_ID = "candidate_impact";
624
+ declare const FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT = "decision";
625
+ declare const FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT = "basis";
626
+ declare const FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT = "unsubscribed_paths";
627
+ interface FactoryCandidateImpactWorkflowOptions {
628
+ readonly base?: string;
629
+ readonly cli?: string;
630
+ readonly head?: string;
631
+ readonly profilePath?: string;
632
+ readonly targets: readonly string[];
633
+ }
634
+ interface FactoryCandidateImpactWorkflow {
635
+ /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
636
+ readonly decisionJobOutputs: Readonly<Record<string, string>>;
637
+ readonly decisionStep: WorkflowStep;
638
+ /** A fail-open condition: only an explicit usable withdrawal skips work. */
639
+ readonly demandedIf: (targetName: string, decisionJob?: string) => string;
640
+ readonly targetOutputs: Readonly<Record<string, string>>;
641
+ }
642
+ /**
643
+ * Generate the factory-owned candidate routing seam for a consumer
644
+ * pull-request workflow (#741). The decision step calls
645
+ * `psf candidate:impact` with the pull request's exact base/head commits, so
646
+ * the canonical profile impact declarations and dependency graph route the
647
+ * run — never a second path matcher. Consumers retain jobs, surface names,
648
+ * runners, commands, and credentials; this artifact supplies only the
649
+ * decision step and per-target withdrawal conditions. Target output keys are
650
+ * the same stable contract the production decision seam writes.
651
+ *
652
+ * The decision checkout must have the exact head commit checked out and the
653
+ * base commit reachable (`fetchDepth: 0`, `ref` = the head SHA); anything
654
+ * else refuses withdrawal and keeps every target demanded.
655
+ */
656
+ declare const factoryCandidateImpactWorkflow: (options: FactoryCandidateImpactWorkflowOptions) => FactoryCandidateImpactWorkflow;
657
+ //#endregion
601
658
  //#region src/production-impact-workflow.d.ts
602
659
  declare const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
603
660
  declare const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
@@ -630,12 +687,6 @@ declare const factoryProductionImpactWorkflow: (options: FactoryProductionImpact
630
687
  //#endregion
631
688
  //#region src/push-identity-workflow.d.ts
632
689
  declare const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
633
- declare const FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX = "factory-push-identity";
634
- declare const FACTORY_PUSH_IDENTITY_RECORD_STEP_ID = "factory_push_identity_record";
635
- declare const FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID = "factory_push_identity_lookup";
636
- declare const FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID = "factory_push_identity_download";
637
- declare const FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID = "factory_push_identity_checkout";
638
- declare const FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID = "factory_push_identity";
639
690
  /** Versioned document uploaded by a push-triggered verification run. */
640
691
  interface FactoryPushIdentityEnvelope {
641
692
  readonly after: string;
@@ -965,4 +1016,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
965
1016
  readonly source: string;
966
1017
  }) => void;
967
1018
  //#endregion
968
- export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, 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_PROOF_TIMING_START_STEP_NAME, FACTORY_PROOF_TIMING_STEP_ID, FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME, FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX, FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID, FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID, FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID, FACTORY_PUSH_IDENTITY_RECORD_STEP_ID, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID, 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, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, 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, factoryCandidateOrPushCondition, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryScript, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
1019
+ export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, 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_PUSH_IDENTITY_SCHEMA_VERSION, type FactoryCandidateImpactWorkflow, type FactoryCandidateImpactWorkflowOptions, 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, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, 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, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
package/dist/index.js CHANGED
@@ -429,7 +429,10 @@ const executeAlchemyEntry = async (options) => {
429
429
  *
430
430
  * It answers one question, before checkout and before install: may this job
431
431
  * reuse the local verification the factory already published for *this exact
432
- * head*? Refusing runs hosted CI; it never fails the candidate. Three
432
+ * head*? On a push to the merge target one fallback extends "this exact head"
433
+ * to "this exact tree": a squash merge whose result tree is byte-identical to
434
+ * the producing pull request's proven head tree reuses that head's proof
435
+ * (#611). Refusing runs hosted CI; it never fails the candidate. Three
433
436
  * repositories had independently grown their own answer to that question and
434
437
  * had already drifted apart, so the answer lives here once, with the App
435
438
  * identity, check name, step identity, output names, guard, and every trust
@@ -526,14 +529,31 @@ const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
526
529
  */
527
530
  const FACTORY_PROOF_GATE_GUARD = `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT} != 'true'`;
528
531
  /**
529
- * Pull requests only. A push to a protected branch must never skip: deploy
530
- * gates wait for a green required check at the *merged* SHA, and no local
531
- * proof was ever written for that tree.
532
+ * Pull requests, plus pushes to the merge target (#611).
533
+ *
534
+ * The gate was pull-request-only through 1.0.0-alpha.15: a push to a
535
+ * protected branch always ran the full suite, because no local proof is ever
536
+ * written for the merged SHA. That still holds for the *direct* read — a
537
+ * squash merge mints a new commit — which is why the push path exists only
538
+ * together with the merge fallback below: when no generation exists at the
539
+ * merged SHA, the gate may look up the producing pull request and reuse its
540
+ * head proof, but only when the merge result's tree is exactly the proven
541
+ * head's tree. Deploy gates keep
542
+ * waiting for a green required check at the merged SHA; a reused proof turns
543
+ * that check green through the same guarded steps a pull request uses.
544
+ *
545
+ * The push side is deliberately the merge target only — the repository's
546
+ * default branch — not every pushed branch. Which branches trigger a
547
+ * consumer's workflow at all stays repository-owned; this condition only
548
+ * keeps the gate from consulting proof on pushes that are not merges into
549
+ * the default branch.
532
550
  */
533
- const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
551
+ const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))";
534
552
  /**
535
553
  * The complete refusal vocabulary. Deliberately few, because these are the
536
- * only distinctions the gate can honestly make from one Checks API read.
554
+ * only distinctions the gate can honestly make from its Checks API reads.
555
+ * The merge fallback adds no words: a fallback that establishes nothing
556
+ * leaves the direct read's refusal standing, with the specifics in `detail`.
537
557
  *
538
558
  * - `proven` a covering, passing, unambiguous proof for this exact head
539
559
  * - `none` no App-verified factory check run at this commit
@@ -648,8 +668,8 @@ const resolveProofReuseCommands = (catalog, names, selectionLabel) => names.map(
648
668
  };
649
669
  });
650
670
  /**
651
- * jq program: every page of the Checks API result in, three sanitized lines
652
- * (`reason`, `mode`, uncovered commands) out.
671
+ * jq program: every page of the Checks API result in, four sanitized lines
672
+ * (`reason`, `mode`, uncovered commands, source URL) out.
653
673
  *
654
674
  * The input is what `gh api --paginate` actually writes: the pages
655
675
  * *concatenated* as a stream of top-level response objects, not merged into
@@ -838,31 +858,43 @@ detail=''
838
858
  mode=''
839
859
  missing=''
840
860
  source_url=''
861
+ proof_head="${shellExpansion$1("HEAD_SHA:-")}"
862
+ merge_note=''
841
863
 
864
+ # One proof lookup at one commit. Sets lookup_reason / lookup_mode /
865
+ # lookup_missing / lookup_url, or lookup_detail when the read itself
866
+ # failed. Always returns 0: every caller must reach the single
867
+ # GITHUB_OUTPUT write below whatever this function meets.
868
+ #
842
869
  # filter=all with full pagination is load-bearing (ADR 0022). GitHub's
843
870
  # default "latest" filter is ordered by completion, so a newer generation
844
871
  # that is still running can be hidden behind an older completed one — the
845
872
  # gate would then read a stale pass as current.
846
- if [ -z "${shellExpansion$1("HEAD_SHA:-")}" ]; then
847
- detail='no pull request head SHA'
848
- elif [ -z "${shellExpansion$1("GITHUB_REPOSITORY:-")}" ]; then
849
- detail='no repository name'
850
- elif ! response=$(gh api --method GET --paginate \
851
- "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs" \
852
- -f "check_name=$CHECK_NAME" \
853
- -f filter=all \
854
- -f per_page=100 2>&1); then
855
- detail="checks API unreadable: $response"
856
- elif ! finding=$(printf '%s' "$response" | jq -r -s \
857
- --arg name "$CHECK_NAME" \
858
- --arg app "$FACTORY_APP_ID" \
859
- --arg sha "$HEAD_SHA" \
860
- --arg repository "$GITHUB_REPOSITORY" \
861
- --argjson required "$REQUIRED_COMMANDS" \
862
- '${GATE_JQ}' 2>&1); then
863
- detail="unreadable proof binding: $finding"
864
- else
865
- # Three separate reads, not one IFS split: tab is IFS whitespace, so a
873
+ gate_lookup() {
874
+ lookup_reason=error
875
+ lookup_mode=''
876
+ lookup_missing=''
877
+ lookup_url=''
878
+ lookup_detail=''
879
+ if ! lookup_response=$(gh api --method GET --paginate \
880
+ "repos/$GITHUB_REPOSITORY/commits/$1/check-runs" \
881
+ -f "check_name=$CHECK_NAME" \
882
+ -f filter=all \
883
+ -f per_page=100 2>&1); then
884
+ lookup_detail="checks API unreadable: $lookup_response"
885
+ return 0
886
+ fi
887
+ if ! lookup_finding=$(printf '%s' "$lookup_response" | jq -r -s \
888
+ --arg name "$CHECK_NAME" \
889
+ --arg app "$FACTORY_APP_ID" \
890
+ --arg sha "$1" \
891
+ --arg repository "$GITHUB_REPOSITORY" \
892
+ --argjson required "$REQUIRED_COMMANDS" \
893
+ '${GATE_JQ}' 2>&1); then
894
+ lookup_detail="unreadable proof binding: $lookup_finding"
895
+ return 0
896
+ fi
897
+ # Four separate reads, not one IFS split: tab is IFS whitespace, so a
866
898
  # split would collapse the empty fields the vocabulary relies on.
867
899
  #
868
900
  # The trailing "|| :" on each read is load-bearing. "missing" is empty for
@@ -873,18 +905,106 @@ else
873
905
  # GITHUB_OUTPUT write below, which is exactly how this gate failed in
874
906
  # hosted CI on every decidable verdict while every test passed (#319).
875
907
  {
876
- IFS= read -r reason || :
877
- IFS= read -r mode || :
878
- IFS= read -r missing || :
879
- IFS= read -r source_url || :
880
- } <<< "$finding"
881
- case "$reason" in
908
+ IFS= read -r lookup_reason || :
909
+ IFS= read -r lookup_mode || :
910
+ IFS= read -r lookup_missing || :
911
+ IFS= read -r lookup_url || :
912
+ } <<< "$lookup_finding"
913
+ case "$lookup_reason" in
882
914
  proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
883
915
  *)
884
- detail="unexpected gate result: $reason"
885
- reason=error
916
+ lookup_detail="unexpected gate result: $lookup_reason"
917
+ lookup_reason=error
886
918
  ;;
887
919
  esac
920
+ return 0
921
+ }
922
+
923
+ if [ -z "${shellExpansion$1("HEAD_SHA:-")}" ]; then
924
+ detail='no verification head SHA'
925
+ elif [ -z "${shellExpansion$1("GITHUB_REPOSITORY:-")}" ]; then
926
+ detail='no repository name'
927
+ else
928
+ gate_lookup "$HEAD_SHA"
929
+ reason="$lookup_reason"
930
+ detail="$lookup_detail"
931
+ mode="$lookup_mode"
932
+ missing="$lookup_missing"
933
+ source_url="$lookup_url"
934
+ fi
935
+
936
+ # Merge fallback (#611). A squash merge mints a new commit, so the direct
937
+ # read at a pushed merge-target head finds nothing even when the factory
938
+ # proved the producing pull request head. Only when the direct read found no
939
+ # generation at all on a push event, look up the producing pull request and
940
+ # reuse its head proof — and only when the merge commit's TREE id equals the
941
+ # proven head's tree id, which makes the pushed content byte-identical to
942
+ # what was verified (a clean squash of an unchanged tip). Patch identity was
943
+ # considered and rejected as the comparator: git patch-id normalizes
944
+ # whitespace and ignores base motion, so an identical patch can still
945
+ # produce an integrated tree that was never tested. Everything else — no
946
+ # unique merged producing PR, an unreadable commit, a proof that is anything
947
+ # but proven, or tree drift from a dirty or stale merge — leaves the direct
948
+ # "none" refusal standing, so the full suite runs (fail open).
949
+ if [ "$reason" = 'none' ] && [ "${shellExpansion$1("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
950
+ producing_head=''
951
+ merge_tree=''
952
+ head_tree=''
953
+ if ! producing_pulls=$(gh api --method GET --paginate \
954
+ "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/pulls" \
955
+ -f per_page=100 2>&1); then
956
+ detail="producing pull request unreadable: $producing_pulls"
957
+ elif ! producing_head=$(printf '%s' "$producing_pulls" | jq -r -s \
958
+ --arg sha "$HEAD_SHA" \
959
+ '[ add[]?
960
+ | select((.merged_at // null) != null)
961
+ | select((.merge_commit_sha // "") == $sha)
962
+ | ((.head.sha // "") | tostring) ]
963
+ | if length == 1 then .[0] else "" end' 2>&1); then
964
+ detail="producing pull request unreadable: $producing_head"
965
+ producing_head=''
966
+ elif ! [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]]; then
967
+ detail='no single merged producing pull request at this commit'
968
+ producing_head=''
969
+ elif ! merge_commit=$(gh api --method GET \
970
+ "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA" 2>&1); then
971
+ detail="merge commit unreadable: $merge_commit"
972
+ elif ! merge_tree=$(printf '%s' "$merge_commit" | jq -r \
973
+ '(.commit.tree.sha // "") | tostring' 2>&1); then
974
+ detail="merge commit unreadable: $merge_tree"
975
+ merge_tree=''
976
+ elif ! [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]]; then
977
+ detail='merge commit carries no readable tree id'
978
+ merge_tree=''
979
+ elif ! head_commit=$(gh api --method GET \
980
+ "repos/$GITHUB_REPOSITORY/commits/$producing_head" 2>&1); then
981
+ detail="producing head commit unreadable: $head_commit"
982
+ elif ! head_tree=$(printf '%s' "$head_commit" | jq -r \
983
+ '(.commit.tree.sha // "") | tostring' 2>&1); then
984
+ detail="producing head commit unreadable: $head_tree"
985
+ head_tree=''
986
+ elif ! [[ "$head_tree" =~ ^[0-9a-f]{40}$ ]]; then
987
+ detail='producing head commit carries no readable tree id'
988
+ head_tree=''
989
+ elif [ "$merge_tree" != "$head_tree" ]; then
990
+ detail='tree drift: the merge result is not the proven head tree'
991
+ merge_tree=''
992
+ fi
993
+ if [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]] \
994
+ && [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]] \
995
+ && [ "$merge_tree" = "$head_tree" ]; then
996
+ gate_lookup "$producing_head"
997
+ if [ "$lookup_reason" = 'proven' ]; then
998
+ reason=proven
999
+ mode="$lookup_mode"
1000
+ missing=''
1001
+ source_url="$lookup_url"
1002
+ proof_head="$producing_head"
1003
+ merge_note="the merge result's tree is byte-identical to the tree proven at pull request head $producing_head"
1004
+ else
1005
+ detail="producing pull request proof not reusable ($lookup_reason)"
1006
+ fi
1007
+ fi
888
1008
  fi
889
1009
 
890
1010
  # The binding is App-verified, but it still reaches markdown. Only a plain
@@ -915,21 +1035,28 @@ say ''
915
1035
 
916
1036
  case "$reason" in
917
1037
  proven)
918
- echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
919
- say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
1038
+ echo "Factory proof: reusing local verification of $proof_head; skipping the $SURFACE suite."
1039
+ say "Skipped. The factory already verified this exact content, so this job did not run the $SURFACE suite a second time."
920
1040
  say ''
921
1041
  if [ -n "$source_url" ]; then
922
1042
  say "- Reused proof: [\`$CHECK_NAME\`]($source_url), published by the pinned factory GitHub App."
923
1043
  else
924
1044
  say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
925
1045
  fi
926
- say "- Covers head: \`$HEAD_SHA\`"
1046
+ say "- Covers head: \`$proof_head\`"
1047
+ if [ -n "$merge_note" ]; then
1048
+ say "- Merge-target reuse: $merge_note."
1049
+ fi
927
1050
  say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
928
1051
  ;;
929
1052
  none)
930
1053
  say 'Ran the full suite. No trusted factory proof covers this commit.'
931
1054
  say ''
932
1055
  say "The factory GitHub App has published no \`$CHECK_NAME\` check run at head \`$HEAD_SHA\`. Proof is keyed to the commit, so proof written for any other commit is not visible here. A pull request opened by hand carries no factory proof at all, which is normal."
1056
+ if [ -n "$detail" ]; then
1057
+ say ''
1058
+ say "- Merge fallback: $detail"
1059
+ fi
933
1060
  say ''
934
1061
  say '${CORRECTIVE_LINE}'
935
1062
  ;;
@@ -1009,9 +1136,13 @@ const factoryProofGateScript = ({ commands, surface }) => {
1009
1136
  };
1010
1137
  /**
1011
1138
  * The step itself, structurally accepted by gagen's `step()` without adding a
1012
- * gagen runtime dependency. It belongs first in the job it guards: one Checks
1013
- * API read with the default `GITHUB_TOKEN` (`checks: read`), no checkout, no
1014
- * install, so a proven head costs a runner nothing beyond job startup.
1139
+ * gagen runtime dependency. It belongs first in the job it guards: a few API
1140
+ * reads with the default `GITHUB_TOKEN`, no checkout, no install, so a proven
1141
+ * head costs a runner nothing beyond job startup. A pull request needs
1142
+ * `checks: read`; the push-event merge fallback additionally reads the
1143
+ * producing pull request (`pull-requests: read`) and the two commit objects
1144
+ * whose tree ids it compares (`contents: read`). A job that grants less
1145
+ * loses only the fallback — the failed read degrades to the full suite.
1015
1146
  *
1016
1147
  * A **step, not a job**, and that is not a style preference. A separate gate
1017
1148
  * job that errored would leave the guarded job `skipped`, and a summary job
@@ -1024,7 +1155,7 @@ const factoryProofGateStep = (options) => Object.freeze({
1024
1155
  continueOnError: true,
1025
1156
  env: Object.freeze({
1026
1157
  GH_TOKEN: githubExpression$1("secrets.GITHUB_TOKEN"),
1027
- HEAD_SHA: githubExpression$1("github.event.pull_request.head.sha")
1158
+ HEAD_SHA: githubExpression$1("github.event.pull_request.head.sha || github.sha")
1028
1159
  }),
1029
1160
  id: FACTORY_PROOF_GATE_STEP_ID,
1030
1161
  if: FACTORY_PROOF_GATE_IF,
@@ -1090,11 +1221,15 @@ say() { printf '%s\n' "$1" >> "$SUMMARY"; }
1090
1221
  say '## ${label} timing'
1091
1222
  say ''
1092
1223
 
1093
- if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
1224
+ if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ] && [ -z "${shellExpansion("PROOF_REASON:-")}" ]; then
1225
+ # The gate runs on pushes to the merge target (#611). A push run with no
1226
+ # recorded reason is one where the gate step's condition kept it off
1227
+ # entirely (a non-merge-target ref), so the full suite ran with no
1228
+ # reuse decision.
1094
1229
  say '- Path: merge-target full execution'
1095
- say '- Proof-reuse gate: not applicable on merge-target runs; the full suite ran.'
1230
+ say '- Proof-reuse gate: did not run on this ref; the full suite ran.'
1096
1231
  say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
1097
- echo "::notice title=Factory proof reuse::Proof reuse is not applicable on merge-target runs; the full ${label} suite executed."
1232
+ echo "::notice title=Factory proof reuse::The proof-reuse gate did not run on this ref; the full ${label} suite executed."
1098
1233
  elif [ "${shellExpansion("PROOF_REUSED:-")}" = 'true' ]; then
1099
1234
  say '- Path: trusted local proof reused'
1100
1235
  if [ -n "${shellExpansion("PROOF_SOURCE_URL:-")}" ]; then
@@ -1121,7 +1256,7 @@ const factoryProofReuseSummaryStep = (options) => Object.freeze({
1121
1256
  continueOnError: true,
1122
1257
  env: Object.freeze({
1123
1258
  CI_STARTED_MS: githubExpression(`steps.${FACTORY_PROOF_TIMING_STEP_ID}.outputs.started_ms`),
1124
- PROOF_HEAD_SHA: githubExpression("github.event.pull_request.head.sha"),
1259
+ PROOF_HEAD_SHA: githubExpression("github.event.pull_request.head.sha || github.sha"),
1125
1260
  PROOF_MODE: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_MODE_OUTPUT}`),
1126
1261
  PROOF_REASON: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_REASON_OUTPUT}`),
1127
1262
  PROOF_REUSED: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT}`),
@@ -1138,7 +1273,7 @@ const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
1138
1273
  const FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT = "basis";
1139
1274
  const FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT = "unsubscribed_paths";
1140
1275
  const OUTPUT_NAME_PATTERN = /[^a-z0-9_]+/gu;
1141
- const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
1276
+ const shellQuote$1 = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
1142
1277
  /** Stable GitHub-output key for one declared target. */
1143
1278
  const productionImpactTargetOutput = (targetName) => {
1144
1279
  const normalized = targetName.toLowerCase().replaceAll(OUTPUT_NAME_PATTERN, "_").replaceAll(/^_+|_+$/gu, "");
@@ -1157,7 +1292,7 @@ const factoryProductionImpactWorkflow = (options) => {
1157
1292
  const before = options.before ?? `\${{ github.event.before }}`;
1158
1293
  const after = options.after ?? `\${{ github.sha }}`;
1159
1294
  const cli = options.cli ?? "pnpm exec psf";
1160
- const profile = options.profilePath ? ` --profile ${shellQuote(options.profilePath)}` : "";
1295
+ const profile = options.profilePath ? ` --profile ${shellQuote$1(options.profilePath)}` : "";
1161
1296
  const decisionStep = {
1162
1297
  continueOnError: true,
1163
1298
  env: {
@@ -1187,6 +1322,62 @@ const factoryProductionImpactWorkflow = (options) => {
1187
1322
  });
1188
1323
  };
1189
1324
  //#endregion
1325
+ //#region src/candidate-impact-workflow.ts
1326
+ const FACTORY_CANDIDATE_IMPACT_STEP_ID = "candidate_impact";
1327
+ const FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT = "decision";
1328
+ const FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT = "basis";
1329
+ const FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT = "unsubscribed_paths";
1330
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
1331
+ /**
1332
+ * Generate the factory-owned candidate routing seam for a consumer
1333
+ * pull-request workflow (#741). The decision step calls
1334
+ * `psf candidate:impact` with the pull request's exact base/head commits, so
1335
+ * the canonical profile impact declarations and dependency graph route the
1336
+ * run — never a second path matcher. Consumers retain jobs, surface names,
1337
+ * runners, commands, and credentials; this artifact supplies only the
1338
+ * decision step and per-target withdrawal conditions. Target output keys are
1339
+ * the same stable contract the production decision seam writes.
1340
+ *
1341
+ * The decision checkout must have the exact head commit checked out and the
1342
+ * base commit reachable (`fetchDepth: 0`, `ref` = the head SHA); anything
1343
+ * else refuses withdrawal and keeps every target demanded.
1344
+ */
1345
+ const factoryCandidateImpactWorkflow = (options) => {
1346
+ const targetOutputs = Object.fromEntries(options.targets.map((target) => [target, productionImpactTargetOutput(target)]));
1347
+ if (new Set(Object.values(targetOutputs)).size !== options.targets.length) throw new Error("Impact target names must map to distinct GitHub output keys.");
1348
+ const base = options.base ?? `\${{ github.event.pull_request.base.sha }}`;
1349
+ const head = options.head ?? `\${{ github.event.pull_request.head.sha }}`;
1350
+ const cli = options.cli ?? "pnpm exec psf";
1351
+ const profile = options.profilePath ? ` --profile ${shellQuote(options.profilePath)}` : "";
1352
+ const decisionStep = {
1353
+ continueOnError: true,
1354
+ env: {
1355
+ FACTORY_BASE_SHA: base,
1356
+ FACTORY_HEAD_SHA: head
1357
+ },
1358
+ id: FACTORY_CANDIDATE_IMPACT_STEP_ID,
1359
+ name: "Classify candidate impact",
1360
+ run: `${cli} candidate:impact --base "$FACTORY_BASE_SHA" --head "$FACTORY_HEAD_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`
1361
+ };
1362
+ return Object.freeze({
1363
+ decisionJobOutputs: Object.freeze({
1364
+ basis: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT} }}`,
1365
+ decision: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT} }}`,
1366
+ unsubscribed_paths: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT} }}`,
1367
+ ...Object.fromEntries(Object.values(targetOutputs).map((output) => [output, `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${output} }}`]))
1368
+ }),
1369
+ decisionStep: Object.freeze(decisionStep),
1370
+ demandedIf: (targetName, decisionJob) => {
1371
+ const output = targetOutputs[targetName];
1372
+ if (output === void 0) throw new Error(`Unknown candidate impact target "${targetName}".`);
1373
+ const source = decisionJob ? `needs.${decisionJob}` : `steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}`;
1374
+ const condition = `${source}.${decisionJob ? "result" : "outcome"} != 'success' || ${source}.outputs.${FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
1375
+ return decisionJob ? `always() && (${condition})` : condition;
1376
+ },
1377
+ targetOutputs: Object.freeze(targetOutputs)
1378
+ });
1379
+ };
1380
+ //#endregion
1190
1381
  //#region src/push-identity-workflow.ts
1191
1382
  const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
1192
1383
  const FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX = "factory-push-identity";
@@ -2254,4 +2445,4 @@ const assertWorkflowShellParses = (yaml, options) => {
2254
2445
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
2255
2446
  };
2256
2447
  //#endregion
2257
- export { FACTORY_CANDIDATE_PULL_REQUEST_TYPES, 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_PROOF_TIMING_START_STEP_NAME, FACTORY_PROOF_TIMING_STEP_ID, FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME, FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX, FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID, FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID, FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID, FACTORY_PUSH_IDENTITY_RECORD_STEP_ID, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateOrPushCondition, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryScript, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
2448
+ export { FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, 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_PUSH_IDENTITY_SCHEMA_VERSION, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, 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.13",
3
+ "version": "1.0.0-alpha.16",
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",
@@ -0,0 +1,103 @@
1
+ import type { WorkflowStep } from "./factory-workflow.ts";
2
+ import { productionImpactTargetOutput } from "./production-impact-workflow.ts";
3
+
4
+ export const FACTORY_CANDIDATE_IMPACT_STEP_ID = "candidate_impact";
5
+ export const FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT = "decision";
6
+ export const FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT = "basis";
7
+ export const FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT =
8
+ "unsubscribed_paths";
9
+
10
+ const shellQuote = (value: string): string =>
11
+ `'${value.replaceAll("'", `'"'"'`)}'`;
12
+
13
+ export interface FactoryCandidateImpactWorkflowOptions {
14
+ readonly base?: string;
15
+ readonly cli?: string;
16
+ readonly head?: string;
17
+ readonly profilePath?: string;
18
+ readonly targets: readonly string[];
19
+ }
20
+
21
+ export interface FactoryCandidateImpactWorkflow {
22
+ /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
23
+ readonly decisionJobOutputs: Readonly<Record<string, string>>;
24
+ readonly decisionStep: WorkflowStep;
25
+ /** A fail-open condition: only an explicit usable withdrawal skips work. */
26
+ readonly demandedIf: (targetName: string, decisionJob?: string) => string;
27
+ readonly targetOutputs: Readonly<Record<string, string>>;
28
+ }
29
+
30
+ /**
31
+ * Generate the factory-owned candidate routing seam for a consumer
32
+ * pull-request workflow (#741). The decision step calls
33
+ * `psf candidate:impact` with the pull request's exact base/head commits, so
34
+ * the canonical profile impact declarations and dependency graph route the
35
+ * run — never a second path matcher. Consumers retain jobs, surface names,
36
+ * runners, commands, and credentials; this artifact supplies only the
37
+ * decision step and per-target withdrawal conditions. Target output keys are
38
+ * the same stable contract the production decision seam writes.
39
+ *
40
+ * The decision checkout must have the exact head commit checked out and the
41
+ * base commit reachable (`fetchDepth: 0`, `ref` = the head SHA); anything
42
+ * else refuses withdrawal and keeps every target demanded.
43
+ */
44
+ export const factoryCandidateImpactWorkflow = (
45
+ options: FactoryCandidateImpactWorkflowOptions
46
+ ): FactoryCandidateImpactWorkflow => {
47
+ const targetOutputs = Object.fromEntries(
48
+ options.targets.map((target) => [
49
+ target,
50
+ productionImpactTargetOutput(target),
51
+ ])
52
+ );
53
+ if (new Set(Object.values(targetOutputs)).size !== options.targets.length) {
54
+ throw new Error(
55
+ "Impact target names must map to distinct GitHub output keys."
56
+ );
57
+ }
58
+
59
+ const base = options.base ?? `\${{ github.event.pull_request.base.sha }}`;
60
+ const head = options.head ?? `\${{ github.event.pull_request.head.sha }}`;
61
+ const cli = options.cli ?? "pnpm exec psf";
62
+ const profile = options.profilePath
63
+ ? ` --profile ${shellQuote(options.profilePath)}`
64
+ : "";
65
+ const decisionStep: WorkflowStep = {
66
+ continueOnError: true,
67
+ env: {
68
+ FACTORY_BASE_SHA: base,
69
+ FACTORY_HEAD_SHA: head,
70
+ },
71
+ id: FACTORY_CANDIDATE_IMPACT_STEP_ID,
72
+ name: "Classify candidate impact",
73
+ run: `${cli} candidate:impact --base "$FACTORY_BASE_SHA" --head "$FACTORY_HEAD_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`,
74
+ };
75
+
76
+ return Object.freeze({
77
+ decisionJobOutputs: Object.freeze({
78
+ basis: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT} }}`,
79
+ decision: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT} }}`,
80
+ unsubscribed_paths: `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT} }}`,
81
+ ...Object.fromEntries(
82
+ Object.values(targetOutputs).map((output) => [
83
+ output,
84
+ `\${{ steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}.outputs.${output} }}`,
85
+ ])
86
+ ),
87
+ }),
88
+ decisionStep: Object.freeze(decisionStep),
89
+ demandedIf: (targetName: string, decisionJob?: string) => {
90
+ const output = targetOutputs[targetName];
91
+ if (output === undefined) {
92
+ throw new Error(`Unknown candidate impact target "${targetName}".`);
93
+ }
94
+ const source = decisionJob
95
+ ? `needs.${decisionJob}`
96
+ : `steps.${FACTORY_CANDIDATE_IMPACT_STEP_ID}`;
97
+ const outcome = decisionJob ? "result" : "outcome";
98
+ const condition = `${source}.${outcome} != 'success' || ${source}.outputs.${FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
99
+ return decisionJob ? `always() && (${condition})` : condition;
100
+ },
101
+ targetOutputs: Object.freeze(targetOutputs),
102
+ });
103
+ };
package/src/index.ts CHANGED
@@ -82,13 +82,18 @@ export {
82
82
  type FactoryProofReusePresentationOptions,
83
83
  type FactoryProofReuseSummaryStep,
84
84
  type FactoryProofTimingStartStep,
85
- FACTORY_PROOF_TIMING_START_STEP_NAME,
86
- FACTORY_PROOF_TIMING_STEP_ID,
87
- FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME,
88
- factoryProofReuseSummaryScript,
89
85
  factoryProofReuseSummaryStep,
90
86
  factoryProofTimingStartStep,
91
87
  } from "./proof-reuse-presentation.ts";
88
+ export {
89
+ FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT,
90
+ FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT,
91
+ FACTORY_CANDIDATE_IMPACT_STEP_ID,
92
+ FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT,
93
+ type FactoryCandidateImpactWorkflow,
94
+ type FactoryCandidateImpactWorkflowOptions,
95
+ factoryCandidateImpactWorkflow,
96
+ } from "./candidate-impact-workflow.ts";
92
97
  export {
93
98
  FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT,
94
99
  FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT,
@@ -100,13 +105,7 @@ export {
100
105
  productionImpactTargetOutput,
101
106
  } from "./production-impact-workflow.ts";
102
107
  export {
103
- FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX,
104
- FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID,
105
- FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID,
106
- FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID,
107
- FACTORY_PUSH_IDENTITY_RECORD_STEP_ID,
108
108
  FACTORY_PUSH_IDENTITY_SCHEMA_VERSION,
109
- FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID,
110
109
  type FactoryPushIdentityConsumer,
111
110
  type FactoryPushIdentityConsumerOptions,
112
111
  type FactoryPushIdentityDisposition,
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * It answers one question, before checkout and before install: may this job
5
5
  * reuse the local verification the factory already published for *this exact
6
- * head*? Refusing runs hosted CI; it never fails the candidate. Three
6
+ * head*? On a push to the merge target one fallback extends "this exact head"
7
+ * to "this exact tree": a squash merge whose result tree is byte-identical to
8
+ * the producing pull request's proven head tree reuses that head's proof
9
+ * (#611). Refusing runs hosted CI; it never fails the candidate. Three
7
10
  * repositories had independently grown their own answer to that question and
8
11
  * had already drifted apart, so the answer lives here once, with the App
9
12
  * identity, check name, step identity, output names, guard, and every trust
@@ -117,15 +120,33 @@ export const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
117
120
  export const FACTORY_PROOF_GATE_GUARD = `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT} != 'true'`;
118
121
 
119
122
  /**
120
- * Pull requests only. A push to a protected branch must never skip: deploy
121
- * gates wait for a green required check at the *merged* SHA, and no local
122
- * proof was ever written for that tree.
123
+ * Pull requests, plus pushes to the merge target (#611).
124
+ *
125
+ * The gate was pull-request-only through 1.0.0-alpha.15: a push to a
126
+ * protected branch always ran the full suite, because no local proof is ever
127
+ * written for the merged SHA. That still holds for the *direct* read — a
128
+ * squash merge mints a new commit — which is why the push path exists only
129
+ * together with the merge fallback below: when no generation exists at the
130
+ * merged SHA, the gate may look up the producing pull request and reuse its
131
+ * head proof, but only when the merge result's tree is exactly the proven
132
+ * head's tree. Deploy gates keep
133
+ * waiting for a green required check at the merged SHA; a reused proof turns
134
+ * that check green through the same guarded steps a pull request uses.
135
+ *
136
+ * The push side is deliberately the merge target only — the repository's
137
+ * default branch — not every pushed branch. Which branches trigger a
138
+ * consumer's workflow at all stays repository-owned; this condition only
139
+ * keeps the gate from consulting proof on pushes that are not merges into
140
+ * the default branch.
123
141
  */
124
- export const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
142
+ export const FACTORY_PROOF_GATE_IF =
143
+ "github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))";
125
144
 
126
145
  /**
127
146
  * The complete refusal vocabulary. Deliberately few, because these are the
128
- * only distinctions the gate can honestly make from one Checks API read.
147
+ * only distinctions the gate can honestly make from its Checks API reads.
148
+ * The merge fallback adds no words: a fallback that establishes nothing
149
+ * leaves the direct read's refusal standing, with the specifics in `detail`.
129
150
  *
130
151
  * - `proven` a covering, passing, unambiguous proof for this exact head
131
152
  * - `none` no App-verified factory check run at this commit
@@ -282,8 +303,8 @@ export const resolveProofReuseCommands = (
282
303
  });
283
304
 
284
305
  /**
285
- * jq program: every page of the Checks API result in, three sanitized lines
286
- * (`reason`, `mode`, uncovered commands) out.
306
+ * jq program: every page of the Checks API result in, four sanitized lines
307
+ * (`reason`, `mode`, uncovered commands, source URL) out.
287
308
  *
288
309
  * The input is what `gh api --paginate` actually writes: the pages
289
310
  * *concatenated* as a stream of top-level response objects, not merged into
@@ -481,31 +502,43 @@ detail=''
481
502
  mode=''
482
503
  missing=''
483
504
  source_url=''
484
-
505
+ proof_head="${shellExpansion("HEAD_SHA:-")}"
506
+ merge_note=''
507
+
508
+ # One proof lookup at one commit. Sets lookup_reason / lookup_mode /
509
+ # lookup_missing / lookup_url, or lookup_detail when the read itself
510
+ # failed. Always returns 0: every caller must reach the single
511
+ # GITHUB_OUTPUT write below whatever this function meets.
512
+ #
485
513
  # filter=all with full pagination is load-bearing (ADR 0022). GitHub's
486
514
  # default "latest" filter is ordered by completion, so a newer generation
487
515
  # that is still running can be hidden behind an older completed one — the
488
516
  # gate would then read a stale pass as current.
489
- if [ -z "${shellExpansion("HEAD_SHA:-")}" ]; then
490
- detail='no pull request head SHA'
491
- elif [ -z "${shellExpansion("GITHUB_REPOSITORY:-")}" ]; then
492
- detail='no repository name'
493
- elif ! response=$(gh api --method GET --paginate \
494
- "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs" \
495
- -f "check_name=$CHECK_NAME" \
496
- -f filter=all \
497
- -f per_page=100 2>&1); then
498
- detail="checks API unreadable: $response"
499
- elif ! finding=$(printf '%s' "$response" | jq -r -s \
500
- --arg name "$CHECK_NAME" \
501
- --arg app "$FACTORY_APP_ID" \
502
- --arg sha "$HEAD_SHA" \
503
- --arg repository "$GITHUB_REPOSITORY" \
504
- --argjson required "$REQUIRED_COMMANDS" \
505
- '${GATE_JQ}' 2>&1); then
506
- detail="unreadable proof binding: $finding"
507
- else
508
- # Three separate reads, not one IFS split: tab is IFS whitespace, so a
517
+ gate_lookup() {
518
+ lookup_reason=error
519
+ lookup_mode=''
520
+ lookup_missing=''
521
+ lookup_url=''
522
+ lookup_detail=''
523
+ if ! lookup_response=$(gh api --method GET --paginate \
524
+ "repos/$GITHUB_REPOSITORY/commits/$1/check-runs" \
525
+ -f "check_name=$CHECK_NAME" \
526
+ -f filter=all \
527
+ -f per_page=100 2>&1); then
528
+ lookup_detail="checks API unreadable: $lookup_response"
529
+ return 0
530
+ fi
531
+ if ! lookup_finding=$(printf '%s' "$lookup_response" | jq -r -s \
532
+ --arg name "$CHECK_NAME" \
533
+ --arg app "$FACTORY_APP_ID" \
534
+ --arg sha "$1" \
535
+ --arg repository "$GITHUB_REPOSITORY" \
536
+ --argjson required "$REQUIRED_COMMANDS" \
537
+ '${GATE_JQ}' 2>&1); then
538
+ lookup_detail="unreadable proof binding: $lookup_finding"
539
+ return 0
540
+ fi
541
+ # Four separate reads, not one IFS split: tab is IFS whitespace, so a
509
542
  # split would collapse the empty fields the vocabulary relies on.
510
543
  #
511
544
  # The trailing "|| :" on each read is load-bearing. "missing" is empty for
@@ -516,18 +549,106 @@ else
516
549
  # GITHUB_OUTPUT write below, which is exactly how this gate failed in
517
550
  # hosted CI on every decidable verdict while every test passed (#319).
518
551
  {
519
- IFS= read -r reason || :
520
- IFS= read -r mode || :
521
- IFS= read -r missing || :
522
- IFS= read -r source_url || :
523
- } <<< "$finding"
524
- case "$reason" in
552
+ IFS= read -r lookup_reason || :
553
+ IFS= read -r lookup_mode || :
554
+ IFS= read -r lookup_missing || :
555
+ IFS= read -r lookup_url || :
556
+ } <<< "$lookup_finding"
557
+ case "$lookup_reason" in
525
558
  proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
526
559
  *)
527
- detail="unexpected gate result: $reason"
528
- reason=error
560
+ lookup_detail="unexpected gate result: $lookup_reason"
561
+ lookup_reason=error
529
562
  ;;
530
563
  esac
564
+ return 0
565
+ }
566
+
567
+ if [ -z "${shellExpansion("HEAD_SHA:-")}" ]; then
568
+ detail='no verification head SHA'
569
+ elif [ -z "${shellExpansion("GITHUB_REPOSITORY:-")}" ]; then
570
+ detail='no repository name'
571
+ else
572
+ gate_lookup "$HEAD_SHA"
573
+ reason="$lookup_reason"
574
+ detail="$lookup_detail"
575
+ mode="$lookup_mode"
576
+ missing="$lookup_missing"
577
+ source_url="$lookup_url"
578
+ fi
579
+
580
+ # Merge fallback (#611). A squash merge mints a new commit, so the direct
581
+ # read at a pushed merge-target head finds nothing even when the factory
582
+ # proved the producing pull request head. Only when the direct read found no
583
+ # generation at all on a push event, look up the producing pull request and
584
+ # reuse its head proof — and only when the merge commit's TREE id equals the
585
+ # proven head's tree id, which makes the pushed content byte-identical to
586
+ # what was verified (a clean squash of an unchanged tip). Patch identity was
587
+ # considered and rejected as the comparator: git patch-id normalizes
588
+ # whitespace and ignores base motion, so an identical patch can still
589
+ # produce an integrated tree that was never tested. Everything else — no
590
+ # unique merged producing PR, an unreadable commit, a proof that is anything
591
+ # but proven, or tree drift from a dirty or stale merge — leaves the direct
592
+ # "none" refusal standing, so the full suite runs (fail open).
593
+ if [ "$reason" = 'none' ] && [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
594
+ producing_head=''
595
+ merge_tree=''
596
+ head_tree=''
597
+ if ! producing_pulls=$(gh api --method GET --paginate \
598
+ "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/pulls" \
599
+ -f per_page=100 2>&1); then
600
+ detail="producing pull request unreadable: $producing_pulls"
601
+ elif ! producing_head=$(printf '%s' "$producing_pulls" | jq -r -s \
602
+ --arg sha "$HEAD_SHA" \
603
+ '[ add[]?
604
+ | select((.merged_at // null) != null)
605
+ | select((.merge_commit_sha // "") == $sha)
606
+ | ((.head.sha // "") | tostring) ]
607
+ | if length == 1 then .[0] else "" end' 2>&1); then
608
+ detail="producing pull request unreadable: $producing_head"
609
+ producing_head=''
610
+ elif ! [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]]; then
611
+ detail='no single merged producing pull request at this commit'
612
+ producing_head=''
613
+ elif ! merge_commit=$(gh api --method GET \
614
+ "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA" 2>&1); then
615
+ detail="merge commit unreadable: $merge_commit"
616
+ elif ! merge_tree=$(printf '%s' "$merge_commit" | jq -r \
617
+ '(.commit.tree.sha // "") | tostring' 2>&1); then
618
+ detail="merge commit unreadable: $merge_tree"
619
+ merge_tree=''
620
+ elif ! [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]]; then
621
+ detail='merge commit carries no readable tree id'
622
+ merge_tree=''
623
+ elif ! head_commit=$(gh api --method GET \
624
+ "repos/$GITHUB_REPOSITORY/commits/$producing_head" 2>&1); then
625
+ detail="producing head commit unreadable: $head_commit"
626
+ elif ! head_tree=$(printf '%s' "$head_commit" | jq -r \
627
+ '(.commit.tree.sha // "") | tostring' 2>&1); then
628
+ detail="producing head commit unreadable: $head_tree"
629
+ head_tree=''
630
+ elif ! [[ "$head_tree" =~ ^[0-9a-f]{40}$ ]]; then
631
+ detail='producing head commit carries no readable tree id'
632
+ head_tree=''
633
+ elif [ "$merge_tree" != "$head_tree" ]; then
634
+ detail='tree drift: the merge result is not the proven head tree'
635
+ merge_tree=''
636
+ fi
637
+ if [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]] \
638
+ && [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]] \
639
+ && [ "$merge_tree" = "$head_tree" ]; then
640
+ gate_lookup "$producing_head"
641
+ if [ "$lookup_reason" = 'proven' ]; then
642
+ reason=proven
643
+ mode="$lookup_mode"
644
+ missing=''
645
+ source_url="$lookup_url"
646
+ proof_head="$producing_head"
647
+ merge_note="the merge result's tree is byte-identical to the tree proven at pull request head $producing_head"
648
+ else
649
+ detail="producing pull request proof not reusable ($lookup_reason)"
650
+ fi
651
+ fi
531
652
  fi
532
653
 
533
654
  # The binding is App-verified, but it still reaches markdown. Only a plain
@@ -558,21 +679,28 @@ say ''
558
679
 
559
680
  case "$reason" in
560
681
  proven)
561
- echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
562
- say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
682
+ echo "Factory proof: reusing local verification of $proof_head; skipping the $SURFACE suite."
683
+ say "Skipped. The factory already verified this exact content, so this job did not run the $SURFACE suite a second time."
563
684
  say ''
564
685
  if [ -n "$source_url" ]; then
565
686
  say "- Reused proof: [\`$CHECK_NAME\`]($source_url), published by the pinned factory GitHub App."
566
687
  else
567
688
  say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
568
689
  fi
569
- say "- Covers head: \`$HEAD_SHA\`"
690
+ say "- Covers head: \`$proof_head\`"
691
+ if [ -n "$merge_note" ]; then
692
+ say "- Merge-target reuse: $merge_note."
693
+ fi
570
694
  say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
571
695
  ;;
572
696
  none)
573
697
  say 'Ran the full suite. No trusted factory proof covers this commit.'
574
698
  say ''
575
699
  say "The factory GitHub App has published no \`$CHECK_NAME\` check run at head \`$HEAD_SHA\`. Proof is keyed to the commit, so proof written for any other commit is not visible here. A pull request opened by hand carries no factory proof at all, which is normal."
700
+ if [ -n "$detail" ]; then
701
+ say ''
702
+ say "- Merge fallback: $detail"
703
+ fi
576
704
  say ''
577
705
  say '${CORRECTIVE_LINE}'
578
706
  ;;
@@ -683,9 +811,13 @@ export interface FactoryProofGateStep {
683
811
 
684
812
  /**
685
813
  * The step itself, structurally accepted by gagen's `step()` without adding a
686
- * gagen runtime dependency. It belongs first in the job it guards: one Checks
687
- * API read with the default `GITHUB_TOKEN` (`checks: read`), no checkout, no
688
- * install, so a proven head costs a runner nothing beyond job startup.
814
+ * gagen runtime dependency. It belongs first in the job it guards: a few API
815
+ * reads with the default `GITHUB_TOKEN`, no checkout, no install, so a proven
816
+ * head costs a runner nothing beyond job startup. A pull request needs
817
+ * `checks: read`; the push-event merge fallback additionally reads the
818
+ * producing pull request (`pull-requests: read`) and the two commit objects
819
+ * whose tree ids it compares (`contents: read`). A job that grants less
820
+ * loses only the fallback — the failed read degrades to the full suite.
689
821
  *
690
822
  * A **step, not a job**, and that is not a style preference. A separate gate
691
823
  * job that errored would leave the guarded job `skipped`, and a summary job
@@ -701,7 +833,12 @@ export const factoryProofGateStep = (
701
833
  continueOnError: true as const,
702
834
  env: Object.freeze({
703
835
  GH_TOKEN: githubExpression("secrets.GITHUB_TOKEN"),
704
- HEAD_SHA: githubExpression("github.event.pull_request.head.sha"),
836
+ // On a pull request the verified head is the PR branch tip; on a push
837
+ // there is no pull_request payload and `github.sha` is the pushed
838
+ // merge-target head the fallback resolves from (#611).
839
+ HEAD_SHA: githubExpression(
840
+ "github.event.pull_request.head.sha || github.sha"
841
+ ),
705
842
  }),
706
843
  id: FACTORY_PROOF_GATE_STEP_ID,
707
844
  if: FACTORY_PROOF_GATE_IF,
@@ -21,10 +21,9 @@ const safeLabel = (surface: string): string => {
21
21
  return cleaned.length > 0 ? cleaned : "verification";
22
22
  };
23
23
 
24
- export const FACTORY_PROOF_TIMING_STEP_ID = "ci-timing";
25
- export const FACTORY_PROOF_TIMING_START_STEP_NAME = "Start CI timing";
26
- export const FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME =
27
- "Record proof-reuse timing";
24
+ const FACTORY_PROOF_TIMING_STEP_ID = "ci-timing";
25
+ const FACTORY_PROOF_TIMING_START_STEP_NAME = "Start CI timing";
26
+ const FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME = "Record proof-reuse timing";
28
27
 
29
28
  export interface FactoryProofReusePresentationOptions {
30
29
  /** Human-readable name of the guarded suite. */
@@ -47,7 +46,7 @@ export const factoryProofTimingStartStep = (): FactoryProofTimingStartStep =>
47
46
  printf 'started_ms=%s\n' "$started_ms" >> "$GITHUB_OUTPUT"`,
48
47
  });
49
48
 
50
- export const factoryProofReuseSummaryScript = ({
49
+ const factoryProofReuseSummaryScript = ({
51
50
  surface,
52
51
  }: FactoryProofReusePresentationOptions): string => {
53
52
  const label = safeLabel(surface);
@@ -60,11 +59,15 @@ say() { printf '%s\n' "$1" >> "$SUMMARY"; }
60
59
  say '## ${label} timing'
61
60
  say ''
62
61
 
63
- if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
62
+ if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ] && [ -z "${shellExpansion("PROOF_REASON:-")}" ]; then
63
+ # The gate runs on pushes to the merge target (#611). A push run with no
64
+ # recorded reason is one where the gate step's condition kept it off
65
+ # entirely (a non-merge-target ref), so the full suite ran with no
66
+ # reuse decision.
64
67
  say '- Path: merge-target full execution'
65
- say '- Proof-reuse gate: not applicable on merge-target runs; the full suite ran.'
68
+ say '- Proof-reuse gate: did not run on this ref; the full suite ran.'
66
69
  say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
67
- echo "::notice title=Factory proof reuse::Proof reuse is not applicable on merge-target runs; the full ${label} suite executed."
70
+ echo "::notice title=Factory proof reuse::The proof-reuse gate did not run on this ref; the full ${label} suite executed."
68
71
  elif [ "${shellExpansion("PROOF_REUSED:-")}" = 'true' ]; then
69
72
  say '- Path: trusted local proof reused'
70
73
  if [ -n "${shellExpansion("PROOF_SOURCE_URL:-")}" ]; then
@@ -105,7 +108,11 @@ export const factoryProofReuseSummaryStep = (
105
108
  CI_STARTED_MS: githubExpression(
106
109
  `steps.${FACTORY_PROOF_TIMING_STEP_ID}.outputs.started_ms`
107
110
  ),
108
- PROOF_HEAD_SHA: githubExpression("github.event.pull_request.head.sha"),
111
+ // PR runs bind to the PR branch tip; push runs to the pushed merge-target
112
+ // head, which is what the gate's merge fallback resolved from (#611).
113
+ PROOF_HEAD_SHA: githubExpression(
114
+ "github.event.pull_request.head.sha || github.sha"
115
+ ),
109
116
  PROOF_MODE: githubExpression(
110
117
  `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_MODE_OUTPUT}`
111
118
  ),