@patronage/factory-ci 1.0.0-alpha.8 → 1.0.0-alpha.9

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
@@ -20,7 +20,10 @@ The admission rule is **upstream on repetition**: nothing enters this package un
20
20
 
21
21
  ```ts
22
22
  import {
23
+ FACTORY_CANDIDATE_PULL_REQUEST_TYPES,
24
+ factoryProductionImpactWorkflow,
23
25
  factoryWorkflow,
26
+ factoryCandidateOrPushCondition,
24
27
  NODE_PNPM_ACTION_FAMILY_NODE24,
25
28
  } from "@patronage/factory-ci";
26
29
  ```
@@ -47,6 +50,7 @@ const generated = factoryWorkflow({
47
50
  regenerate: "pnpm workflows:generate",
48
51
  },
49
52
  setup: {
53
+ checkout: { fetchDepth: 0, ref: "${{ github.sha }}" },
50
54
  setupNode: { cacheDependencyPath: "pnpm-lock.yaml" },
51
55
  },
52
56
  });
@@ -59,6 +63,45 @@ workflow({/* caller-owned jobs and topology */}).writeOrLint({
59
63
 
60
64
  The **runner is not this package's business**. Jobs, runners, permissions, workflow topology, and deploy policy remain with the caller. The returned values are plain structural objects; this package does not depend on gagen.
61
65
 
66
+ Candidate lifecycle support follows the same boundary. Use `FACTORY_CANDIDATE_PULL_REQUEST_TYPES` for the pull-request trigger matrix and `factoryCandidateOrPushCondition()` on each substantive job. With an optional caller-owned path condition, the helper emits the GitHub expression that runs merge-target pushes unconditionally and runs pull-request work only when GitHub's draft boolean says the pull request is a Candidate:
67
+
68
+ ```ts
69
+ const changes = job("changes", {
70
+ if: factoryCandidateOrPushCondition(),
71
+ // caller-owned runner, permissions, and steps
72
+ });
73
+
74
+ const core = job("core", {
75
+ if: factoryCandidateOrPushCondition("needs.changes.outputs.core == 'true'"),
76
+ // caller-owned topology
77
+ });
78
+ ```
79
+
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
+
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:
83
+
84
+ ```ts
85
+ const impact = factoryProductionImpactWorkflow({
86
+ targets: profile.impact?.targets.map(({ name }) => name) ?? [],
87
+ });
88
+
89
+ const decide = job("impact", {
90
+ outputs: impact.decisionJobOutputs,
91
+ steps: [...setupSteps, impact.decisionStep],
92
+ });
93
+
94
+ const deployWebsite = job("deploy-website", {
95
+ if: impact.demandedIf("website", "impact"),
96
+ needs: [decide],
97
+ // consumer-owned runner, credentials, deploy commands, and convergence
98
+ });
99
+ ```
100
+
101
+ The step calls `psf production:impact` with the merge push's exact `before` and `after` commits. 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. The artifact with an empty target list generates no deploy jobs because job creation remains with the consumer. This package does not own target declarations, deploy topology, credentials, commands, or convergence/no-op proof.
102
+
103
+ The decision checkout must make both push identities reachable. Use `factoryWorkflow({ setup: { checkout: { fetchDepth: 0, ref: "${{ github.sha }}" } } })`; a shallow checkout is safe but deliberately refuses withdrawal because the `before` commit is unreadable.
104
+
62
105
  ### Generated shell
63
106
 
64
107
  ```ts
@@ -90,6 +133,8 @@ import {
90
133
  assertProofReuseCoverage,
91
134
  FACTORY_PROOF_GATE_GUARD,
92
135
  factoryProofGateStep,
136
+ factoryProofReuseSummaryStep,
137
+ factoryProofTimingStartStep,
93
138
  } from "@patronage/factory-ci";
94
139
 
95
140
  import profile from "../../software-factory.profile.json" with { type: "json" };
@@ -102,14 +147,16 @@ const core = job("core", {
102
147
  permissions: { checks: "read", contents: "read" },
103
148
  steps: [
104
149
  step(factoryProofGateStep({ commands: coreCommands, surface: "core" })),
150
+ step(factoryProofTimingStartStep()),
105
151
  ...guardedSteps.map((s) => ({ ...s, if: FACTORY_PROOF_GATE_GUARD })),
152
+ step(factoryProofReuseSummaryStep({ surface: "core" })),
106
153
  ],
107
154
  });
108
155
  ```
109
156
 
110
157
  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.
111
158
 
112
- 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. `mode` is reported as diagnostic metadata, never authorized on: a reduced-mode proof that ran everything a surface requires is reusable.
159
+ 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.
113
160
 
114
161
  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.
115
162
 
@@ -121,6 +168,8 @@ The gate is a **step, not a job**, marked `continue-on-error`, with no `set -e`.
121
168
 
122
169
  `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.
123
170
 
171
+ `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.
172
+
124
173
  ### Alchemy entries
125
174
 
126
175
  ```ts
@@ -205,7 +254,7 @@ The credential env-block helper that the audit found repeated was deliberately c
205
254
 
206
255
  ## Releases
207
256
 
208
- Attended and hand-cut, on the same terms as `@patronage/alchemy-d1-state`: bump the version, run the workspace checks, read the `npm pack --dry-run` file list, publish, tag `factory-ci@<version>`. There is no changesets setup and no automatic release trigger, by design. Consumers pin exact versions.
257
+ Attended and hand-cut, on the same terms as `@patronage/alchemy-d1-state`: bump the version, run the workspace checks, read the `npm pack --dry-run` file list, build and approve one pnpm tarball, publish that exact tarball with an explicit channel tag, then tag `factory-ci@<version>`. Publishing the package directory is not allowed because its `prepack` build would replace the reviewed bytes. There is no changesets setup and no automatic release trigger, by design. Consumers pin exact versions.
209
258
 
210
259
  ## License
211
260
 
package/dist/index.d.ts CHANGED
@@ -54,6 +54,21 @@ declare const NODE_PNPM_ACTION_FAMILY_NODE24: {
54
54
  };
55
55
  };
56
56
  //#endregion
57
+ //#region src/candidate-lifecycle.d.ts
58
+ /**
59
+ * Pull-request events that can create or refresh a Factory candidate run.
60
+ *
61
+ * GitHub's draft boolean remains the lifecycle authority: these triggers make
62
+ * a run visible, while `factoryCandidateOrPushCondition` keeps substantive
63
+ * jobs idle until the pull request is a candidate.
64
+ */
65
+ declare const FACTORY_CANDIDATE_PULL_REQUEST_TYPES: readonly ["opened", "synchronize", "reopened", "ready_for_review"];
66
+ /**
67
+ * Build a GitHub Actions job condition for candidate PRs and merge-target
68
+ * pushes. The caller owns triggers, jobs, runners, permissions, and topology.
69
+ */
70
+ declare const factoryCandidateOrPushCondition: (candidateCondition?: string) => string;
71
+ //#endregion
57
72
  //#region src/bundle-alchemy-entry.d.ts
58
73
  interface BundleAlchemyEntryOptions {
59
74
  /** The Alchemy entry to bundle, e.g. `alchemy.run.ts`. */
@@ -148,12 +163,16 @@ declare const localPreviewStage: (options: LocalPreviewStageOptions) => LocalPre
148
163
  //#endregion
149
164
  //#region src/factory-workflow.d.ts
150
165
  interface WorkflowStep {
166
+ readonly continueOnError?: boolean;
167
+ readonly env?: Readonly<Record<string, string>>;
168
+ readonly id?: string;
151
169
  readonly name: string;
152
170
  readonly uses?: string;
153
171
  readonly with?: Readonly<Record<string, string>>;
154
172
  readonly run?: string;
155
173
  }
156
174
  interface CheckoutStepOptions {
175
+ readonly fetchDepth?: number;
157
176
  readonly name?: string;
158
177
  readonly ref?: string;
159
178
  }
@@ -368,6 +387,8 @@ declare const FACTORY_PROOF_GATE_REASON_OUTPUT = "reason";
368
387
  * proof that executed every command the surface requires is reusable.
369
388
  */
370
389
  declare const FACTORY_PROOF_GATE_MODE_OUTPUT = "mode";
390
+ /** Exact Checks API URL of the proof generation selected by the gate. */
391
+ declare const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
371
392
  /**
372
393
  * Guard for every step the gate protects. Deliberately `!= 'true'` and not
373
394
  * `== 'false'`: an unset, empty, or garbled output must run the suite.
@@ -388,7 +409,8 @@ declare const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
388
409
  * - `pending` the newest generation had not completed when this job read it
389
410
  * - `failed` the newest generation records no pass
390
411
  * - `unreadable` it passed but carries no binding for this repository and head
391
- * - `incomplete` it passed but did not execute every required command
412
+ * - `incomplete` its executed plus stamp-authorized released commands do not
413
+ * cover every required command
392
414
  * - `ambiguous` two newest generations share the greatest start time
393
415
  * - `error` the gate could not reach a decision (fail open)
394
416
  *
@@ -547,6 +569,64 @@ declare const proofReuseCoverage: ({
547
569
  /** `proofReuseCoverage`, as a build failure. */
548
570
  declare const assertProofReuseCoverage: (input: ProofReuseCoverageInput) => ProofReuseCoverageReport;
549
571
  //#endregion
572
+ //#region src/proof-reuse-presentation.d.ts
573
+ /** Generic timing and presentation steps around the proof-reuse gate (#652). */
574
+ declare const FACTORY_PROOF_TIMING_STEP_ID = "ci-timing";
575
+ declare const FACTORY_PROOF_TIMING_START_STEP_NAME = "Start CI timing";
576
+ declare const FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME = "Record proof-reuse timing";
577
+ interface FactoryProofReusePresentationOptions {
578
+ /** Human-readable name of the guarded suite. */
579
+ readonly surface: string;
580
+ }
581
+ interface FactoryProofTimingStartStep {
582
+ readonly continueOnError: true;
583
+ readonly id: typeof FACTORY_PROOF_TIMING_STEP_ID;
584
+ readonly name: typeof FACTORY_PROOF_TIMING_START_STEP_NAME;
585
+ readonly run: string;
586
+ }
587
+ declare const factoryProofTimingStartStep: () => FactoryProofTimingStartStep;
588
+ declare const factoryProofReuseSummaryScript: ({
589
+ surface
590
+ }: FactoryProofReusePresentationOptions) => string;
591
+ interface FactoryProofReuseSummaryStep {
592
+ readonly continueOnError: true;
593
+ readonly env: Readonly<Record<string, string>>;
594
+ readonly if: "always()";
595
+ readonly name: typeof FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME;
596
+ readonly run: string;
597
+ }
598
+ declare const factoryProofReuseSummaryStep: (options: FactoryProofReusePresentationOptions) => FactoryProofReuseSummaryStep;
599
+ //#endregion
600
+ //#region src/production-impact-workflow.d.ts
601
+ declare const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
602
+ declare const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
603
+ declare const FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT = "basis";
604
+ declare const FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT = "unsubscribed_paths";
605
+ /** Stable GitHub-output key for one declared target. */
606
+ declare const productionImpactTargetOutput: (targetName: string) => string;
607
+ interface FactoryProductionImpactWorkflowOptions {
608
+ readonly after?: string;
609
+ readonly before?: string;
610
+ readonly cli?: string;
611
+ readonly profilePath?: string;
612
+ readonly targets: readonly string[];
613
+ }
614
+ interface FactoryProductionImpactWorkflow {
615
+ /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
616
+ readonly decisionJobOutputs: Readonly<Record<string, string>>;
617
+ readonly decisionStep: WorkflowStep;
618
+ /** A fail-open condition: only an explicit usable withdrawal skips work. */
619
+ readonly demandedIf: (targetName: string, decisionJob?: string) => string;
620
+ readonly targetOutputs: Readonly<Record<string, string>>;
621
+ }
622
+ /**
623
+ * Generate the small factory-owned decision seam for a consumer production
624
+ * workflow. Consumers retain jobs, deploy commands, credentials, topology,
625
+ * and convergence checks; this artifact supplies only the decision step and
626
+ * per-target withdrawal conditions.
627
+ */
628
+ declare const factoryProductionImpactWorkflow: (options: FactoryProductionImpactWorkflowOptions) => FactoryProductionImpactWorkflow;
629
+ //#endregion
550
630
  //#region src/vitest-profile.d.ts
551
631
  /** Schema version of the emitted profile document. */
552
632
  declare const VITEST_PROFILE_SCHEMA_VERSION = 1;
@@ -828,4 +908,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
828
908
  readonly source: string;
829
909
  }) => void;
830
910
  //#endregion
831
- export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, 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_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, 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, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
911
+ 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, type FactoryProductionImpactWorkflow, type FactoryProductionImpactWorkflowOptions, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, type FactoryProofReusePresentationOptions, type FactoryProofReuseSummaryStep, type FactoryProofTimingStartStep, 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, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
package/dist/index.js CHANGED
@@ -32,6 +32,32 @@ const NODE_PNPM_ACTION_FAMILY_NODE24 = {
32
32
  }
33
33
  };
34
34
  //#endregion
35
+ //#region src/candidate-lifecycle.ts
36
+ /**
37
+ * Pull-request events that can create or refresh a Factory candidate run.
38
+ *
39
+ * GitHub's draft boolean remains the lifecycle authority: these triggers make
40
+ * a run visible, while `factoryCandidateOrPushCondition` keeps substantive
41
+ * jobs idle until the pull request is a candidate.
42
+ */
43
+ const FACTORY_CANDIDATE_PULL_REQUEST_TYPES = Object.freeze([
44
+ "opened",
45
+ "synchronize",
46
+ "reopened",
47
+ "ready_for_review"
48
+ ]);
49
+ /**
50
+ * Build a GitHub Actions job condition for candidate PRs and merge-target
51
+ * pushes. The caller owns triggers, jobs, runners, permissions, and topology.
52
+ */
53
+ const factoryCandidateOrPushCondition = (candidateCondition) => {
54
+ return ["github.event_name == 'push'", `(${[
55
+ "github.event_name == 'pull_request'",
56
+ "github.event.pull_request.draft != true",
57
+ ...candidateCondition ? [candidateCondition] : []
58
+ ].join(" && ")})`].join(" || ");
59
+ };
60
+ //#endregion
35
61
  //#region src/bundle-alchemy-entry.ts
36
62
  /**
37
63
  * Alchemy and Effect must resolve from the *consumer's* node_modules at run
@@ -208,15 +234,26 @@ const assertAdditionalActions = (actions) => {
208
234
  assertPinnedAction(name, action);
209
235
  }
210
236
  };
237
+ const checkoutInputs = (checkout) => {
238
+ if (checkout === void 0) return;
239
+ const inputs = {};
240
+ if (checkout.fetchDepth !== void 0) {
241
+ if (!Number.isSafeInteger(checkout.fetchDepth) || checkout.fetchDepth < 0) throw new Error("checkout.fetchDepth must be a non-negative integer.");
242
+ inputs["fetch-depth"] = String(checkout.fetchDepth);
243
+ }
244
+ if (checkout.ref) inputs.ref = checkout.ref;
245
+ return Object.keys(inputs).length > 0 ? inputs : void 0;
246
+ };
211
247
  const setupSteps = (family, setup = {}) => {
212
248
  const installRun = setup.install?.run ?? "pnpm install --frozen-lockfile";
213
249
  if (/(?:^|\s)--ignore-scripts(?:\s|$)/u.test(installRun)) throw new Error("The install step must not use --ignore-scripts; workspace prepare scripts build required package artifacts.");
214
250
  const { checkout, setupNode } = setup;
251
+ const checkoutWith = checkoutInputs(checkout);
215
252
  return Object.freeze([
216
253
  {
217
254
  name: checkout?.name ?? "Checkout",
218
255
  uses: family.checkout.uses,
219
- ...checkout?.ref ? { with: { ref: checkout.ref } } : {}
256
+ ...checkoutWith ? { with: checkoutWith } : {}
220
257
  },
221
258
  usesStep("Setup pnpm", family.setupPnpm),
222
259
  {
@@ -403,9 +440,9 @@ const executeAlchemyEntry = async (options) => {
403
440
  * Escaped so the emitted text carries a GitHub Actions expression rather than
404
441
  * this file carrying a JavaScript template hole.
405
442
  */
406
- const githubExpression = (expression) => `\${{ ${expression} }}`;
443
+ const githubExpression$1 = (expression) => `\${{ ${expression} }}`;
407
444
  /** A braced shell expansion that has to survive TypeScript interpolation. */
408
- const shellExpansion = (expression) => `\${${expression}}`;
445
+ const shellExpansion$1 = (expression) => `\${${expression}}`;
409
446
  /**
410
447
  * Single-quote a value for the emitted script. Every non-literal value that
411
448
  * reaches the script goes through this.
@@ -478,6 +515,8 @@ const FACTORY_PROOF_GATE_REASON_OUTPUT = "reason";
478
515
  * proof that executed every command the surface requires is reusable.
479
516
  */
480
517
  const FACTORY_PROOF_GATE_MODE_OUTPUT = "mode";
518
+ /** Exact Checks API URL of the proof generation selected by the gate. */
519
+ const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
481
520
  /**
482
521
  * Guard for every step the gate protects. Deliberately `!= 'true'` and not
483
522
  * `== 'false'`: an unset, empty, or garbled output must run the suite.
@@ -498,7 +537,8 @@ const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
498
537
  * - `pending` the newest generation had not completed when this job read it
499
538
  * - `failed` the newest generation records no pass
500
539
  * - `unreadable` it passed but carries no binding for this repository and head
501
- * - `incomplete` it passed but did not execute every required command
540
+ * - `incomplete` its executed plus stamp-authorized released commands do not
541
+ * cover every required command
502
542
  * - `ambiguous` two newest generations share the greatest start time
503
543
  * - `error` the gate could not reach a decision (fail open)
504
544
  *
@@ -532,6 +572,13 @@ const FACTORY_PROOF_GATE_REASONS = [
532
572
  */
533
573
  const COMMAND_IDENTITY = /^\w[\w.:@/-]*$/u;
534
574
  const COMMAND_IDENTITY_MAX_LENGTH = 120;
575
+ /**
576
+ * The impact-stamp interpretation the paired factory packages currently
577
+ * share. A release recorded under any other version is an unmodelled input and
578
+ * cannot subtract hosted work. This is intentionally fail-closed and moves in
579
+ * lockstep with the proof/check-payload producer.
580
+ */
581
+ const TRUSTED_IMPACT_STAMP_VERSION = 3;
535
582
  const isProofReuseCommand = (value) => {
536
583
  if (!(value && typeof value === "object")) return false;
537
584
  const entry = value;
@@ -599,7 +646,7 @@ const resolveProofReuseCommands = (catalog, names, selectionLabel) => names.map(
599
646
  });
600
647
  /**
601
648
  * jq program: every page of the Checks API result in, three sanitized lines
602
- * (`reason`, `mode`, missing commands) out.
649
+ * (`reason`, `mode`, uncovered commands) out.
603
650
  *
604
651
  * The input is what `gh api --paginate` actually writes: the pages
605
652
  * *concatenated* as a stream of top-level response objects, not merged into
@@ -656,6 +703,20 @@ def startedAt: (.started_at // "") | tostring;
656
703
  def rankable:
657
704
  test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$");
658
705
 
706
+ def nonemptyString:
707
+ type == "string" and length > 0;
708
+
709
+ def distinctNames:
710
+ (map(.name) | length) == (map(.name) | unique | length);
711
+
712
+ def releaseAuthorized($release; $commands; $stamp):
713
+ ([ $commands[] | select(.name == $release.name) ]) as $commandRows
714
+ | ([ $stamp.targets[] | select(.name == $release.impactTarget) ]) as $targetRows
715
+ | ($commandRows | length) == 1
716
+ and $commandRows[0].impactTarget == $release.impactTarget
717
+ and ($targetRows | length) == 1
718
+ and $targetRows[0].impact == "not-affected";
719
+
659
720
  [ .[]
660
721
  | (.check_runs // [])[]
661
722
  | select(.name == $name)
@@ -663,20 +724,60 @@ def rankable:
663
724
  | { started: startedAt,
664
725
  status: (.status // ""),
665
726
  conclusion: (.conclusion // ""),
727
+ url: ((.html_url // "") | tostring),
666
728
  binding: binding }
667
729
  ] as $runs
668
730
  | ($runs | map(.started | rankable) | all) as $orderable
669
731
  | ($runs | map(.started | sub("\\.[0-9]+Z$"; "Z")) | max) as $newest
670
732
  | [ $runs[] | select((.started | sub("\\.[0-9]+Z$"; "Z")) == $newest) ] as $generation
671
- | (if ($runs | length) == 0 then ["none", "", ""]
672
- elif ($orderable | not) then ["ambiguous", "", ""]
673
- elif ($generation | length) != 1 then ["ambiguous", "", ""]
733
+ | (if ($runs | length) == 0 then ["none", "", "", ""]
734
+ elif ($orderable | not) then ["ambiguous", "", "", ""]
735
+ elif ($generation | length) != 1 then ["ambiguous", "", "", ""]
674
736
  else
675
737
  $generation[0] as $run
676
738
  | (if ($run.binding | type) == "object" then $run.binding else {} end) as $proof
677
739
  | (($proof.mode // "") | tostring) as $mode
678
740
  | (($proof.executedCommands // []) | map(select(type == "string"))) as $executed
679
- | ($required - $executed) as $missing
741
+ | ($proof | has("notRequiredCommands")) as $hasReleases
742
+ | (if $hasReleases then $proof.notRequiredCommands else [] end) as $releases
743
+ | ($proof.verificationCommands // null) as $commands
744
+ | ($proof.impactStamp // null) as $stamp
745
+ | (if ($hasReleases | not) then true
746
+ else
747
+ ($proof.proofSchemaVersion == 4)
748
+ and ($releases | type) == "array"
749
+ and ($releases | length) > 0
750
+ and ($releases | all(.[];
751
+ type == "object"
752
+ and (.name | nonemptyString)
753
+ and (.impactTarget | nonemptyString)
754
+ and (.basis | nonemptyString)))
755
+ and ($releases | distinctNames)
756
+ and ($commands | type) == "array"
757
+ and ($commands | all(.[];
758
+ type == "object"
759
+ and (.name | nonemptyString)
760
+ and ((has("impactTarget") | not) or (.impactTarget | nonemptyString))))
761
+ and ($commands | distinctNames)
762
+ and ($stamp | type) == "object"
763
+ and ($stamp.stampVersion == ${TRUSTED_IMPACT_STAMP_VERSION})
764
+ and ($stamp.basis == "target-scoped")
765
+ and (($stamp.reasons | type) == "array")
766
+ and ($stamp.reasons | all(.[]; type == "string"))
767
+ and (($stamp.unsubscribedPaths | type) == "array")
768
+ and ($stamp.unsubscribedPaths | all(.[]; type == "string"))
769
+ and (($stamp.targets | type) == "array")
770
+ and ($stamp.targets | all(.[];
771
+ type == "object"
772
+ and (.name | nonemptyString)
773
+ and (.basis | nonemptyString)
774
+ and ((.impact == "affected") or (.impact == "not-affected"))))
775
+ and ($stamp.targets | distinctNames)
776
+ and (([$releases[].name] - $executed | length) == ($releases | length))
777
+ and ($releases | all(.[]; releaseAuthorized(.; $commands; $stamp)))
778
+ end) as $releasesValid
779
+ | (if $releasesValid then [$releases[].name] else [] end) as $released
780
+ | ($required - $executed - $released) as $missing
680
781
  | (if $run.status != "completed" then "pending"
681
782
  elif $run.conclusion != "success" then "failed"
682
783
  elif (($run.binding | type) != "object")
@@ -686,10 +787,11 @@ def rankable:
686
787
  or ($proof.repository != $repository) then "unreadable"
687
788
  elif $proof.outcome != "passed" then "failed"
688
789
  elif ($required | length) == 0 then "incomplete"
790
+ elif ($releasesValid | not) then "incomplete"
689
791
  elif ($missing | length) != 0 then "incomplete"
690
792
  else "proven"
691
793
  end) as $reason
692
- | [$reason, $mode, ($missing | join(", "))]
794
+ | [$reason, $mode, ($missing | join(", ")), $run.url]
693
795
  end)
694
796
  | map(gsub("[\\r\\n\\t]"; " "))
695
797
  | join("\n")
@@ -714,9 +816,9 @@ const CORRECTIVE_LINE = "Run `psf pr:verify` before publishing to reuse local pr
714
816
  const UNUSABLE_SELECTION_SCRIPT = String.raw`{
715
817
  printf '${FACTORY_PROOF_GATE_OUTPUT}=false\n'
716
818
  printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=error\n'
717
- } >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"`;
819
+ } >> "${shellExpansion$1("GITHUB_OUTPUT:-/dev/null")}"`;
718
820
  /** Surface labels reach markdown, so only a plain, bounded label survives. */
719
- const safeLabel = (surface) => {
821
+ const safeLabel$1 = (surface) => {
720
822
  const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
721
823
  return cleaned.length > 0 ? cleaned : "verification";
722
824
  };
@@ -732,14 +834,15 @@ reason=error
732
834
  detail=''
733
835
  mode=''
734
836
  missing=''
837
+ source_url=''
735
838
 
736
839
  # filter=all with full pagination is load-bearing (ADR 0022). GitHub's
737
840
  # default "latest" filter is ordered by completion, so a newer generation
738
841
  # that is still running can be hidden behind an older completed one — the
739
842
  # gate would then read a stale pass as current.
740
- if [ -z "${shellExpansion("HEAD_SHA:-")}" ]; then
843
+ if [ -z "${shellExpansion$1("HEAD_SHA:-")}" ]; then
741
844
  detail='no pull request head SHA'
742
- elif [ -z "${shellExpansion("GITHUB_REPOSITORY:-")}" ]; then
845
+ elif [ -z "${shellExpansion$1("GITHUB_REPOSITORY:-")}" ]; then
743
846
  detail='no repository name'
744
847
  elif ! response=$(gh api --method GET --paginate \
745
848
  "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs" \
@@ -770,6 +873,7 @@ else
770
873
  IFS= read -r reason || :
771
874
  IFS= read -r mode || :
772
875
  IFS= read -r missing || :
876
+ IFS= read -r source_url || :
773
877
  } <<< "$finding"
774
878
  case "$reason" in
775
879
  proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
@@ -796,8 +900,11 @@ fi
796
900
  # line so it cannot restructure the summary it is written into.
797
901
  detail=$(printf '%s' "$detail" | tr '\n\r\t' ' ' | cut -c1-240)
798
902
  missing=$(printf '%s' "$missing" | tr '\n\r\t' ' ' | cut -c1-240)
903
+ if ! [[ "$source_url" =~ ^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/runs/[0-9]+$ ]]; then
904
+ source_url=''
905
+ fi
799
906
 
800
- SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
907
+ SUMMARY="${shellExpansion$1("GITHUB_STEP_SUMMARY:-/dev/null")}"
801
908
  say() { printf '%s\n' "$1" >> "$SUMMARY"; }
802
909
 
803
910
  say "## Proof reuse: $SURFACE"
@@ -808,7 +915,11 @@ case "$reason" in
808
915
  echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
809
916
  say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
810
917
  say ''
811
- say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
918
+ if [ -n "$source_url" ]; then
919
+ say "- Reused proof: [\`$CHECK_NAME\`]($source_url), published by the pinned factory GitHub App."
920
+ else
921
+ say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
922
+ fi
812
923
  say "- Covers head: \`$HEAD_SHA\`"
813
924
  say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
814
925
  ;;
@@ -843,7 +954,7 @@ case "$reason" in
843
954
  incomplete)
844
955
  say "Ran the full suite. The factory proof for this commit does not cover every command the $SURFACE surface requires."
845
956
  say ''
846
- say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its \`executedCommands\` is missing: \`$missing\`"
957
+ say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its executed commands plus stamp-authorized \`notRequiredCommands\` do not cover this surface. Missing: \`$missing\`"
847
958
  say ''
848
959
  say '${CORRECTIVE_LINE}'
849
960
  ;;
@@ -865,7 +976,7 @@ esac
865
976
  say ''
866
977
 
867
978
  if [ "$verdict" != 'true' ]; then
868
- echo "No reusable factory proof for ${shellExpansion("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
979
+ echo "No reusable factory proof for ${shellExpansion$1("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
869
980
  fi
870
981
 
871
982
  # The only write. A crash before this line leaves the output unset, the guard
@@ -874,7 +985,8 @@ fi
874
985
  printf '${FACTORY_PROOF_GATE_OUTPUT}=%s\n' "$verdict"
875
986
  printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=%s\n' "$reason"
876
987
  printf '${FACTORY_PROOF_GATE_MODE_OUTPUT}=%s\n' "$mode"
877
- } >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"
988
+ printf '${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}=%s\n' "$source_url"
989
+ } >> "${shellExpansion$1("GITHUB_OUTPUT:-/dev/null")}"
878
990
  `.trim();
879
991
  /**
880
992
  * The gate script. Exported so it can be executed directly under test against
@@ -890,7 +1002,7 @@ fi
890
1002
  */
891
1003
  const factoryProofGateScript = ({ commands, surface }) => {
892
1004
  const required = proofReuseRequiredCommands(commands);
893
- return required ? gateScript(required, safeLabel(surface)) : UNUSABLE_SELECTION_SCRIPT;
1005
+ return required ? gateScript(required, safeLabel$1(surface)) : UNUSABLE_SELECTION_SCRIPT;
894
1006
  };
895
1007
  /**
896
1008
  * The step itself, structurally accepted by gagen's `step()` without adding a
@@ -908,8 +1020,8 @@ const factoryProofGateScript = ({ commands, surface }) => {
908
1020
  const factoryProofGateStep = (options) => Object.freeze({
909
1021
  continueOnError: true,
910
1022
  env: Object.freeze({
911
- GH_TOKEN: githubExpression("secrets.GITHUB_TOKEN"),
912
- HEAD_SHA: githubExpression("github.event.pull_request.head.sha")
1023
+ GH_TOKEN: githubExpression$1("secrets.GITHUB_TOKEN"),
1024
+ HEAD_SHA: githubExpression$1("github.event.pull_request.head.sha")
913
1025
  }),
914
1026
  id: FACTORY_PROOF_GATE_STEP_ID,
915
1027
  if: FACTORY_PROOF_GATE_IF,
@@ -941,11 +1053,137 @@ const proofReuseCoverage = ({ commands, skipped }) => {
941
1053
  const assertProofReuseCoverage = (input) => {
942
1054
  const report = proofReuseCoverage(input);
943
1055
  if (report.covered) return report;
944
- const surface = safeLabel(input.surface ?? "");
1056
+ const surface = safeLabel$1(input.surface ?? "");
945
1057
  const problem = report.requiredCommands.length === 0 ? "selects no usable profile commands, so any passing proof would trivially cover it" : `skips work no selected profile command runs: ${report.uncovered.join(", ")}`;
946
1058
  throw new Error(`Proof-reuse coverage failed: the ${surface} surface ${problem}. Add the command to software-factory.profile.json (and to this surface's selection), or stop skipping it.`);
947
1059
  };
948
1060
  //#endregion
1061
+ //#region src/proof-reuse-presentation.ts
1062
+ /** Generic timing and presentation steps around the proof-reuse gate (#652). */
1063
+ const githubExpression = (expression) => `\${{ ${expression} }}`;
1064
+ const shellExpansion = (expression) => `\${${expression}}`;
1065
+ const safeLabel = (surface) => {
1066
+ const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
1067
+ return cleaned.length > 0 ? cleaned : "verification";
1068
+ };
1069
+ const FACTORY_PROOF_TIMING_STEP_ID = "ci-timing";
1070
+ const FACTORY_PROOF_TIMING_START_STEP_NAME = "Start CI timing";
1071
+ const FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME = "Record proof-reuse timing";
1072
+ const factoryProofTimingStartStep = () => Object.freeze({
1073
+ continueOnError: true,
1074
+ id: FACTORY_PROOF_TIMING_STEP_ID,
1075
+ name: FACTORY_PROOF_TIMING_START_STEP_NAME,
1076
+ run: String.raw`started_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
1077
+ printf 'started_ms=%s\n' "$started_ms" >> "$GITHUB_OUTPUT"`
1078
+ });
1079
+ const factoryProofReuseSummaryScript = ({ surface }) => {
1080
+ const label = safeLabel(surface);
1081
+ return String.raw`now_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
1082
+ ci_started_ms="${shellExpansion("CI_STARTED_MS:-")}"
1083
+
1084
+ SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
1085
+ say() { printf '%s\n' "$1" >> "$SUMMARY"; }
1086
+
1087
+ say '## ${label} timing'
1088
+ say ''
1089
+
1090
+ if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
1091
+ say '- Path: merge-target full execution'
1092
+ say '- Proof-reuse gate: not applicable on merge-target runs; the full suite ran.'
1093
+ say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
1094
+ echo "::notice title=Factory proof reuse::Proof reuse is not applicable on merge-target runs; the full ${label} suite executed."
1095
+ elif [ "${shellExpansion("PROOF_REUSED:-")}" = 'true' ]; then
1096
+ say '- Path: trusted local proof reused'
1097
+ if [ -n "${shellExpansion("PROOF_SOURCE_URL:-")}" ]; then
1098
+ say "- Reused proof: [exact source check](${shellExpansion("PROOF_SOURCE_URL")})"
1099
+ fi
1100
+ say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
1101
+ say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
1102
+ echo "::notice title=Factory proof reuse::Reused trusted local proof for ${label} at ${shellExpansion("PROOF_HEAD_SHA:-unknown")}."
1103
+ else
1104
+ say "- Path: full GitHub CI fallback (${shellExpansion("PROOF_REASON:-error")})"
1105
+ say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
1106
+ say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
1107
+ echo "::notice title=Factory proof reuse::No reusable proof for ${label}; the full hosted suite executed (${shellExpansion("PROOF_REASON:-error")})."
1108
+ fi
1109
+
1110
+ if [[ "$ci_started_ms" =~ ^[0-9]+$ ]]; then
1111
+ say "- GitHub workflow execution after the proof gate: $((now_ms - ci_started_ms)) ms"
1112
+ else
1113
+ say '- GitHub workflow execution after the proof gate: unavailable'
1114
+ fi
1115
+ say ''`;
1116
+ };
1117
+ const factoryProofReuseSummaryStep = (options) => Object.freeze({
1118
+ continueOnError: true,
1119
+ env: Object.freeze({
1120
+ CI_STARTED_MS: githubExpression(`steps.${FACTORY_PROOF_TIMING_STEP_ID}.outputs.started_ms`),
1121
+ PROOF_HEAD_SHA: githubExpression("github.event.pull_request.head.sha"),
1122
+ PROOF_MODE: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_MODE_OUTPUT}`),
1123
+ PROOF_REASON: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_REASON_OUTPUT}`),
1124
+ PROOF_REUSED: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT}`),
1125
+ PROOF_SOURCE_URL: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}`)
1126
+ }),
1127
+ if: "always()",
1128
+ name: FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME,
1129
+ run: factoryProofReuseSummaryScript(options)
1130
+ });
1131
+ //#endregion
1132
+ //#region src/production-impact-workflow.ts
1133
+ const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
1134
+ const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
1135
+ const FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT = "basis";
1136
+ const FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT = "unsubscribed_paths";
1137
+ const OUTPUT_NAME_PATTERN = /[^a-z0-9_]+/gu;
1138
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
1139
+ /** Stable GitHub-output key for one declared target. */
1140
+ const productionImpactTargetOutput = (targetName) => {
1141
+ const normalized = targetName.toLowerCase().replaceAll(OUTPUT_NAME_PATTERN, "_").replaceAll(/^_+|_+$/gu, "");
1142
+ if (normalized.length === 0) throw new Error(`Impact target name "${targetName}" has no output-safe characters.`);
1143
+ return `target_${normalized}`;
1144
+ };
1145
+ /**
1146
+ * Generate the small factory-owned decision seam for a consumer production
1147
+ * workflow. Consumers retain jobs, deploy commands, credentials, topology,
1148
+ * and convergence checks; this artifact supplies only the decision step and
1149
+ * per-target withdrawal conditions.
1150
+ */
1151
+ const factoryProductionImpactWorkflow = (options) => {
1152
+ const targetOutputs = Object.fromEntries(options.targets.map((target) => [target, productionImpactTargetOutput(target)]));
1153
+ if (new Set(Object.values(targetOutputs)).size !== options.targets.length) throw new Error("Impact target names must map to distinct GitHub output keys.");
1154
+ const before = options.before ?? `\${{ github.event.before }}`;
1155
+ const after = options.after ?? `\${{ github.sha }}`;
1156
+ const cli = options.cli ?? "pnpm exec psf";
1157
+ const profile = options.profilePath ? ` --profile ${shellQuote(options.profilePath)}` : "";
1158
+ const decisionStep = {
1159
+ continueOnError: true,
1160
+ env: {
1161
+ FACTORY_AFTER_SHA: after,
1162
+ FACTORY_BEFORE_SHA: before
1163
+ },
1164
+ id: FACTORY_PRODUCTION_IMPACT_STEP_ID,
1165
+ name: "Classify production impact",
1166
+ run: `${cli} production:impact --before "$FACTORY_BEFORE_SHA" --after "$FACTORY_AFTER_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`
1167
+ };
1168
+ return Object.freeze({
1169
+ decisionJobOutputs: Object.freeze({
1170
+ basis: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT} }}`,
1171
+ decision: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} }}`,
1172
+ unsubscribed_paths: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT} }}`,
1173
+ ...Object.fromEntries(Object.values(targetOutputs).map((output) => [output, `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${output} }}`]))
1174
+ }),
1175
+ decisionStep: Object.freeze(decisionStep),
1176
+ demandedIf: (targetName, decisionJob) => {
1177
+ const output = targetOutputs[targetName];
1178
+ if (output === void 0) throw new Error(`Unknown production impact target "${targetName}".`);
1179
+ const source = decisionJob ? `needs.${decisionJob}` : `steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}`;
1180
+ const condition = `${source}.${decisionJob ? "result" : "outcome"} != 'success' || ${source}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
1181
+ return decisionJob ? `always() && (${condition})` : condition;
1182
+ },
1183
+ targetOutputs: Object.freeze(targetOutputs)
1184
+ });
1185
+ };
1186
+ //#endregion
949
1187
  //#region src/vitest-profile.ts
