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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -32,6 +32,32 @@ const NODE_PNPM_ACTION_FAMILY_NODE24 = {
32
32
  }
33
33
  };
34
34
  //#endregion
35
+ //#region src/candidate-lifecycle.ts
36
+ /**
37
+ * Pull-request events that can create or refresh a Factory candidate run.
38
+ *
39
+ * GitHub's draft boolean remains the lifecycle authority: these triggers make
40
+ * a run visible, while `factoryCandidateOrPushCondition` keeps substantive
41
+ * jobs idle until the pull request is a candidate.
42
+ */
43
+ const FACTORY_CANDIDATE_PULL_REQUEST_TYPES = Object.freeze([
44
+ "opened",
45
+ "synchronize",
46
+ "reopened",
47
+ "ready_for_review"
48
+ ]);
49
+ /**
50
+ * Build a GitHub Actions job condition for candidate PRs and merge-target
51
+ * pushes. The caller owns triggers, jobs, runners, permissions, and topology.
52
+ */
53
+ const factoryCandidateOrPushCondition = (candidateCondition) => {
54
+ return ["github.event_name == 'push'", `(${[
55
+ "github.event_name == 'pull_request'",
56
+ "github.event.pull_request.draft != true",
57
+ ...candidateCondition ? [candidateCondition] : []
58
+ ].join(" && ")})`].join(" || ");
59
+ };
60
+ //#endregion
35
61
  //#region src/bundle-alchemy-entry.ts
36
62
  /**
37
63
  * Alchemy and Effect must resolve from the *consumer's* node_modules at run
@@ -208,15 +234,26 @@ const assertAdditionalActions = (actions) => {
208
234
  assertPinnedAction(name, action);
209
235
  }
210
236
  };
237
+ const checkoutInputs = (checkout) => {
238
+ if (checkout === void 0) return;
239
+ const inputs = {};
240
+ if (checkout.fetchDepth !== void 0) {
241
+ if (!Number.isSafeInteger(checkout.fetchDepth) || checkout.fetchDepth < 0) throw new Error("checkout.fetchDepth must be a non-negative integer.");
242
+ inputs["fetch-depth"] = String(checkout.fetchDepth);
243
+ }
244
+ if (checkout.ref) inputs.ref = checkout.ref;
245
+ return Object.keys(inputs).length > 0 ? inputs : void 0;
246
+ };
211
247
  const setupSteps = (family, setup = {}) => {
212
248
  const installRun = setup.install?.run ?? "pnpm install --frozen-lockfile";
213
249
  if (/(?:^|\s)--ignore-scripts(?:\s|$)/u.test(installRun)) throw new Error("The install step must not use --ignore-scripts; workspace prepare scripts build required package artifacts.");
214
250
  const { checkout, setupNode } = setup;
251
+ const checkoutWith = checkoutInputs(checkout);
215
252
  return Object.freeze([
216
253
  {
217
254
  name: checkout?.name ?? "Checkout",
218
255
  uses: family.checkout.uses,
219
- ...checkout?.ref ? { with: { ref: checkout.ref } } : {}
256
+ ...checkoutWith ? { with: checkoutWith } : {}
220
257
  },
221
258
  usesStep("Setup pnpm", family.setupPnpm),
222
259
  {
@@ -403,9 +440,9 @@ const executeAlchemyEntry = async (options) => {
403
440
  * Escaped so the emitted text carries a GitHub Actions expression rather than
404
441
  * this file carrying a JavaScript template hole.
405
442
  */
406
- const githubExpression = (expression) => `\${{ ${expression} }}`;
443
+ const githubExpression$1 = (expression) => `\${{ ${expression} }}`;
407
444
  /** A braced shell expansion that has to survive TypeScript interpolation. */
408
- const shellExpansion = (expression) => `\${${expression}}`;
445
+ const shellExpansion$1 = (expression) => `\${${expression}}`;
409
446
  /**
410
447
  * Single-quote a value for the emitted script. Every non-literal value that
411
448
  * reaches the script goes through this.
@@ -435,6 +472,13 @@ const FACTORY_PROOF_GATE_APP_ID = "4314840";
435
472
  /** Step id the guard condition refers to. */
436
473
  const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
437
474
  /**
475
+ * Human-visible name of the gate step as the Actions jobs API serves it. A
476
+ * read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
477
+ * classify a run as proof-reuse versus full fallback, so it is exported from
478
+ * exactly the module that writes it — matching on a re-typed copy would drift.
479
+ */
480
+ const FACTORY_PROOF_GATE_STEP_NAME = "Check for factory proof of this head";
481
+ /**
438
482
  * The shell the gate runs under, and it is a correctness requirement rather
439
483
  * than a preference.
440
484
  *
@@ -471,6 +515,8 @@ const FACTORY_PROOF_GATE_REASON_OUTPUT = "reason";
471
515
  * proof that executed every command the surface requires is reusable.
472
516
  */
