@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 +51 -2
- package/dist/index.d.ts +82 -2
- package/dist/index.js +262 -24
- package/package.json +1 -1
- package/src/candidate-lifecycle.ts +29 -0
- package/src/factory-workflow.ts +25 -1
- package/src/index.ts +26 -0
- package/src/production-impact-workflow.ts +109 -0
- package/src/proof-reuse-gate.ts +86 -9
- package/src/proof-reuse-presentation.ts +125 -0
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
|
|
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`
|
|
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
|
-
...
|
|
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`
|
|
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`,
|
|
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
|
-
| ($
|
|
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
|
-
|
|
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 \`
|
|
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
|
-
}
|
|
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.
|
|
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
|
+
};
|
package/src/factory-workflow.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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
|
+
};
|
package/src/proof-reuse-gate.ts
CHANGED
|
@@ -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`
|
|
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`,
|
|
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
|
-
| ($
|
|
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
|
-
|
|
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 \`
|
|
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
|
+
});
|