950
1188
  /**
951
1189
  * Vitest suite profiling — the measurement mechanics behind a CI runner
@@ -1646,4 +1884,4 @@ const assertWorkflowShellParses = (yaml, options) => {
1646
1884
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
1647
1885
  };
1648
1886
  //#endregion
1649
- export { 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_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
1887
+ 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, 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, 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.8",
3
+ "version": "1.0.0-alpha.9",
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,29 @@
1
+ /**
2
+ * Pull-request events that can create or refresh a Factory candidate run.
3
+ *
4
+ * GitHub's draft boolean remains the lifecycle authority: these triggers make
5
+ * a run visible, while `factoryCandidateOrPushCondition` keeps substantive
6
+ * jobs idle until the pull request is a candidate.
7
+ */
8
+ export const FACTORY_CANDIDATE_PULL_REQUEST_TYPES = Object.freeze([
9
+ "opened",
10
+ "synchronize",
11
+ "reopened",
12
+ "ready_for_review",
13
+ ] as const);
14
+
15
+ /**
16
+ * Build a GitHub Actions job condition for candidate PRs and merge-target
17
+ * pushes. The caller owns triggers, jobs, runners, permissions, and topology.
18
+ */
19
+ export const factoryCandidateOrPushCondition = (
20
+ candidateCondition?: string
21
+ ): string => {
22
+ const candidate = [
23
+ "github.event_name == 'pull_request'",
24
+ "github.event.pull_request.draft != true",
25
+ ...(candidateCondition ? [candidateCondition] : []),
26
+ ].join(" && ");
27
+
28
+ return ["github.event_name == 'push'", `(${candidate})`].join(" || ");
29
+ };
@@ -5,6 +5,9 @@ const ACTION_TAG_PATTERN = /^v\d+(?:\.\d+){0,2}$/u;
5
5
  const RESERVED_ACTION_NAMES = new Set(["checkout", "setupNode", "setupPnpm"]);