473
517
  const FACTORY_PROOF_GATE_MODE_OUTPUT = "mode";
518
+ /** Exact Checks API URL of the proof generation selected by the gate. */
519
+ const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
474
520
  /**
475
521
  * Guard for every step the gate protects. Deliberately `!= 'true'` and not
476
522
  * `== 'false'`: an unset, empty, or garbled output must run the suite.
@@ -491,7 +537,8 @@ const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
491
537
  * - `pending` the newest generation had not completed when this job read it
492
538
  * - `failed` the newest generation records no pass
493
539
  * - `unreadable` it passed but carries no binding for this repository and head
494
- * - `incomplete` it passed but did not execute every required command
540
+ * - `incomplete` its executed plus stamp-authorized released commands do not
541
+ * cover every required command
495
542
  * - `ambiguous` two newest generations share the greatest start time
496
543
  * - `error` the gate could not reach a decision (fail open)
497
544
  *
@@ -525,6 +572,13 @@ const FACTORY_PROOF_GATE_REASONS = [
525
572
  */
526
573
  const COMMAND_IDENTITY = /^\w[\w.:@/-]*$/u;
527
574
  const COMMAND_IDENTITY_MAX_LENGTH = 120;
575
+ /**
576
+ * The impact-stamp interpretation the paired factory packages currently
577
+ * share. A release recorded under any other version is an unmodelled input and
578
+ * cannot subtract hosted work. This is intentionally fail-closed and moves in
579
+ * lockstep with the proof/check-payload producer.
580
+ */
581
+ const TRUSTED_IMPACT_STAMP_VERSION = 3;
528
582
  const isProofReuseCommand = (value) => {
529
583
  if (!(value && typeof value === "object")) return false;
530
584
  const entry = value;
@@ -592,7 +646,7 @@ const resolveProofReuseCommands = (catalog, names, selectionLabel) => names.map(
592
646
  });
