@patronage/factory-ci 0.2.0 → 1.0.0-alpha.10

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.
@@ -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
+ };
@@ -57,6 +57,15 @@ export const FACTORY_PROOF_GATE_APP_ID = "4314840";
57
57
  /** Step id the guard condition refers to. */
58
58
  export const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
59
59
 
60
+ /**
61
+ * Human-visible name of the gate step as the Actions jobs API serves it. A
62
+ * read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
63
+ * classify a run as proof-reuse versus full fallback, so it is exported from
64
+ * exactly the module that writes it — matching on a re-typed copy would drift.
65
+ */
66
+ export const FACTORY_PROOF_GATE_STEP_NAME =
67
+ "Check for factory proof of this head";
68
+
60
69
  /**
61
70
  * The shell the gate runs under, and it is a correctness requirement rather
62
71
  * than a preference.
@@ -98,6 +107,9 @@ export const FACTORY_PROOF_GATE_REASON_OUTPUT = "reason";
98
107
  */
99
108
  export const FACTORY_PROOF_GATE_MODE_OUTPUT = "mode";
100
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
+
101
113
  /**
102
114
  * Guard for every step the gate protects. Deliberately `!= 'true'` and not
103
115
  * `== 'false'`: an unset, empty, or garbled output must run the suite.
@@ -120,7 +132,8 @@ export const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
120
132
  * - `pending` the newest generation had not completed when this job read it
121
133
  * - `failed` the newest generation records no pass
122
134
  * - `unreadable` it passed but carries no binding for this repository and head
123
- * - `incomplete` it passed but did not execute every required command
135
+ * - `incomplete` its executed plus stamp-authorized released commands do not
136
+ * cover every required command
124
137
  * - `ambiguous` two newest generations share the greatest start time
125
138
  * - `error` the gate could not reach a decision (fail open)
126
139
  *
@@ -170,6 +183,14 @@ export interface ProofReuseCommand {
170
183
  const COMMAND_IDENTITY = /^\w[\w.:@/-]*$/u;
171
184
  const COMMAND_IDENTITY_MAX_LENGTH = 120;
172
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
+
173
194
  const isProofReuseCommand = (value: unknown): value is ProofReuseCommand => {
174
195
  if (!(value && typeof value === "object")) {
175
196
  return false;
@@ -193,9 +214,11 @@ const isProofReuseCommand = (value: unknown): value is ProofReuseCommand => {
193
214
  *
194
215
  * `undefined` means the selection is unusable — not an array, empty, or
195
216
  * carrying an entry whose `command` is blank or whose `name` is not a plain
196
- * command identity. An empty required set would make *every* passing proof
197
- * trivially covering, so it is never silently treated as "requires nothing";
198
- * callers must refuse instead.
217
+ * command identity, or carrying duplicate names. A name is the executable
218
+ * authorization identity recorded in proof, so two commands may never collapse
219
+ * behind one. An empty required set would make *every* passing proof trivially
220
+ * covering, so it is never silently treated as "requires nothing"; callers
221
+ * must refuse instead.
199
222
  */