6
6
 
7
7
  export interface WorkflowStep {
8
+ readonly continueOnError?: boolean;
9
+ readonly env?: Readonly<Record<string, string>>;
10
+ readonly id?: string;
8
11
  readonly name: string;
9
12
  readonly uses?: string;
10
13
  readonly with?: Readonly<Record<string, string>>;
@@ -12,6 +15,7 @@ export interface WorkflowStep {
12
15
  }
13
16
 
14
17
  export interface CheckoutStepOptions {
18
+ readonly fetchDepth?: number;
15
19
  readonly name?: string;
16
20
  readonly ref?: string;
17
21
  }
@@ -129,6 +133,25 @@ const assertAdditionalActions = (
129
133
  }
130
134
  };
131
135
 
136
+ const checkoutInputs = (
137
+ checkout: CheckoutStepOptions | undefined
138
+ ): Readonly<Record<string, string>> | undefined => {
139
+ if (checkout === undefined) {
140
+ return undefined;
141
+ }
142
+ const inputs: Record<string, string> = {};
143
+ if (checkout.fetchDepth !== undefined) {
144
+ if (!Number.isSafeInteger(checkout.fetchDepth) || checkout.fetchDepth < 0) {
145
+ throw new Error("checkout.fetchDepth must be a non-negative integer.");
146
+ }
147
+ inputs["fetch-depth"] = String(checkout.fetchDepth);
148
+ }
149
+ if (checkout.ref) {
150
+ inputs.ref = checkout.ref;
151
+ }
152
+ return Object.keys(inputs).length > 0 ? inputs : undefined;
153
+ };
154
+
132
155
  const setupSteps = (
133
156
  family: NodePnpmActionFamily,
134
157
  setup: FactoryWorkflowSetupOptions = {}
@@ -141,11 +164,12 @@ const setupSteps = (
141
164
  }
142
165
 
143
166
  const { checkout, setupNode } = setup;
167
+ const checkoutWith = checkoutInputs(checkout);
144
168
  return Object.freeze([
145
169
  {
146
170
  name: checkout?.name ?? "Checkout",
147
171
  uses: family.checkout.uses,
148
- ...(checkout?.ref ? { with: { ref: checkout.ref } } : {}),
172
+ ...(checkoutWith ? { with: checkoutWith } : {}),
149
173
  },
150
174
  usesStep("Setup pnpm", family.setupPnpm),
151
175
  {
package/src/index.ts CHANGED
@@ -13,6 +13,10 @@ export {
13
13
  type NodePnpmActionFamily,
14
14
  type PinnedAction,
15
15
  } from "./actions.ts";
16
+ export {
17
+ FACTORY_CANDIDATE_PULL_REQUEST_TYPES,
18
+ factoryCandidateOrPushCondition,
19
+ } from "./candidate-lifecycle.ts";
16
20
  export {
17
21
  bundleAlchemyEntry,
18
22
  type BundleAlchemyEntryOptions,
@@ -59,6 +63,7 @@ export {
59
63
  FACTORY_PROOF_GATE_REASON_OUTPUT,
60
64
  FACTORY_PROOF_GATE_REASONS,
61
65
  FACTORY_PROOF_GATE_SHELL,
66
+ FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT,
62
67
  FACTORY_PROOF_GATE_STEP_ID,
63
68
  FACTORY_PROOF_GATE_STEP_NAME,
64
69
  type FactoryProofGateOptions,
@@ -73,6 +78,27 @@ export {
73
78
  proofReuseRequiredCommands,
74
79
  resolveProofReuseCommands,
75
80
  } from "./proof-reuse-gate.ts";
81
+ export {
82
+ type FactoryProofReusePresentationOptions,
83
+ type FactoryProofReuseSummaryStep,
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
+ factoryProofReuseSummaryStep,
90
+ factoryProofTimingStartStep,
91
+ } from "./proof-reuse-presentation.ts";
92
+ export {
93
+ FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT,
94
+ FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT,
95
+ FACTORY_PRODUCTION_IMPACT_STEP_ID,
96
+ FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT,
97
+ type FactoryProductionImpactWorkflow,
98
+ type FactoryProductionImpactWorkflowOptions,
99
+ factoryProductionImpactWorkflow,
100
+ productionImpactTargetOutput,
101
+ } from "./production-impact-workflow.ts";
76
102
  export {
77
103
  captureVitestProfileEnvironment,
78
104
  normalizeVitestProfileSample,
@@ -0,0 +1,109 @@
1
+ import type { WorkflowStep } from "./factory-workflow.ts";
2
+
3
+ export const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
4
+ export const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
5
+ export const FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT = "basis";
6
+ export const FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT =
7
+ "unsubscribed_paths";
8
+
9
+ const OUTPUT_NAME_PATTERN = /[^a-z0-9_]+/gu;
10
+ const shellQuote = (value: string): string =>
11
+ `'${value.replaceAll("'", `'"'"'`)}'`;
12
+
13
+ /** Stable GitHub-output key for one declared target. */
14
+ export const productionImpactTargetOutput = (targetName: string): string => {
15
+ const normalized = targetName
16
+ .toLowerCase()
17
+ .replaceAll(OUTPUT_NAME_PATTERN, "_")
18
+ .replaceAll(/^_+|_+$/gu, "");
19
+ if (normalized.length === 0) {
20
+ throw new Error(
21
+ `Impact target name "${targetName}" has no output-safe characters.`
22
+ );
23
+ }
24
+ return `target_${normalized}`;
25
+ };
26
+
27
+ export interface FactoryProductionImpactWorkflowOptions {
28
+ readonly after?: string;
29
+ readonly before?: string;
30
+ readonly cli?: string;
31
+ readonly profilePath?: string;
32
+ readonly targets: readonly string[];
33
+ }
34
+
35
+ export interface FactoryProductionImpactWorkflow {
36
+ /** Outputs for a caller-owned decision job that subsequent jobs may consume. */
37
+ readonly decisionJobOutputs: Readonly<Record<string, string>>;
38
+ readonly decisionStep: WorkflowStep;
39
+ /** A fail-open condition: only an explicit usable withdrawal skips work. */
40
+ readonly demandedIf: (targetName: string, decisionJob?: string) => string;
41
+ readonly targetOutputs: Readonly<Record<string, string>>;
42
+ }
43
+
44
+ /**
45
+ * Generate the small factory-owned decision seam for a consumer production
46
+ * workflow. Consumers retain jobs, deploy commands, credentials, topology,
47
+ * and convergence checks; this artifact supplies only the decision step and
48
+ * per-target withdrawal conditions.
49
+ */
50
+ export const factoryProductionImpactWorkflow = (
51
+ options: FactoryProductionImpactWorkflowOptions
52
+ ): FactoryProductionImpactWorkflow => {
53
+ const targetOutputs = Object.fromEntries(
54
+ options.targets.map((target) => [
55
+ target,
56
+ productionImpactTargetOutput(target),
57
+ ])
58
+ );
59
+ if (new Set(Object.values(targetOutputs)).size !== options.targets.length) {
60
+ throw new Error(
61
+ "Impact target names must map to distinct GitHub output keys."
62
+ );
63
+ }
64
+
65
+ const before = options.before ?? `\${{ github.event.before }}`;
66
+ const after = options.after ?? `\${{ github.sha }}`;
67
+ const cli = options.cli ?? "pnpm exec psf";
68
+ const profile = options.profilePath
69
+ ? ` --profile ${shellQuote(options.profilePath)}`
70
+ : "";
71
+ const decisionStep: WorkflowStep = {
72
+ continueOnError: true,
73
+ env: {
74
+ FACTORY_AFTER_SHA: after,
75
+ FACTORY_BEFORE_SHA: before,
76
+ },
77
+ id: FACTORY_PRODUCTION_IMPACT_STEP_ID,
78
+ name: "Classify production impact",
79
+ run: `${cli} production:impact --before "$FACTORY_BEFORE_SHA" --after "$FACTORY_AFTER_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`,
80
+ };
81
+
82
+ return Object.freeze({
83
+ decisionJobOutputs: Object.freeze({
84
+ basis: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT} }}`,
85
+ decision: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} }}`,
86
+ unsubscribed_paths: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT} }}`,
87
+ ...Object.fromEntries(
88
+ Object.values(targetOutputs).map((output) => [
89
+ output,
90
+ `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${output} }}`,
91
+ ])
92
+ ),
93
+ }),
94
+ 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
+ },
107
+ targetOutputs: Object.freeze(targetOutputs),
108
+ });
109
+ };
@@ -107,6 +107,9 @@ export const FACTORY_PROOF_GATE_REASON_OUTPUT = "reason";
107
107
  */
108
108
  export const FACTORY_PROOF_GATE_MODE_OUTPUT = "mode";
109
109
 
110
+ /** Exact Checks API URL of the proof generation selected by the gate. */
111
+ export const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
112
+
110
113
  /**
111
114
  * Guard for every step the gate protects. Deliberately `!= 'true'` and not
112
115
  * `== 'false'`: an unset, empty, or garbled output must run the suite.
@@ -129,7 +132,8 @@ export const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
129
132
  * - `pending` the newest generation had not completed when this job read it
130
133
  * - `failed` the newest generation records no pass
131
134
  * - `unreadable` it passed but carries no binding for this repository and head
132
- * - `incomplete` it passed but did not execute every required command
135
+ * - `incomplete` its executed plus stamp-authorized released commands do not
136
+ * cover every required command
133
137
  * - `ambiguous` two newest generations share the greatest start time
134
138
  * - `error` the gate could not reach a decision (fail open)
135
139
  *
@@ -179,6 +183,14 @@ export interface ProofReuseCommand {
179
183
  const COMMAND_IDENTITY = /^\w[\w.:@/-]*$/u;
180
184
  const COMMAND_IDENTITY_MAX_LENGTH = 120;
181
185
 
186
+ /**
187
+ * The impact-stamp interpretation the paired factory packages currently
188
+ * share. A release recorded under any other version is an unmodelled input and
189
+ * cannot subtract hosted work. This is intentionally fail-closed and moves in
190
+ * lockstep with the proof/check-payload producer.
191
+ */
192
+ const TRUSTED_IMPACT_STAMP_VERSION = 3;
193
+
182
194
  const isProofReuseCommand = (value: unknown): value is ProofReuseCommand => {
183
195
  if (!(value && typeof value === "object")) {
184
196
  return false;
@@ -271,7 +283,7 @@ export const resolveProofReuseCommands = (
271
283
 
272
284
  /**
273
285
  * jq program: every page of the Checks API result in, three sanitized lines
274
- * (`reason`, `mode`, missing commands) out.
286
+ * (`reason`, `mode`, uncovered commands) out.
275
287
  *
276
288
  * The input is what `gh api --paginate` actually writes: the pages
277
289
  * *concatenated* as a stream of top-level response objects, not merged into
@@ -328,6 +340,20 @@ def startedAt: (.started_at // "") | tostring;
328
340
  def rankable:
329
341
  test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$");
330
342
 
343
+ def nonemptyString:
344
+ type == "string" and length > 0;
345
+
346
+ def distinctNames:
347
+ (map(.name) | length) == (map(.name) | unique | length);
348
+
349
+ def releaseAuthorized($release; $commands; $stamp):
350
+ ([ $commands[] | select(.name == $release.name) ]) as $commandRows
351
+ | ([ $stamp.targets[] | select(.name == $release.impactTarget) ]) as $targetRows
352
+ | ($commandRows | length) == 1
353
+ and $commandRows[0].impactTarget == $release.impactTarget
354
+ and ($targetRows | length) == 1
355
+ and $targetRows[0].impact == "not-affected";
356
+
331
357
  [ .[]
332
358
  | (.check_runs // [])[]
333
359
  | select(.name == $name)
@@ -335,20 +361,60 @@ def rankable:
335
361
  | { started: startedAt,
336
362
  status: (.status // ""),
337
363
  conclusion: (.conclusion // ""),
364
+ url: ((.html_url // "") | tostring),
338
365
  binding: binding }
339
366
  ] as $runs
340
367
  | ($runs | map(.started | rankable) | all) as $orderable
341
368
  | ($runs | map(.started | sub("\\.[0-9]+Z$"; "Z")) | max) as $newest
342
369
  | [ $runs[] | select((.started | sub("\\.[0-9]+Z$"; "Z")) == $newest) ] as $generation
343
- | (if ($runs | length) == 0 then ["none", "", ""]
344
- elif ($orderable | not) then ["ambiguous", "", ""]
345
- elif ($generation | length) != 1 then ["ambiguous", "", ""]
370
+ | (if ($runs | length) == 0 then ["none", "", "", ""]
371
+ elif ($orderable | not) then ["ambiguous", "", "", ""]
372
+ elif ($generation | length) != 1 then ["ambiguous", "", "", ""]
346
373
  else
347
374
  $generation[0] as $run
348
375
  | (if ($run.binding | type) == "object" then $run.binding else {} end) as $proof
349
376
  | (($proof.mode // "") | tostring) as $mode
350
377
  | (($proof.executedCommands // []) | map(select(type == "string"))) as $executed
351
- | ($required - $executed) as $missing
378
+ | ($proof | has("notRequiredCommands")) as $hasReleases
379
+ | (if $hasReleases then $proof.notRequiredCommands else [] end) as $releases
380
+ | ($proof.verificationCommands // null) as $commands
381
+ | ($proof.impactStamp // null) as $stamp
382
+ | (if ($hasReleases | not) then true
383
+ else
384
+ ($proof.proofSchemaVersion == 4)
385
+ and ($releases | type) == "array"
386
+ and ($releases | length) > 0
387
+ and ($releases | all(.[];
388
+ type == "object"
389
+ and (.name | nonemptyString)
390
+ and (.impactTarget | nonemptyString)
391
+ and (.basis | nonemptyString)))
392
+ and ($releases | distinctNames)
393
+ and ($commands | type) == "array"
394
+ and ($commands | all(.[];
395
+ type == "object"
396
+ and (.name | nonemptyString)
397
+ and ((has("impactTarget") | not) or (.impactTarget | nonemptyString))))
398
+ and ($commands | distinctNames)
399
+ and ($stamp | type) == "object"
400
+ and ($stamp.stampVersion == ${TRUSTED_IMPACT_STAMP_VERSION})
401
+ and ($stamp.basis == "target-scoped")
402
+ and (($stamp.reasons | type) == "array")
403
+ and ($stamp.reasons | all(.[]; type == "string"))
404
+ and (($stamp.unsubscribedPaths | type) == "array")
405
+ and ($stamp.unsubscribedPaths | all(.[]; type == "string"))
406
+ and (($stamp.targets | type) == "array")
407
+ and ($stamp.targets | all(.[];
408
+ type == "object"
409
+ and (.name | nonemptyString)
410
+ and (.basis | nonemptyString)
411
+ and ((.impact == "affected") or (.impact == "not-affected"))))
412
+ and ($stamp.targets | distinctNames)
413
+ and (([$releases[].name] - $executed | length) == ($releases | length))
414
+ and ($releases | all(.[]; releaseAuthorized(.; $commands; $stamp)))
415
+ end) as $releasesValid
416
+ | (if $releasesValid then [$releases[].name] else [] end) as $released
417
+ | ($required - $executed - $released) as $missing
352
418
  | (if $run.status != "completed" then "pending"
353
419
  elif $run.conclusion != "success" then "failed"
354
420
  elif (($run.binding | type) != "object")
@@ -358,10 +424,11 @@ def rankable:
358
424
  or ($proof.repository != $repository) then "unreadable"
359
425
  elif $proof.outcome != "passed" then "failed"
360
426
  elif ($required | length) == 0 then "incomplete"
427
+ elif ($releasesValid | not) then "incomplete"
361
428
  elif ($missing | length) != 0 then "incomplete"
362
429
  else "proven"
363
430
  end) as $reason
364
- | [$reason, $mode, ($missing | join(", "))]
431
+ | [$reason, $mode, ($missing | join(", ")), $run.url]
365
432
  end)
366
433
  | map(gsub("[\\r\\n\\t]"; " "))
367
434
  | join("\n")
@@ -413,6 +480,7 @@ reason=error
413
480
  detail=''
414
481
  mode=''
415
482
  missing=''
483
+ source_url=''
416
484
 
417
485
  # filter=all with full pagination is load-bearing (ADR 0022). GitHub's
418
486
  # default "latest" filter is ordered by completion, so a newer generation
@@ -451,6 +519,7 @@ else
451
519
  IFS= read -r reason || :
452
520
  IFS= read -r mode || :
453
521
  IFS= read -r missing || :
522
+ IFS= read -r source_url || :
454
523
  } <<< "$finding"
455
524
  case "$reason" in
456
525
  proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
@@ -477,6 +546,9 @@ fi
477
546
  # line so it cannot restructure the summary it is written into.
478
547
  detail=$(printf '%s' "$detail" | tr '\n\r\t' ' ' | cut -c1-240)
479
548
  missing=$(printf '%s' "$missing" | tr '\n\r\t' ' ' | cut -c1-240)
549
+ if ! [[ "$source_url" =~ ^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/runs/[0-9]+$ ]]; then
550
+ source_url=''
551
+ fi
480
552
 
481
553
  SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
482
554
  say() { printf '%s\n' "$1" >> "$SUMMARY"; }
@@ -489,7 +561,11 @@ case "$reason" in
489
561
  echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
490
562
  say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
491
563
  say ''
492
- say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
564
+ if [ -n "$source_url" ]; then
565
+ say "- Reused proof: [\`$CHECK_NAME\`]($source_url), published by the pinned factory GitHub App."
566
+ else
567
+ say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
568
+ fi
493
569
  say "- Covers head: \`$HEAD_SHA\`"
494
570
  say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
495
571
  ;;
@@ -524,7 +600,7 @@ case "$reason" in
524
600
  incomplete)
525
601
  say "Ran the full suite. The factory proof for this commit does not cover every command the $SURFACE surface requires."
526
602
  say ''
527
- say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its \`executedCommands\` is missing: \`$missing\`"
603
+ say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its executed commands plus stamp-authorized \`notRequiredCommands\` do not cover this surface. Missing: \`$missing\`"
528
604
  say ''
529
605
  say '${CORRECTIVE_LINE}'
530
606
  ;;
@@ -555,6 +631,7 @@ fi
555
631
  printf '${FACTORY_PROOF_GATE_OUTPUT}=%s\n' "$verdict"
556
632
  printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=%s\n' "$reason"
557
633
  printf '${FACTORY_PROOF_GATE_MODE_OUTPUT}=%s\n' "$mode"
634
+ printf '${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}=%s\n' "$source_url"
558
635
  } >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"
559
636
  `.trim();
560
637
 
@@ -0,0 +1,125 @@
1
+ /** Generic timing and presentation steps around the proof-reuse gate (#652). */
2
+
3
+ import {
4
+ FACTORY_PROOF_GATE_MODE_OUTPUT,
5
+ FACTORY_PROOF_GATE_OUTPUT,
6
+ FACTORY_PROOF_GATE_REASON_OUTPUT,
7
+ FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT,
8
+ FACTORY_PROOF_GATE_STEP_ID,
9
+ } from "./proof-reuse-gate.ts";
10
+
11
+ const githubExpression = (expression: string): string =>
12
+ `\${{ ${expression} }}`;
13
+
14
+ const shellExpansion = (expression: string): string => `\${${expression}}`;
15
+
16
+ const safeLabel = (surface: string): string => {
17
+ const cleaned = (typeof surface === "string" ? surface : "")
18
+ .replaceAll(/[^\w -]/gu, "")
19
+ .trim()
20
+ .slice(0, 60);
21
+ return cleaned.length > 0 ? cleaned : "verification";
22
+ };
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";
28
+
29
+ export interface FactoryProofReusePresentationOptions {
30
+ /** Human-readable name of the guarded suite. */
31
+ readonly surface: string;
32
+ }
33
+
34
+ export interface FactoryProofTimingStartStep {
35
+ readonly continueOnError: true;
36
+ readonly id: typeof FACTORY_PROOF_TIMING_STEP_ID;
37
+ readonly name: typeof FACTORY_PROOF_TIMING_START_STEP_NAME;
38
+ readonly run: string;
39
+ }
40
+
41
+ export const factoryProofTimingStartStep = (): FactoryProofTimingStartStep =>
42
+ Object.freeze({
43
+ continueOnError: true as const,
44
+ id: FACTORY_PROOF_TIMING_STEP_ID,
45
+ name: FACTORY_PROOF_TIMING_START_STEP_NAME,
46
+ run: String.raw`started_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
47
+ printf 'started_ms=%s\n' "$started_ms" >> "$GITHUB_OUTPUT"`,
48
+ });
49
+
50
+ export const factoryProofReuseSummaryScript = ({
51
+ surface,
52
+ }: FactoryProofReusePresentationOptions): string => {
53
+ const label = safeLabel(surface);
54
+ return String.raw`now_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
55
+ ci_started_ms="${shellExpansion("CI_STARTED_MS:-")}"
56
+
57
+ SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
58
+ say() { printf '%s\n' "$1" >> "$SUMMARY"; }
59
+
60
+ say '## ${label} timing'
61
+ say ''
62
+
63
+ if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
64
+ say '- Path: merge-target full execution'
65
+ say '- Proof-reuse gate: not applicable on merge-target runs; the full suite ran.'
66
+ 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."
68
+ elif [ "${shellExpansion("PROOF_REUSED:-")}" = 'true' ]; then
69
+ say '- Path: trusted local proof reused'
70
+ if [ -n "${shellExpansion("PROOF_SOURCE_URL:-")}" ]; then
71
+ say "- Reused proof: [exact source check](${shellExpansion("PROOF_SOURCE_URL")})"
72
+ fi
73
+ say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
74
+ say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
75
+ echo "::notice title=Factory proof reuse::Reused trusted local proof for ${label} at ${shellExpansion("PROOF_HEAD_SHA:-unknown")}."
76
+ else
77
+ say "- Path: full GitHub CI fallback (${shellExpansion("PROOF_REASON:-error")})"
78
+ say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
79
+ say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
80
+ echo "::notice title=Factory proof reuse::No reusable proof for ${label}; the full hosted suite executed (${shellExpansion("PROOF_REASON:-error")})."
81
+ fi
82
+
83
+ if [[ "$ci_started_ms" =~ ^[0-9]+$ ]]; then
84
+ say "- GitHub workflow execution after the proof gate: $((now_ms - ci_started_ms)) ms"
85
+ else
86
+ say '- GitHub workflow execution after the proof gate: unavailable'
87
+ fi
88
+ say ''`;
89
+ };
90
+
91
+ export interface FactoryProofReuseSummaryStep {
92
+ readonly continueOnError: true;
93
+ readonly env: Readonly<Record<string, string>>;
94
+ readonly if: "always()";
95
+ readonly name: typeof FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME;
96
+ readonly run: string;
97
+ }
98
+
99
+ export const factoryProofReuseSummaryStep = (
100
+ options: FactoryProofReusePresentationOptions
101
+ ): FactoryProofReuseSummaryStep =>
102
+ Object.freeze({
103
+ continueOnError: true as const,
104
+ env: Object.freeze({
105
+ CI_STARTED_MS: githubExpression(
106
+ `steps.${FACTORY_PROOF_TIMING_STEP_ID}.outputs.started_ms`
107
+ ),
108
+ PROOF_HEAD_SHA: githubExpression("github.event.pull_request.head.sha"),
109
+ PROOF_MODE: githubExpression(
110
+ `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_MODE_OUTPUT}`
111
+ ),
112
+ PROOF_REASON: githubExpression(
113
+ `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_REASON_OUTPUT}`
114
+ ),
115
+ PROOF_REUSED: githubExpression(
116
+ `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT}`
117
+ ),
118
+ PROOF_SOURCE_URL: githubExpression(
119
+ `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}`
120
+ ),
121
+ }),
122
+ if: "always()" as const,
123
+ name: FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME,
124
+ run: factoryProofReuseSummaryScript(options),
125
+ });