593
647
  /**
594
648
  * jq program: every page of the Checks API result in, three sanitized lines
595
- * (`reason`, `mode`, missing commands) out.
649
+ * (`reason`, `mode`, uncovered commands) out.
596
650
  *
597
651
  * The input is what `gh api --paginate` actually writes: the pages
598
652
  * *concatenated* as a stream of top-level response objects, not merged into
@@ -649,6 +703,20 @@ def startedAt: (.started_at // "") | tostring;
649
703
  def rankable:
650
704
  test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$");
651
705
 
706
+ def nonemptyString:
707
+ type == "string" and length > 0;
708
+
709
+ def distinctNames:
710
+ (map(.name) | length) == (map(.name) | unique | length);
711
+
712
+ def releaseAuthorized($release; $commands; $stamp):
713
+ ([ $commands[] | select(.name == $release.name) ]) as $commandRows
714
+ | ([ $stamp.targets[] | select(.name == $release.impactTarget) ]) as $targetRows
715
+ | ($commandRows | length) == 1
716
+ and $commandRows[0].impactTarget == $release.impactTarget
717
+ and ($targetRows | length) == 1
718
+ and $targetRows[0].impact == "not-affected";
719
+
652
720
  [ .[]
653
721
  | (.check_runs // [])[]
654
722
  | select(.name == $name)
@@ -656,20 +724,60 @@ def rankable:
656
724
  | { started: startedAt,
657
725
  status: (.status // ""),
658
726
  conclusion: (.conclusion // ""),
727
+ url: ((.html_url // "") | tostring),
659
728
  binding: binding }
660
729
  ] as $runs
661
730
  | ($runs | map(.started | rankable) | all) as $orderable
662
731
  | ($runs | map(.started | sub("\\.[0-9]+Z$"; "Z")) | max) as $newest
663
732
  | [ $runs[] | select((.started | sub("\\.[0-9]+Z$"; "Z")) == $newest) ] as $generation
664
- | (if ($runs | length) == 0 then ["none", "", ""]
665
- elif ($orderable | not) then ["ambiguous", "", ""]
666
- elif ($generation | length) != 1 then ["ambiguous", "", ""]
733
+ | (if ($runs | length) == 0 then ["none", "", "", ""]
734
+ elif ($orderable | not) then ["ambiguous", "", "", ""]
735
+ elif ($generation | length) != 1 then ["ambiguous", "", "", ""]
667
736
  else
668
737
  $generation[0] as $run
669
738
  | (if ($run.binding | type) == "object" then $run.binding else {} end) as $proof
670
739
  | (($proof.mode // "") | tostring) as $mode
671
740
  | (($proof.executedCommands // []) | map(select(type == "string"))) as $executed
672
- | ($required - $executed) as $missing
741
+ | ($proof | has("notRequiredCommands")) as $hasReleases
742
+ | (if $hasReleases then $proof.notRequiredCommands else [] end) as $releases
743
+ | ($proof.verificationCommands // null) as $commands
744
+ | ($proof.impactStamp // null) as $stamp
745
+ | (if ($hasReleases | not) then true
746
+ else
747
+ ($proof.proofSchemaVersion == 4)
748
+ and ($releases | type) == "array"
749
+ and ($releases | length) > 0
750
+ and ($releases | all(.[];
751
+ type == "object"
752
+ and (.name | nonemptyString)
753
+ and (.impactTarget | nonemptyString)
754
+ and (.basis | nonemptyString)))
755
+ and ($releases | distinctNames)
756
+ and ($commands | type) == "array"
757
+ and ($commands | all(.[];
758
+ type == "object"
759
+ and (.name | nonemptyString)
760
+ and ((has("impactTarget") | not) or (.impactTarget | nonemptyString))))
761
+ and ($commands | distinctNames)
762
+ and ($stamp | type) == "object"
763
+ and ($stamp.stampVersion == ${TRUSTED_IMPACT_STAMP_VERSION})
764
+ and ($stamp.basis == "target-scoped")
765
+ and (($stamp.reasons | type) == "array")
766
+ and ($stamp.reasons | all(.[]; type == "string"))
767
+ and (($stamp.unsubscribedPaths | type) == "array")
768
+ and ($stamp.unsubscribedPaths | all(.[]; type == "string"))
769
+ and (($stamp.targets | type) == "array")
770
+ and ($stamp.targets | all(.[];
771
+ type == "object"
772
+ and (.name | nonemptyString)
773
+ and (.basis | nonemptyString)
774
+ and ((.impact == "affected") or (.impact == "not-affected"))))
775
+ and ($stamp.targets | distinctNames)
776
+ and (([$releases[].name] - $executed | length) == ($releases | length))
777
+ and ($releases | all(.[]; releaseAuthorized(.; $commands; $stamp)))
778
+ end) as $releasesValid
779
+ | (if $releasesValid then [$releases[].name] else [] end) as $released
780
+ | ($required - $executed - $released) as $missing
673
781
  | (if $run.status != "completed" then "pending"
674
782
  elif $run.conclusion != "success" then "failed"
675
783
  elif (($run.binding | type) != "object")
@@ -679,10 +787,11 @@ def rankable:
679
787
  or ($proof.repository != $repository) then "unreadable"
680
788
  elif $proof.outcome != "passed" then "failed"
681
789
  elif ($required | length) == 0 then "incomplete"
790
+ elif ($releasesValid | not) then "incomplete"
682
791
  elif ($missing | length) != 0 then "incomplete"
683
792
  else "proven"
684
793
  end) as $reason
685
- | [$reason, $mode, ($missing | join(", "))]
794
+ | [$reason, $mode, ($missing | join(", ")), $run.url]
686
795
  end)
687
796
  | map(gsub("[\\r\\n\\t]"; " "))
688
797
  | join("\n")
@@ -707,9 +816,9 @@ const CORRECTIVE_LINE = "Run `psf pr:verify` before publishing to reuse local pr
707
816
  const UNUSABLE_SELECTION_SCRIPT = String.raw`{
708
817
  printf '${FACTORY_PROOF_GATE_OUTPUT}=false\n'
709
818
  printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=error\n'
710
- } >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"`;
819
+ } >> "${shellExpansion$1("GITHUB_OUTPUT:-/dev/null")}"`;
711
820
  /** Surface labels reach markdown, so only a plain, bounded label survives. */
