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