200
223
  export const proofReuseRequiredCommands = (
201
224
  commands: readonly ProofReuseCommand[]
@@ -206,12 +229,61 @@ export const proofReuseRequiredCommands = (
206
229
  if (!commands.every(isProofReuseCommand)) {
207
230
  return;
208
231
  }
209
- return [...new Set(commands.map(({ name }) => name))].toSorted();
232
+ const names = commands.map(({ name }) => name);
233
+ if (new Set(names).size !== names.length) {
234
+ return;
235
+ }
236
+ return names.toSorted();
210
237
  };
211
238
 
239
+ /**
240
+ * Resolve command *identities* to their `ProofReuseCommand` objects (ADR
241
+ * 0021).
242
+ *
243
+ * Selection by name is deliberately a consumer decision (proof-surfaces.ts in
244
+ * software-factory-hq, the `paitronage:verify` filter in paitronage's
245
+ * `verify.ts`): only the consumer knows which named commands a guarded
246
+ * surface requires. What both of those implementations independently
247
+ * hand-rolled is the same lookup — find each name in the profile's command
248
+ * catalog, and refuse to silently shrink the required set when a name has no
249
+ * entry. That lookup is what this function is: the mechanic, not the
250
+ * selection.
251
+ *
252
+ * Throwing at generation time (rather than returning `undefined` or an empty
253
+ * array) is deliberate: a name with no catalog entry is a mistake in the
254
+ * generator source, not a runtime condition a consumer should have to check
255
+ * for, and a required set that quietly loses an entry is exactly what makes a
256
+ * passing proof trivially "covering".
257
+ *
258
+ * `selectionLabel` names the failure, nothing else: it is not part of the
259
+ * selection this function resolves, only prose a consumer supplies for its
260
+ * own thrown error (e.g. HQ's surface name, "core" or "docs"). The message
261
+ * deliberately says "the profile's command catalog" rather than naming
262
+ * `software-factory.profile.json`: a fleet-generic library must not assume
263
+ * every consumer's catalog is that exact file, so this wording differs
264
+ * on purpose from the HQ-local message it replaced.
265
+ */
266
+ export const resolveProofReuseCommands = (
267
+ catalog: readonly ProofReuseCommand[],
268
+ names: readonly string[],
269
+ selectionLabel?: string
270
+ ): readonly ProofReuseCommand[] =>
271
+ names.map((name) => {
272
+ const command = catalog.find((entry) => entry.name === name);
273
+ if (!command) {
274
+ const location = selectionLabel
275
+ ? `Proof-reuse selection for the ${selectionLabel} surface names`
276
+ : "Proof-reuse selection names";
277
+ throw new Error(
278
+ `${location} "${name}", which the profile's command catalog does not define.`
279
+ );
280
+ }
281
+ return { command: command.command, name: command.name };
282
+ });
283
+
212
284
  /**
213
285
  * jq program: every page of the Checks API result in, three sanitized lines
214
- * (`reason`, `mode`, missing commands) out.
286
+ * (`reason`, `mode`, uncovered commands) out.
215
287
  *
216
288
  * The input is what `gh api --paginate` actually writes: the pages
217
289
  * *concatenated* as a stream of top-level response objects, not merged into
@@ -268,6 +340,20 @@ def startedAt: (.started_at // "") | tostring;
268
340
  def rankable:
269
341
  test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$");
270
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
+
271
357
  [ .[]
272
358
  | (.check_runs // [])[]
273
359
  | select(.name == $name)
@@ -275,20 +361,60 @@ def rankable:
275
361
  | { started: startedAt,
276
362
  status: (.status // ""),
277
363
  conclusion: (.conclusion // ""),
364
+ url: ((.html_url // "") | tostring),
278
365
  binding: binding }
279
366
  ] as $runs
280
367
  | ($runs | map(.started | rankable) | all) as $orderable
281
368
  | ($runs | map(.started | sub("\\.[0-9]+Z$"; "Z")) | max) as $newest
282
369
  | [ $runs[] | select((.started | sub("\\.[0-9]+Z$"; "Z")) == $newest) ] as $generation
283
- | (if ($runs | length) == 0 then ["none", "", ""]
284
- elif ($orderable | not) then ["ambiguous", "", ""]
285
- 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", "", "", ""]
286
373
  else
287
374
  $generation[0] as $run
288
375
  | (if ($run.binding | type) == "object" then $run.binding else {} end) as $proof
289
376
  | (($proof.mode // "") | tostring) as $mode
290
377
  | (($proof.executedCommands // []) | map(select(type == "string"))) as $executed
291
- | ($required - $executed) as $missing
378
+ | ($proof | has("notRequiredCommands")) as $hasReleases
379
+ | (if $hasReleases then $proof.notRequiredCommands else [] end) as $releases
380
+ | ($proof.verificationCommands // null) as $commands
381
+ | ($proof.impactStamp // null) as $stamp
382
+ | (if ($hasReleases | not) then true
383
+ else
384
+ ($proof.proofSchemaVersion == 4)
385
+ and ($releases | type) == "array"
386
+ and ($releases | length) > 0
387
+ and ($releases | all(.[];
388
+ type == "object"
389
+ and (.name | nonemptyString)
390
+ and (.impactTarget | nonemptyString)
391
+ and (.basis | nonemptyString)))
392
+ and ($releases | distinctNames)
393
+ and ($commands | type) == "array"
394
+ and ($commands | all(.[];
395
+ type == "object"
396
+ and (.name | nonemptyString)
397
+ and ((has("impactTarget") | not) or (.impactTarget | nonemptyString))))
398
+ and ($commands | distinctNames)
399
+ and ($stamp | type) == "object"
400
+ and ($stamp.stampVersion == ${TRUSTED_IMPACT_STAMP_VERSION})
401
+ and ($stamp.basis == "target-scoped")
402
+ and (($stamp.reasons | type) == "array")
403
+ and ($stamp.reasons | all(.[]; type == "string"))
404
+ and (($stamp.unsubscribedPaths | type) == "array")
405
+ and ($stamp.unsubscribedPaths | all(.[]; type == "string"))
406
+ and (($stamp.targets | type) == "array")
407
+ and ($stamp.targets | all(.[];
408
+ type == "object"
409
+ and (.name | nonemptyString)
410
+ and (.basis | nonemptyString)
411
+ and ((.impact == "affected") or (.impact == "not-affected"))))
412
+ and ($stamp.targets | distinctNames)
413
+ and (([$releases[].name] - $executed | length) == ($releases | length))
414
+ and ($releases | all(.[]; releaseAuthorized(.; $commands; $stamp)))
415
+ end) as $releasesValid
416
+ | (if $releasesValid then [$releases[].name] else [] end) as $released
417
+ | ($required - $executed - $released) as $missing
292
418
  | (if $run.status != "completed" then "pending"
293
419
  elif $run.conclusion != "success" then "failed"
294
420
  elif (($run.binding | type) != "object")
@@ -298,10 +424,11 @@ def rankable:
298
424
  or ($proof.repository != $repository) then "unreadable"
299
425
  elif $proof.outcome != "passed" then "failed"
300
426
  elif ($required | length) == 0 then "incomplete"
427
+ elif ($releasesValid | not) then "incomplete"
301
428
  elif ($missing | length) != 0 then "incomplete"
302
429
  else "proven"
303
430
  end) as $reason
304
- | [$reason, $mode, ($missing | join(", "))]
431
+ | [$reason, $mode, ($missing | join(", ")), $run.url]
305
432
  end)
306
433
  | map(gsub("[\\r\\n\\t]"; " "))
307
434
  | join("\n")
@@ -353,6 +480,7 @@ reason=error
353
480
  detail=''
354
481
  mode=''
355
482
  missing=''
483
+ source_url=''
356
484
 
357
485
  # filter=all with full pagination is load-bearing (ADR 0022). GitHub's
358
486
  # default "latest" filter is ordered by completion, so a newer generation
@@ -391,6 +519,7 @@ else
391
519
  IFS= read -r reason || :
392
520
  IFS= read -r mode || :
393
521
  IFS= read -r missing || :
522
+ IFS= read -r source_url || :
394
523
  } <<< "$finding"
395
524
  case "$reason" in
396
525
  proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
@@ -417,6 +546,9 @@ fi
417
546
  # line so it cannot restructure the summary it is written into.
418
547
  detail=$(printf '%s' "$detail" | tr '\n\r\t' ' ' | cut -c1-240)
419
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
420
552
 
421
553
  SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
422
554
  say() { printf '%s\n' "$1" >> "$SUMMARY"; }
@@ -429,7 +561,11 @@ case "$reason" in
429
561
  echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
430
562
  say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
431
563
  say ''
432
- say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
564
+ if [ -n "$source_url" ]; then
565
+ say "- Reused proof: [\`$CHECK_NAME\`]($source_url), published by the pinned factory GitHub App."
566
+ else
567
+ say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
568
+ fi
433
569
  say "- Covers head: \`$HEAD_SHA\`"
434
570
  say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
435
571
  ;;
@@ -464,7 +600,7 @@ case "$reason" in
464
600
  incomplete)
465
601
  say "Ran the full suite. The factory proof for this commit does not cover every command the $SURFACE surface requires."
466
602
  say ''
467
- say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its \`executedCommands\` is missing: \`$missing\`"
603
+ say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its executed commands plus stamp-authorized \`notRequiredCommands\` do not cover this surface. Missing: \`$missing\`"
468
604
  say ''
469
605
  say '${CORRECTIVE_LINE}'
470
606
  ;;
@@ -495,6 +631,7 @@ fi
495
631
  printf '${FACTORY_PROOF_GATE_OUTPUT}=%s\n' "$verdict"
496
632
  printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=%s\n' "$reason"
497
633
  printf '${FACTORY_PROOF_GATE_MODE_OUTPUT}=%s\n' "$mode"
634
+ printf '${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}=%s\n' "$source_url"
498
635
  } >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"
499
636
  `.trim();
500
637
 
@@ -568,7 +705,7 @@ export const factoryProofGateStep = (
568
705
  }),
569
706
  id: FACTORY_PROOF_GATE_STEP_ID,
570
707
  if: FACTORY_PROOF_GATE_IF,
571
- name: "Check for factory proof of this head",
708
+ name: FACTORY_PROOF_GATE_STEP_NAME,
572
709
  run: factoryProofGateScript(options),
573
710
  // Never omit: the runner's default `run:` shell supplies `-e`, which
574
711
  // voids the fail-open design. See FACTORY_PROOF_GATE_SHELL.
@@ -579,10 +716,8 @@ export interface ProofReuseCoverageInput {
579
716
  /** The same selection handed to the gate for this surface. */
580
717
  readonly commands: readonly ProofReuseCommand[];
581
718
  /**
582
- * Skipped work covered under a different command string, keyed by the
583
- * skipped string with the reason as its value. Deliberately explicit: a
584
- * prose rationale for why some narrower command is "equivalent enough" is
585
- * exactly what this assertion exists to force into the open.
719
+ * @deprecated Ignored. Consumer prose cannot authorize executable coverage;
720
+ * retained only so the 0.2.1 security patch remains source-compatible.
586
721
  */
587
722
  readonly equivalents?: Readonly<Record<string, string>>;
588
723
  /**
@@ -617,7 +752,6 @@ export interface ProofReuseCoverageReport {
617
752
  */
618
753
  export const proofReuseCoverage = ({
619
754
  commands,
620
- equivalents = {},
621
755
  skipped,
622
756
  }: ProofReuseCoverageInput): ProofReuseCoverageReport => {
623
757
  const requiredCommands = proofReuseRequiredCommands(commands) ?? [];
@@ -630,15 +764,7 @@ export const proofReuseCoverage = ({
630
764
  ...new Set(
631
765
  (Array.isArray(skipped) ? skipped : [])
632
766
  .map((command) => String(command).trim())
633
- .filter(
634
- (command) =>
635
- command.length > 0 &&
636
- // `Object.hasOwn`, never `in`: `in` walks the prototype chain, so
637
- // a skipped command named `toString` or `constructor` would report
638
- // itself as a declared equivalent of nothing. This assertion's only
639
- // job is to fail loudly.
640
- !(proven.has(command) || Object.hasOwn(equivalents, command))
641
- )
767
+ .filter((command) => command.length > 0 && !proven.has(command))
642
768
  ),
643
769
  ].toSorted();
644
770
 
@@ -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
+ });