712
- const safeLabel = (surface) => {
821
+ const safeLabel$1 = (surface) => {
713
822
  const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
714
823
  return cleaned.length > 0 ? cleaned : "verification";
715
824
  };
@@ -725,14 +834,15 @@ reason=error
725
834
  detail=''
726
835
  mode=''
727
836
  missing=''
837
+ source_url=''
728
838
 
729
839
  # filter=all with full pagination is load-bearing (ADR 0022). GitHub's
730
840
  # default "latest" filter is ordered by completion, so a newer generation
731
841
  # that is still running can be hidden behind an older completed one — the
732
842
  # gate would then read a stale pass as current.
733
- if [ -z "${shellExpansion("HEAD_SHA:-")}" ]; then
843
+ if [ -z "${shellExpansion$1("HEAD_SHA:-")}" ]; then
734
844
  detail='no pull request head SHA'
735
- elif [ -z "${shellExpansion("GITHUB_REPOSITORY:-")}" ]; then
845
+ elif [ -z "${shellExpansion$1("GITHUB_REPOSITORY:-")}" ]; then
736
846
  detail='no repository name'
737
847
  elif ! response=$(gh api --method GET --paginate \
738
848
  "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs" \
@@ -763,6 +873,7 @@ else
763
873
  IFS= read -r reason || :
764
874
  IFS= read -r mode || :
765
875
  IFS= read -r missing || :
876
+ IFS= read -r source_url || :
766
877
  } <<< "$finding"
767
878
  case "$reason" in
768
879
  proven | none | pending | failed | unreadable | incomplete | ambiguous) ;;
@@ -789,8 +900,11 @@ fi
789
900
  # line so it cannot restructure the summary it is written into.
790
901
  detail=$(printf '%s' "$detail" | tr '\n\r\t' ' ' | cut -c1-240)
791
902
  missing=$(printf '%s' "$missing" | tr '\n\r\t' ' ' | cut -c1-240)
903
+ if ! [[ "$source_url" =~ ^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/runs/[0-9]+$ ]]; then
904
+ source_url=''
905
+ fi
792
906
 
793
- SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
907
+ SUMMARY="${shellExpansion$1("GITHUB_STEP_SUMMARY:-/dev/null")}"
794
908
  say() { printf '%s\n' "$1" >> "$SUMMARY"; }
795
909
 
796
910
  say "## Proof reuse: $SURFACE"
@@ -801,7 +915,11 @@ case "$reason" in
801
915
  echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
802
916
  say "Skipped. The factory already verified this exact commit, so this job did not run the $SURFACE suite a second time."
803
917
  say ''
804
- say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
918
+ if [ -n "$source_url" ]; then
919
+ say "- Reused proof: [\`$CHECK_NAME\`]($source_url), published by the pinned factory GitHub App."
920
+ else
921
+ say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
922
+ fi
805
923
  say "- Covers head: \`$HEAD_SHA\`"
806
924
  say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
807
925
  ;;
@@ -836,7 +954,7 @@ case "$reason" in
836
954
  incomplete)
837
955
  say "Ran the full suite. The factory proof for this commit does not cover every command the $SURFACE surface requires."
838
956
  say ''
839
- say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its \`executedCommands\` is missing: \`$missing\`"
957
+ say "The newest \`$CHECK_NAME\` check run at head \`$HEAD_SHA\` passed, but its executed commands plus stamp-authorized \`notRequiredCommands\` do not cover this surface. Missing: \`$missing\`"
840
958
  say ''
841
959
  say '${CORRECTIVE_LINE}'
842
960
  ;;
@@ -858,7 +976,7 @@ esac
858
976
  say ''
859
977
 
860
978
  if [ "$verdict" != 'true' ]; then
861
- echo "No reusable factory proof for ${shellExpansion("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
979
+ echo "No reusable factory proof for ${shellExpansion$1("HEAD_SHA:-<unknown>")}; running the $SURFACE suite ($reason). $detail"
862
980
  fi
863
981
 
864
982
  # The only write. A crash before this line leaves the output unset, the guard
@@ -867,7 +985,8 @@ fi
867
985
  printf '${FACTORY_PROOF_GATE_OUTPUT}=%s\n' "$verdict"
868
986
  printf '${FACTORY_PROOF_GATE_REASON_OUTPUT}=%s\n' "$reason"
869
987
  printf '${FACTORY_PROOF_GATE_MODE_OUTPUT}=%s\n' "$mode"
870
- } >> "${shellExpansion("GITHUB_OUTPUT:-/dev/null")}"
988
+ printf '${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}=%s\n' "$source_url"
989
+ } >> "${shellExpansion$1("GITHUB_OUTPUT:-/dev/null")}"
871
990
  `.trim();
872
991
  /**
873
992
  * The gate script. Exported so it can be executed directly under test against
@@ -883,7 +1002,7 @@ fi
883
1002
  */
884
1003
  const factoryProofGateScript = ({ commands, surface }) => {
885
1004
  const required = proofReuseRequiredCommands(commands);
886
- return required ? gateScript(required, safeLabel(surface)) : UNUSABLE_SELECTION_SCRIPT;
1005
+ return required ? gateScript(required, safeLabel$1(surface)) : UNUSABLE_SELECTION_SCRIPT;
887
1006
  };
888
1007
  /**
889
1008
  * The step itself, structurally accepted by gagen's `step()` without adding a
@@ -901,12 +1020,12 @@ const factoryProofGateScript = ({ commands, surface }) => {
901
1020
  const factoryProofGateStep = (options) => Object.freeze({
902
1021
  continueOnError: true,
903
1022
  env: Object.freeze({
904
- GH_TOKEN: githubExpression("secrets.GITHUB_TOKEN"),
905
- HEAD_SHA: githubExpression("github.event.pull_request.head.sha")
1023
+ GH_TOKEN: githubExpression$1("secrets.GITHUB_TOKEN"),
1024
+ HEAD_SHA: githubExpression$1("github.event.pull_request.head.sha")
906
1025
  }),
907
1026
  id: FACTORY_PROOF_GATE_STEP_ID,
908
1027
  if: FACTORY_PROOF_GATE_IF,
909
- name: "Check for factory proof of this head",
1028
+ name: FACTORY_PROOF_GATE_STEP_NAME,
910
1029
  run: factoryProofGateScript(options),
911
1030
  shell: FACTORY_PROOF_GATE_SHELL
912
1031
  });
@@ -934,11 +1053,137 @@ const proofReuseCoverage = ({ commands, skipped }) => {
934
1053
  const assertProofReuseCoverage = (input) => {
935
1054
  const report = proofReuseCoverage(input);
936
1055
  if (report.covered) return report;
937
- const surface = safeLabel(input.surface ?? "");
1056
+ const surface = safeLabel$1(input.surface ?? "");
938
1057
  const problem = report.requiredCommands.length === 0 ? "selects no usable profile commands, so any passing proof would trivially cover it" : `skips work no selected profile command runs: ${report.uncovered.join(", ")}`;
939
1058
  throw new Error(`Proof-reuse coverage failed: the ${surface} surface ${problem}. Add the command to software-factory.profile.json (and to this surface's selection), or stop skipping it.`);
940
1059
  };
941
1060
  //#endregion
1061
+ //#region src/proof-reuse-presentation.ts
1062
+ /** Generic timing and presentation steps around the proof-reuse gate (#652). */
1063
+ const githubExpression = (expression) => `\${{ ${expression} }}`;
1064
+ const shellExpansion = (expression) => `\${${expression}}`;
1065
+ const safeLabel = (surface) => {
1066
+ const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
1067
+ return cleaned.length > 0 ? cleaned : "verification";
1068
+ };
1069
+ const FACTORY_PROOF_TIMING_STEP_ID = "ci-timing";
1070
+ const FACTORY_PROOF_TIMING_START_STEP_NAME = "Start CI timing";
1071
+ const FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME = "Record proof-reuse timing";
1072
+ const factoryProofTimingStartStep = () => Object.freeze({
1073
+ continueOnError: true,
1074
+ id: FACTORY_PROOF_TIMING_STEP_ID,
1075
+ name: FACTORY_PROOF_TIMING_START_STEP_NAME,
1076
+ run: String.raw`started_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
1077
+ printf 'started_ms=%s\n' "$started_ms" >> "$GITHUB_OUTPUT"`
1078
+ });
1079
+ const factoryProofReuseSummaryScript = ({ surface }) => {
1080
+ const label = safeLabel(surface);
1081
+ return String.raw`now_ms="$(node -e 'process.stdout.write(String(Date.now()))')"
1082
+ ci_started_ms="${shellExpansion("CI_STARTED_MS:-")}"
1083
+
1084
+ SUMMARY="${shellExpansion("GITHUB_STEP_SUMMARY:-/dev/null")}"
1085
+ say() { printf '%s\n' "$1" >> "$SUMMARY"; }
1086
+
1087
+ say '## ${label} timing'
1088
+ say ''
1089
+
1090
+ if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
1091
+ say '- Path: merge-target full execution'
1092
+ say '- Proof-reuse gate: not applicable on merge-target runs; the full suite ran.'
1093
+ say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
1094
+ echo "::notice title=Factory proof reuse::Proof reuse is not applicable on merge-target runs; the full ${label} suite executed."
1095
+ elif [ "${shellExpansion("PROOF_REUSED:-")}" = 'true' ]; then
1096
+ say '- Path: trusted local proof reused'
1097
+ if [ -n "${shellExpansion("PROOF_SOURCE_URL:-")}" ]; then
1098
+ say "- Reused proof: [exact source check](${shellExpansion("PROOF_SOURCE_URL")})"
1099
+ fi
1100
+ say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
1101
+ say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
1102
+ echo "::notice title=Factory proof reuse::Reused trusted local proof for ${label} at ${shellExpansion("PROOF_HEAD_SHA:-unknown")}."
1103
+ else
1104
+ say "- Path: full GitHub CI fallback (${shellExpansion("PROOF_REASON:-error")})"
1105
+ say "- Bound head: \`${shellExpansion("PROOF_HEAD_SHA:-unknown")}\`"
1106
+ say "- Factory verification mode: ${shellExpansion("PROOF_MODE:-unavailable")} (diagnostic only)"
1107
+ echo "::notice title=Factory proof reuse::No reusable proof for ${label}; the full hosted suite executed (${shellExpansion("PROOF_REASON:-error")})."
1108
+ fi
1109
+
1110
+ if [[ "$ci_started_ms" =~ ^[0-9]+$ ]]; then
1111
+ say "- GitHub workflow execution after the proof gate: $((now_ms - ci_started_ms)) ms"
1112
+ else
1113
+ say '- GitHub workflow execution after the proof gate: unavailable'
1114
+ fi
1115
+ say ''`;
1116
+ };
1117
+ const factoryProofReuseSummaryStep = (options) => Object.freeze({
1118
+ continueOnError: true,
1119
+ env: Object.freeze({
1120
+ CI_STARTED_MS: githubExpression(`steps.${FACTORY_PROOF_TIMING_STEP_ID}.outputs.started_ms`),
1121
+ PROOF_HEAD_SHA: githubExpression("github.event.pull_request.head.sha"),
1122
+ PROOF_MODE: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_MODE_OUTPUT}`),
1123
+ PROOF_REASON: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_REASON_OUTPUT}`),
1124
+ PROOF_REUSED: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT}`),
1125
+ PROOF_SOURCE_URL: githubExpression(`steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT}`)
1126
+ }),
1127
+ if: "always()",
1128
+ name: FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME,
1129
+ run: factoryProofReuseSummaryScript(options)
1130
+ });
1131
+ //#endregion
1132
+ //#region src/production-impact-workflow.ts
1133
+ const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
1134
+ const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
1135
+ const FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT = "basis";
1136
+ const FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT = "unsubscribed_paths";
1137
+ const OUTPUT_NAME_PATTERN = /[^a-z0-9_]+/gu;
1138
+ const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
1139
+ /** Stable GitHub-output key for one declared target. */
1140
+ const productionImpactTargetOutput = (targetName) => {
1141
+ const normalized = targetName.toLowerCase().replaceAll(OUTPUT_NAME_PATTERN, "_").replaceAll(/^_+|_+$/gu, "");
1142
+ if (normalized.length === 0) throw new Error(`Impact target name "${targetName}" has no output-safe characters.`);
1143
+ return `target_${normalized}`;
1144
+ };
1145
+ /**
1146
+ * Generate the small factory-owned decision seam for a consumer production
1147
+ * workflow. Consumers retain jobs, deploy commands, credentials, topology,
1148
+ * and convergence checks; this artifact supplies only the decision step and
1149
+ * per-target withdrawal conditions.
1150
+ */
1151
+ const factoryProductionImpactWorkflow = (options) => {
1152
+ const targetOutputs = Object.fromEntries(options.targets.map((target) => [target, productionImpactTargetOutput(target)]));
1153
+ if (new Set(Object.values(targetOutputs)).size !== options.targets.length) throw new Error("Impact target names must map to distinct GitHub output keys.");
1154
+ const before = options.before ?? `\${{ github.event.before }}`;
1155
+ const after = options.after ?? `\${{ github.sha }}`;
1156
+ const cli = options.cli ?? "pnpm exec psf";
1157
+ const profile = options.profilePath ? ` --profile ${shellQuote(options.profilePath)}` : "";
1158
+ const decisionStep = {
1159
+ continueOnError: true,
1160
+ env: {
1161
+ FACTORY_AFTER_SHA: after,
1162
+ FACTORY_BEFORE_SHA: before
1163
+ },
1164
+ id: FACTORY_PRODUCTION_IMPACT_STEP_ID,
1165
+ name: "Classify production impact",
1166
+ run: `${cli} production:impact --before "$FACTORY_BEFORE_SHA" --after "$FACTORY_AFTER_SHA" --github-output "$GITHUB_OUTPUT" --github-summary "$GITHUB_STEP_SUMMARY"${profile}`
1167
+ };
1168
+ return Object.freeze({
1169
+ decisionJobOutputs: Object.freeze({
1170
+ basis: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT} }}`,
1171
+ decision: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} }}`,
1172
+ unsubscribed_paths: `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT} }}`,
1173
+ ...Object.fromEntries(Object.values(targetOutputs).map((output) => [output, `\${{ steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}.outputs.${output} }}`]))
1174
+ }),
1175
+ decisionStep: Object.freeze(decisionStep),
1176
+ demandedIf: (targetName, decisionJob) => {
1177
+ const output = targetOutputs[targetName];
1178
+ if (output === void 0) throw new Error(`Unknown production impact target "${targetName}".`);
1179
+ const source = decisionJob ? `needs.${decisionJob}` : `steps.${FACTORY_PRODUCTION_IMPACT_STEP_ID}`;
1180
+ const condition = `${source}.${decisionJob ? "result" : "outcome"} != 'success' || ${source}.outputs.${FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT} != 'usable' || ${source}.outputs.${output} != 'withdrawn'`;
1181
+ return decisionJob ? `always() && (${condition})` : condition;
1182
+ },
1183
+ targetOutputs: Object.freeze(targetOutputs)
1184
+ });
1185
+ };
1186
+ //#endregion
942
1187
  //#region src/vitest-profile.ts
943
1188
  /**
944
1189
  * Vitest suite profiling — the measurement mechanics behind a CI runner
@@ -1261,6 +1506,120 @@ const runVitestProfile = async (options, dependencies = {}) => {
1261
1506
  return profile;
1262
1507
  };
1263
1508
  //#endregion
1509
+ //#region src/vitest-profile-reader.ts
1510
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1511
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
1512
+ const durationSummaryFailure = (value, where) => {
1513
+ if (!isRecord(value)) return `${where} is not an object`;
1514
+ const missing = [
1515
+ "maximum",
1516
+ "mean",
1517
+ "median",
1518
+ "minimum"
1519
+ ].find((field) => !isFiniteNumber(value[field]));
1520
+ return missing === void 0 ? void 0 : `${where}.${missing} is not a finite number`;
1521
+ };
1522
+ const environmentFailure = (value) => {
1523
+ if (!isRecord(value)) return "environment is not an object";
1524
+ if (!(typeof value.cpuModel === "string" || value.cpuModel === null)) return "environment.cpuModel is neither a string nor null";
1525
+ if (!isFiniteNumber(value.cpuCount)) return "environment.cpuCount is not a finite number";
1526
+ };
1527
+ /** The count fields the writer emits; every one must be a finite number. */
1528
+ const COUNT_FIELDS = [
1529
+ "failed",
1530
+ "passed",
1531
+ "pending",
1532
+ "suites",
1533
+ "tests",
1534
+ "todo"
1535
+ ];
1536
+ const runFailure = (value, index) => {
1537
+ if (!isRecord(value)) return `runs[${index}] is not an object`;
1538
+ if (!isFiniteNumber(value.durationMs)) return `runs[${index}].durationMs is not a finite number`;
1539
+ if (value.counts === null) return;
1540
+ if (!isRecord(value.counts)) return `runs[${index}].counts is neither an object nor null`;
1541
+ const { counts } = value;
1542
+ const missing = COUNT_FIELDS.find((field) => !isFiniteNumber(counts[field]));
1543
+ return missing === void 0 ? void 0 : `runs[${index}].counts.${missing} is not a finite number`;
1544
+ };
1545
+ const slowEntryFailure = (value, where, labelField) => {
1546
+ if (!isRecord(value)) return `${where} is not an object`;
1547
+ if (typeof value[labelField] !== "string") return `${where}.${labelField} is not a string`;
1548
+ if (labelField === "name" && typeof value.file !== "string") return `${where}.file is not a string`;
1549
+ return durationSummaryFailure(value.durationMs, `${where}.durationMs`);
1550
+ };
1551
+ const summaryFailure = (value) => {
1552
+ if (!isRecord(value)) return "summary is not an object";
1553
+ const durations = durationSummaryFailure(value.durationMs, "summary.durationMs");
1554
+ if (durations !== void 0) return durations;
1555
+ if (!Array.isArray(value.slowFiles)) return "summary.slowFiles is not an array";
1556
+ for (const [index, entry] of value.slowFiles.entries()) {
1557
+ const failure = slowEntryFailure(entry, `summary.slowFiles[${index}]`, "path");
1558
+ if (failure !== void 0) return failure;
1559
+ }
1560
+ if (!Array.isArray(value.slowTests)) return "summary.slowTests is not an array";
1561
+ for (const [index, entry] of value.slowTests.entries()) {
1562
+ const failure = slowEntryFailure(entry, `summary.slowTests[${index}]`, "name");
1563
+ if (failure !== void 0) return failure;
1564
+ }
1565
+ };
1566
+ /**
1567
+ * Check one parsed JSON document against the profile contract the writer
1568
+ * emits. Returns the typed profile on success and the first mismatch reason
1569
+ * otherwise — never a partially-usable value.
1570
+ */
1571
+ const readVitestProfileDocument = (value) => {
1572
+ if (!isRecord(value)) return {
1573
+ kind: "unrecognized",
1574
+ reason: "the document is not an object"
1575
+ };
1576
+ if (value.tool !== "factory-ci-vitest-profile") return {
1577
+ kind: "unrecognized",
1578
+ reason: `tool is ${JSON.stringify(value.tool)} rather than "${VITEST_PROFILE_TOOL}"`
1579
+ };
1580
+ if (value.schemaVersion !== 1) return {
1581
+ kind: "unrecognized",
1582
+ reason: `schemaVersion is ${JSON.stringify(value.schemaVersion)} rather than 1`
1583
+ };
1584
+ if (!(Array.isArray(value.command) && value.command.every((part) => typeof part === "string"))) return {
1585
+ kind: "unrecognized",
1586
+ reason: "command is not an array of strings"
1587
+ };
1588
+ const environment = environmentFailure(value.environment);
1589
+ if (environment !== void 0) return {
1590
+ kind: "unrecognized",
1591
+ reason: environment
1592
+ };
1593
+ if (!isRecord(value.options)) return {
1594
+ kind: "unrecognized",
1595
+ reason: "options is not an object"
1596
+ };
1597
+ if (!isFiniteNumber(value.options.maxWorkers)) return {
1598
+ kind: "unrecognized",
1599
+ reason: "options.maxWorkers is not a finite number"
1600
+ };
1601
+ if (!Array.isArray(value.runs)) return {
1602
+ kind: "unrecognized",
1603
+ reason: "runs is not an array"
1604
+ };
1605
+ for (const [index, run] of value.runs.entries()) {
1606
+ const failure = runFailure(run, index);
1607
+ if (failure !== void 0) return {
1608
+ kind: "unrecognized",
1609
+ reason: failure
1610
+ };
1611
+ }
1612
+ const summary = summaryFailure(value.summary);
1613
+ if (summary !== void 0) return {
1614
+ kind: "unrecognized",
1615
+ reason: summary
1616
+ };
1617
+ return {
1618
+ kind: "profile",
1619
+ profile: value
1620
+ };
1621
+ };
1622
+ //#endregion
1264
1623
  //#region src/workflow-shell-lint.ts
1265
1624
  const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
1266
1625
  /**
@@ -1525,4 +1884,4 @@ const assertWorkflowShellParses = (yaml, options) => {
1525
1884
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
1526
1885
  };
1527
1886
  //#endregion
1528
- export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
1887
+ export { FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_PRODUCTION_IMPACT_BASIS_OUTPUT, FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT, FACTORY_PRODUCTION_IMPACT_STEP_ID, FACTORY_PRODUCTION_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, FACTORY_PROOF_TIMING_START_STEP_NAME, FACTORY_PROOF_TIMING_STEP_ID, FACTORY_PROOF_TIMING_SUMMARY_STEP_NAME, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateOrPushCondition, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryScript, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/factory-ci",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pull-request events that can create or refresh a Factory candidate run.
3
+ *
4
+ * GitHub's draft boolean remains the lifecycle authority: these triggers make
5
+ * a run visible, while `factoryCandidateOrPushCondition` keeps substantive
6
+ * jobs idle until the pull request is a candidate.
7
+ */
8
+ export const FACTORY_CANDIDATE_PULL_REQUEST_TYPES = Object.freeze([
9
+ "opened",
10
+ "synchronize",
11
+ "reopened",
12
+ "ready_for_review",
13
+ ] as const);
14
+
15
+ /**
16
+ * Build a GitHub Actions job condition for candidate PRs and merge-target
17
+ * pushes. The caller owns triggers, jobs, runners, permissions, and topology.
18
+ */
19
+ export const factoryCandidateOrPushCondition = (
20
+ candidateCondition?: string
21
+ ): string => {
22
+ const candidate = [
23
+ "github.event_name == 'pull_request'",
24
+ "github.event.pull_request.draft != true",
25
+ ...(candidateCondition ? [candidateCondition] : []),
26
+ ].join(" && ");
27
+
28
+ return ["github.event_name == 'push'", `(${candidate})`].join(" || ");
29
+ };