@patronage/factory-ci 1.0.0-alpha.21 → 1.0.0-alpha.22

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
@@ -519,6 +519,22 @@ const FACTORY_PROOF_GATE_GUARD = `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${
519
519
  */
520
520
  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))";
521
521
  /**
522
+ * Pull requests only — the condition a consumer gets with
523
+ * `reuse: "pull-request"` (#873).
524
+ *
525
+ * A repository may want its default-branch pushes to always execute the full
526
+ * hosted suite, whatever proof exists: the merged commit is what the fleet
527
+ * deploys, so a periodic unconditional run of every command is a deliberate
528
+ * cost some consumers choose to pay. This condition is that choice, expressed
529
+ * once here rather than as a consumer-written override string. The mode also
530
+ * skips the push-event merge fallback, so no push path can reuse proof even if
531
+ * the workflow reaches the step through some other trigger.
532
+ *
533
+ * Which branches trigger the workflow at all stays repository-owned. This
534
+ * condition only keeps the gate from consulting proof outside a pull request.
535
+ */
536
+ const FACTORY_PROOF_GATE_PULL_REQUEST_IF = "github.event_name == 'pull_request'";
537
+ /**
522
538
  * The complete refusal vocabulary. Deliberately few, because these are the
523
539
  * only distinctions the gate can honestly make from its Checks API reads.
524
540
  * The merge fallback adds no words: a fallback that establishes nothing
@@ -814,7 +830,92 @@ const safeLabel$1 = (surface) => {
814
830
  const cleaned = (typeof surface === "string" ? surface : "").replaceAll(/[^\w -]/gu, "").trim().slice(0, 60);
815
831
  return cleaned.length > 0 ? cleaned : "verification";
816
832
  };
817
- const gateScript = (required, surface) => String.raw`
833
+ /**
834
+ * The push-event merge fallback (#611), emitted only for the default reuse
835
+ * mode. `reuse: "pull-request"` omits it: that mode's whole point is that a
836
+ * default-branch push executes everything, so a push path that could still
837
+ * reuse proof would contradict the condition above it.
838
+ */
839
+ const MERGE_FALLBACK_BLOCK = String.raw`# Merge fallback (#611). A squash merge mints a new commit, so the direct
840
+ # read at a pushed merge-target head finds nothing even when the factory
841
+ # proved the producing pull request head. Only when the direct read found no
842
+ # generation at all on a push event, look up the producing pull request and
843
+ # reuse its head proof — and only when the merge commit's TREE id equals the
844
+ # proven head's tree id, which makes the pushed content byte-identical to
845
+ # what was verified (a clean squash of an unchanged tip). Patch identity was
846
+ # considered and rejected as the comparator: git patch-id normalizes
847
+ # whitespace and ignores base motion, so an identical patch can still
848
+ # produce an integrated tree that was never tested. Everything else — no
849
+ # unique merged producing PR, an unreadable commit, a proof that is anything
850
+ # but proven, or tree drift from a dirty or stale merge — leaves the direct
851
+ # "none" refusal standing, so the full suite runs (fail open).
852
+ if [ "$reason" = 'none' ] && [ "${shellExpansion$1("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
853
+ producing_head=''
854
+ merge_tree=''
855
+ head_tree=''
856
+ if ! producing_pulls=$(gh api --method GET --paginate \
857
+ "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/pulls" \
858
+ -f per_page=100 2>&1); then
859
+ detail="producing pull request unreadable: $producing_pulls"
860
+ elif ! producing_head=$(printf '%s' "$producing_pulls" | jq -r -s \
861
+ --arg sha "$HEAD_SHA" \
862
+ '[ add[]?
863
+ | select((.merged_at // null) != null)
864
+ | select((.merge_commit_sha // "") == $sha)
865
+ | ((.head.sha // "") | tostring) ]
866
+ | if length == 1 then .[0] else "" end' 2>&1); then
867
+ detail="producing pull request unreadable: $producing_head"
868
+ producing_head=''
869
+ elif ! [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]]; then
870
+ detail='no single merged producing pull request at this commit'
871
+ producing_head=''
872
+ elif ! merge_commit=$(gh api --method GET \
873
+ "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA" 2>&1); then
874
+ detail="merge commit unreadable: $merge_commit"
875
+ elif ! merge_tree=$(printf '%s' "$merge_commit" | jq -r \
876
+ '(.commit.tree.sha // "") | tostring' 2>&1); then
877
+ detail="merge commit unreadable: $merge_tree"
878
+ merge_tree=''
879
+ elif ! [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]]; then
880
+ detail='merge commit carries no readable tree id'
881
+ merge_tree=''
882
+ elif ! head_commit=$(gh api --method GET \
883
+ "repos/$GITHUB_REPOSITORY/commits/$producing_head" 2>&1); then
884
+ detail="producing head commit unreadable: $head_commit"
885
+ elif ! head_tree=$(printf '%s' "$head_commit" | jq -r \
886
+ '(.commit.tree.sha // "") | tostring' 2>&1); then
887
+ detail="producing head commit unreadable: $head_tree"
888
+ head_tree=''
889
+ elif ! [[ "$head_tree" =~ ^[0-9a-f]{40}$ ]]; then
890
+ detail='producing head commit carries no readable tree id'
891
+ head_tree=''
892
+ elif [ "$merge_tree" != "$head_tree" ]; then
893
+ detail='tree drift: the merge result is not the proven head tree'
894
+ merge_tree=''
895
+ fi
896
+ if [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]] \
897
+ && [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]] \
898
+ && [ "$merge_tree" = "$head_tree" ]; then
899
+ gate_lookup "$producing_head"
900
+ if [ "$lookup_reason" = 'proven' ]; then
901
+ reason=proven
902
+ mode="$lookup_mode"
903
+ missing=''
904
+ source_url="$lookup_url"
905
+ proof_head="$producing_head"
906
+ merge_note="the merge result's tree is byte-identical to the tree proven at pull request head $producing_head"
907
+ else
908
+ detail="producing pull request proof not reusable ($lookup_reason)"
909
+ fi
910
+ fi
911
+ fi`;
912
+ /**
913
+ * The fallback with the blank lines that surround it, or nothing at all. Kept
914
+ * as one piece so the default mode emits the exact script it emitted before
915
+ * this option existed.
916
+ */
917
+ const mergeFallbackSection = (reuse) => reuse === "pull-request" ? "\n" : `\n${MERGE_FALLBACK_BLOCK}\n\n`;
918
+ const gateScript = (required, surface, reuse) => String.raw`
818
919
  set -uo pipefail
819
920
 
820
921
  CHECK_NAME=${shellSingleQuote(FACTORY_PROOF_GATE_CHECK_NAME)}
@@ -901,82 +1002,7 @@ else
901
1002
  missing="$lookup_missing"
902
1003
  source_url="$lookup_url"
903
1004
  fi
904
-
905
- # Merge fallback (#611). A squash merge mints a new commit, so the direct
906
- # read at a pushed merge-target head finds nothing even when the factory
907
- # proved the producing pull request head. Only when the direct read found no
908
- # generation at all on a push event, look up the producing pull request and
909
- # reuse its head proof — and only when the merge commit's TREE id equals the
910
- # proven head's tree id, which makes the pushed content byte-identical to
911
- # what was verified (a clean squash of an unchanged tip). Patch identity was
912
- # considered and rejected as the comparator: git patch-id normalizes
913
- # whitespace and ignores base motion, so an identical patch can still
914
- # produce an integrated tree that was never tested. Everything else — no
915
- # unique merged producing PR, an unreadable commit, a proof that is anything
916
- # but proven, or tree drift from a dirty or stale merge — leaves the direct
917
- # "none" refusal standing, so the full suite runs (fail open).
918
- if [ "$reason" = 'none' ] && [ "${shellExpansion$1("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
919
- producing_head=''
920
- merge_tree=''
921
- head_tree=''
922
- if ! producing_pulls=$(gh api --method GET --paginate \
923
- "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/pulls" \
924
- -f per_page=100 2>&1); then
925
- detail="producing pull request unreadable: $producing_pulls"
926
- elif ! producing_head=$(printf '%s' "$producing_pulls" | jq -r -s \
927
- --arg sha "$HEAD_SHA" \
928
- '[ add[]?
929
- | select((.merged_at // null) != null)
930
- | select((.merge_commit_sha // "") == $sha)
931
- | ((.head.sha // "") | tostring) ]
932
- | if length == 1 then .[0] else "" end' 2>&1); then
933
- detail="producing pull request unreadable: $producing_head"
934
- producing_head=''
935
- elif ! [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]]; then
936
- detail='no single merged producing pull request at this commit'
937
- producing_head=''
938
- elif ! merge_commit=$(gh api --method GET \
939
- "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA" 2>&1); then
940
- detail="merge commit unreadable: $merge_commit"
941
- elif ! merge_tree=$(printf '%s' "$merge_commit" | jq -r \
942
- '(.commit.tree.sha // "") | tostring' 2>&1); then
943
- detail="merge commit unreadable: $merge_tree"
944
- merge_tree=''
945
- elif ! [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]]; then
946
- detail='merge commit carries no readable tree id'
947
- merge_tree=''
948
- elif ! head_commit=$(gh api --method GET \
949
- "repos/$GITHUB_REPOSITORY/commits/$producing_head" 2>&1); then
950
- detail="producing head commit unreadable: $head_commit"
951
- elif ! head_tree=$(printf '%s' "$head_commit" | jq -r \
952
- '(.commit.tree.sha // "") | tostring' 2>&1); then
953
- detail="producing head commit unreadable: $head_tree"
954
- head_tree=''
955
- elif ! [[ "$head_tree" =~ ^[0-9a-f]{40}$ ]]; then
956
- detail='producing head commit carries no readable tree id'
957
- head_tree=''
958
- elif [ "$merge_tree" != "$head_tree" ]; then
959
- detail='tree drift: the merge result is not the proven head tree'
960
- merge_tree=''
961
- fi
962
- if [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]] \
963
- && [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]] \
964
- && [ "$merge_tree" = "$head_tree" ]; then
965
- gate_lookup "$producing_head"
966
- if [ "$lookup_reason" = 'proven' ]; then
967
- reason=proven
968
- mode="$lookup_mode"
969
- missing=''
970
- source_url="$lookup_url"
971
- proof_head="$producing_head"
972
- merge_note="the merge result's tree is byte-identical to the tree proven at pull request head $producing_head"
973
- else
974
- detail="producing pull request proof not reusable ($lookup_reason)"
975
- fi
976
- fi
977
- fi
978
-
979
- # The binding is App-verified, but it still reaches markdown. Only a plain
1005
+ ${mergeFallbackSection(reuse)}# The binding is App-verified, but it still reaches markdown. Only a plain
980
1006
  # lowercase token is quoted back; anything else is reported as unknown.
981
1007
  case "$mode" in
982
1008
  '') ;;
@@ -1099,9 +1125,9 @@ fi
1099
1125
  * `FACTORY_PROOF_GATE_SHELL` — so the script is also written to reach that
1100
1126
  * write under errexit, and the tests execute it both ways.
1101
1127
  */
1102
- const factoryProofGateScript = ({ commands, surface }) => {
1128
+ const factoryProofGateScript = ({ commands, reuse = "pull-request-and-default-branch", surface }) => {
1103
1129
  const required = proofReuseRequiredCommands(commands);
1104
- return required ? gateScript(required, safeLabel$1(surface)) : UNUSABLE_SELECTION_SCRIPT;
1130
+ return required ? gateScript(required, safeLabel$1(surface), reuse) : UNUSABLE_SELECTION_SCRIPT;
1105
1131
  };
1106
1132
  /**
1107
1133
  * The step itself, structurally accepted by gagen's `step()` without adding a
@@ -1111,7 +1137,9 @@ const factoryProofGateScript = ({ commands, surface }) => {
1111
1137
  * `checks: read`; the push-event merge fallback additionally reads the
1112
1138
  * producing pull request (`pull-requests: read`) and the two commit objects
1113
1139
  * whose tree ids it compares (`contents: read`). A job that grants less
1114
- * loses only the fallback — the failed read degrades to the full suite.
1140
+ * loses only the fallback — the failed read degrades to the full suite. A
1141
+ * consumer that passes `reuse: "pull-request"` emits neither the push clause
1142
+ * nor the fallback, and needs only `checks: read` (#873).
1115
1143
  *
1116
1144
  * A **step, not a job**, and that is not a style preference. A separate gate
1117
1145
  * job that errored would leave the guarded job `skipped`, and a summary job
@@ -1127,7 +1155,7 @@ const factoryProofGateStep = (options) => Object.freeze({
1127
1155
  HEAD_SHA: githubExpression$1("github.event.pull_request.head.sha || github.sha")
1128
1156
  }),
1129
1157
  id: FACTORY_PROOF_GATE_STEP_ID,
1130
- if: FACTORY_PROOF_GATE_IF,
1158
+ if: options.reuse === "pull-request" ? FACTORY_PROOF_GATE_PULL_REQUEST_IF : FACTORY_PROOF_GATE_IF,
1131
1159
  name: FACTORY_PROOF_GATE_STEP_NAME,
1132
1160
  run: factoryProofGateScript(options),
1133
1161
  shell: FACTORY_PROOF_GATE_SHELL
@@ -1185,6 +1213,20 @@ const assertProofReuseCoverage = (input) => {
1185
1213
  */
1186
1214
  /** Check-run name that carries one registration JSON payload. Not a gate. */
1187
1215
  const PREVIEW_PROOF_INVENTORY_CHECK_NAME = "patronage-factory/preview-proof";
1216
+ /**
1217
+ * Least privilege for a job whose `GITHUB_TOKEN` calls `list`. A
1218
+ * `permissions` block zeroes every unlisted scope, and `list` reads five
1219
+ * endpoints: `pulls/{pr}`, `pulls/{pr}/commits`, and the GraphQL force-push
1220
+ * timeline (`pull-requests`), `compare` (`contents`), and
1221
+ * `commits/{sha}/check-runs` (`checks`). A job
1222
+ * that omits `pull-requests: read` fails with 403 on a same-repository PR
1223
+ * and skips every destroy matrix (#859). Give the discovery job this object.
1224
+ */
1225
+ const PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS = Object.freeze({
1226
+ checks: "read",
1227
+ contents: "read",
1228
+ "pull-requests": "read"
1229
+ });
1188
1230
  const INVENTORY_KIND = "patronage-factory-preview-proof";
1189
1231
  const INVENTORY_SCHEMA_VERSION = 1;
1190
1232
  const DEFAULT_TIMEOUT_MS = 5e3;
@@ -1311,6 +1353,7 @@ const assertPersistableRegistration = (registration) => {
1311
1353
  const registrationKey = (row) => `${row.stack}:${row.stage}`;
1312
1354
  const replaceRegistration = (rows, next) => [...rows.filter((row) => registrationKey(row) !== registrationKey(next)), next];
1313
1355
  const memoryKey = (owner, repo, pr) => `${owner}/${repo}#${pr}`;
1356
+ /** Reached by callers as `previewProofInventory.memoryStore`. */
1314
1357
  const memoryStore = () => {
1315
1358
  const byPr = /* @__PURE__ */ new Map();
1316
1359
  return {
@@ -1601,6 +1644,7 @@ const takeNewestRegistrations = (runsBySha, pr) => {
1601
1644
  }
1602
1645
  return [...byKey.values()];
1603
1646
  };
1647
+ /** Reached by callers as `previewProofInventory.githubStore`. */
1604
1648
  const githubStore = (transport) => {
1605
1649
  const request = transport.fetch ?? fetch;
1606
1650
  const timeoutMs = transport.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -1695,6 +1739,7 @@ const recordCleanup = async (input, access) => {
1695
1739
  const previewProofInventory = {
1696
1740
  githubStore,
1697
1741
  list,
1742
+ listPermissions: PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS,
1698
1743
  memoryStore,
1699
1744
  persist,
1700
1745
  recordCleanup
@@ -2106,6 +2151,173 @@ const factoryLifecycleContractLane = (declaration) => {
2106
2151
  });
2107
2152
  };
2108
2153
  //#endregion
2154
+ //#region src/merge-freeze-job.ts
2155
+ /**
2156
+ * The merge-freeze writer job (#356, ADR 0016 as amended; #429 for the
2157
+ * merge-target namespace; #872 for this package).
2158
+ *
2159
+ * A consumer's generated merge-target-push Verify workflow is the ONLY
2160
+ * producer of `patronage-factory/merge-freeze` generations: a red
2161
+ * push-triggered Verify completes a generation active, a green one completes
2162
+ * it inactive, and no command, scheduled job, or second workflow writes this
2163
+ * check run. The operator override (`demand:waive --demand merge-freeze`)
2164
+ * waives the demand for one candidate during a fix-forward; it never writes
2165
+ * here.
2166
+ *
2167
+ * The write mirrors what the factory package's merge-freeze reader parses:
2168
+ * create the run `in_progress` with no `started_at` (GitHub stamps the
2169
+ * generation clock — the ordering rule itself is owned by the reader), then
2170
+ * PATCH it completed with the reader-shaped state payload in `output.text`.
2171
+ * The consumer's workflow test executes this exact script against a stubbed
2172
+ * `gh` and parses the emitted payload with `validateMergeFreezeState`, so
2173
+ * writer and reader cannot drift silently.
2174
+ *
2175
+ * Failure posture is deliberately fail-closed and the opposite of the
2176
+ * proof-reuse gate's fail-open shell: this step runs under the runner's
2177
+ * default `bash -e {0}`, so any API refusal aborts the step, the generation
2178
+ * stays absent or `in_progress`, and every `pr:ready` arming-time read of
2179
+ * that tip (#477) refuses until a later push writes a green generation.
2180
+ *
2181
+ * **Ownership line.** This package owns the check name, the App identity, the
2182
+ * `create-github-app-token` inputs, the result fold, the merge-target `if`,
2183
+ * and the job's `permissions`. The caller owns the runner, the `needs` list
2184
+ * of every authoritative verification leaf, the timeout, and any extra env.
2185
+ */
2186
+ /** The one check-run name every merge-freeze reader pins. */
2187
+ const FACTORY_MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
2188
+ const FACTORY_MERGE_FREEZE_JOB_ID = "freeze";
2189
+ const FACTORY_MERGE_FREEZE_JOB_NAME = "Report the merge freeze";
2190
+ /** Step id the token step exposes its installation token under. */
2191
+ const FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID = "factory-app-token";
2192
+ /**
2193
+ * The GitHub Actions expression that folds the needed jobs' results into the
2194
+ * one word the script branches on. It mirrors the `verify` summary job's
2195
+ * failure condition exactly: any failed or cancelled needed job is a red
2196
+ * merge target.
2197
+ */
2198
+ const FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION = `\${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }}`;
2199
+ /**
2200
+ * The literal factory merge-target namespace, as a `github.ref` predicate
2201
+ * (#429).
2202
+ *
2203
+ * `main` plus the repository-owned `epic/**` integration branches, and
2204
+ * nothing else. Deliberately literal rather than profile-declared: one
2205
+ * implementation per contract, no configuration machinery for a two-element
2206
+ * namespace whose second element is a namespace prefix the repository itself
2207
+ * owns. `epic/` needs the trailing slash — `startsWith` would otherwise admit
2208
+ * `epicness/…`, which is not a merge target.
2209
+ *
2210
+ * A caller's `push` trigger branch list names the same namespace. That list
2211
+ * decides which events produce a run at all; this predicate decides which of
2212
+ * those runs may write a generation.
2213
+ */
2214
+ const FACTORY_MERGE_TARGET_REF_CONDITION = ["github.ref == 'refs/heads/main'", "startsWith(github.ref, 'refs/heads/epic/')"].join(" || ");
2215
+ /**
2216
+ * `always()` is load-bearing: the red path is the whole point, so the job must
2217
+ * run when a needed verification job failed. `github.event_name == 'push'` is
2218
+ * load-bearing twice over: a `pull_request` run must never write a generation
2219
+ * (its `github.sha` is a synthetic merge commit that no base tip is ever read
2220
+ * at), and a `pull_request` trigger with an `epic/**` base list would
2221
+ * otherwise reach this job.
2222
+ */
2223
+ const FACTORY_MERGE_FREEZE_IF = `always() && github.event_name == 'push' && (${FACTORY_MERGE_TARGET_REF_CONDITION})`;
2224
+ /**
2225
+ * Least privilege for the writer job: no `GITHUB_TOKEN` scope at all.
2226
+ *
2227
+ * Both Checks API calls run under the App installation token minted in the
2228
+ * first step (`GH_TOKEN: steps.factory-app-token.outputs.token`), and the job
2229
+ * never checks out the repository. So the job uses no `GITHUB_TOKEN` scope,
2230
+ * and an empty block is the honest declaration. A `checks: write` here would
2231
+ * hand every adopter an unused write scope (#884).
2232
+ */
2233
+ const FACTORY_MERGE_FREEZE_PERMISSIONS = Object.freeze({});
2234
+ /**
2235
+ * The script the freeze step runs. Environment contract:
2236
+ * - `GH_TOKEN`: an installation token for the pinned Patronage Factory App —
2237
+ * the check run must carry that App's identity or every reader rejects it.
2238
+ * - `VERIFY_RESULT`: `success` or `failure` (see the fold expression above).
2239
+ * - `GITHUB_SHA` / `GITHUB_REPOSITORY` / `GITHUB_SERVER_URL` /
2240
+ * `GITHUB_RUN_ID` / `GITHUB_REF_NAME`: runner-provided.
2241
+ *
2242
+ * `GITHUB_REF_NAME` is the merge target this run pushed to — `main` or an
2243
+ * `epic/**` integration branch (#429). The job is gated to `push` events on
2244
+ * exactly those refs, so the branch name is always the short name of a
2245
+ * repository-owned merge target and never a pull-request merge ref. It
2246
+ * appears in both recovery sentences because a reader of an epic-targeting
2247
+ * candidate must be told which branch to fix forward on: the generation lives
2248
+ * on that branch's tip, and only a later green push to THAT branch clears it.
2249
+ */
2250
+ const factoryMergeFreezeScript = () => [
2251
+ `sha="$GITHUB_SHA"`,
2252
+ `branch="$GITHUB_REF_NAME"`,
2253
+ `repo="$GITHUB_REPOSITORY"`,
2254
+ `run_url="$GITHUB_SERVER_URL/$repo/actions/runs/$GITHUB_RUN_ID"`,
2255
+ `created=$(gh api --method POST "repos/$repo/check-runs" \\`,
2256
+ ` -f "name=${FACTORY_MERGE_FREEZE_CHECK_NAME}" \\`,
2257
+ ` -f "head_sha=$sha" \\`,
2258
+ ` -f "status=in_progress" \\`,
2259
+ ` -f "details_url=$run_url" \\`,
2260
+ ` -f "output[title]=merge freeze generation in progress" \\`,
2261
+ ` -f "output[summary]=This generation blocks factory merge handoffs until the merge-target Verify result is recorded.")`,
2262
+ `id=$(jq -r '.id' <<<"$created")`,
2263
+ `case "$id" in`,
2264
+ ` ''|*[!0-9]*) echo "unusable check-run id: $id" >&2; exit 1;;`,
2265
+ `esac`,
2266
+ `if [ "$VERIFY_RESULT" = 'success' ]; then`,
2267
+ ` active=false; outcome=inactive; conclusion=success`,
2268
+ ` reason="Merge-target Verify passed on $branch at $sha ($run_url)."`,
2269
+ `else`,
2270
+ ` active=true; outcome=active; conclusion=failure`,
2271
+ ` reason="Merge-target Verify failed on $branch at $sha; fix forward or revert on $branch and let the next green Verify push on $branch clear this freeze ($run_url)."`,
2272
+ `fi`,
2273
+ `state=$(jq -cn --argjson active "$active" --argjson id "$id" --arg headSha "$sha" --arg outcome "$outcome" --arg reason "$reason" \\`,
2274
+ ` '{active: $active, generationId: $id, headSha: $headSha, outcome: $outcome, reason: $reason, recordedAt: (now | todate), schemaVersion: 1}')`,
2275
+ `gh api --method PATCH "repos/$repo/check-runs/$id" \\`,
2276
+ ` -f "status=completed" \\`,
2277
+ ` -f "conclusion=$conclusion" \\`,
2278
+ ` -f "details_url=$run_url" \\`,
2279
+ ` -f "output[title]=merge freeze $outcome" \\`,
2280
+ ` -f "output[summary]=$reason" \\`,
2281
+ ` -f "output[text]=$state" >/dev/null`,
2282
+ `echo "merge-freeze generation $id completed $outcome on $branch at $sha"`
2283
+ ].join("\n");
2284
+ /**
2285
+ * Build the merge-freeze writer job.
2286
+ *
2287
+ * An empty `needs` list is refused: a fold over no verification leaf is
2288
+ * always `success`, so such a job would write a green generation for a merge
2289
+ * target nothing verified — the exact failure the freeze exists to prevent.
2290
+ */
2291
+ const factoryMergeFreezeJob = (options) => {
2292
+ assertPinnedAction("createGithubAppToken", options.createGithubAppToken, "actions/create-github-app-token");
2293
+ if (options.needs.length === 0) throw new Error("factoryMergeFreezeJob needs at least one verification job: a fold over no needed job always reports success, so the freeze would report a merge target nothing verified as green.");
2294
+ const mintStep = Object.freeze({
2295
+ id: FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID,
2296
+ name: "Mint the factory App token",
2297
+ uses: options.createGithubAppToken.uses,
2298
+ with: Object.freeze({
2299
+ "client-id": FACTORY_PROOF_GATE_APP_ID,
2300
+ "private-key": `\${{ secrets.FACTORY_GITHUB_APP_PRIVATE_KEY }}`
2301
+ })
2302
+ });
2303
+ const reportStep = Object.freeze({
2304
+ env: Object.freeze({
2305
+ GH_TOKEN: `\${{ steps.${FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID}.outputs.token }}`,
2306
+ VERIFY_RESULT: options.verifyResultExpression ?? FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION
2307
+ }),
2308
+ name: "Complete the merge-freeze generation",
2309
+ run: factoryMergeFreezeScript()
2310
+ });
2311
+ return Object.freeze({
2312
+ if: FACTORY_MERGE_FREEZE_IF,
2313
+ jobId: FACTORY_MERGE_FREEZE_JOB_ID,
2314
+ jobName: FACTORY_MERGE_FREEZE_JOB_NAME,
2315
+ needs: Object.freeze([...options.needs]),
2316
+ permissions: FACTORY_MERGE_FREEZE_PERMISSIONS,
2317
+ steps: Object.freeze([mintStep, reportStep])
2318
+ });
2319
+ };
2320
+ //#endregion
2109
2321
  //#region src/pr-status-hud-workflow.ts
2110
2322
  const FACTORY_PR_STATUS_HUD_JOB_ID = "status-hud";
2111
2323
  const FACTORY_PR_STATUS_HUD_JOB_NAME = "Present PR status HUD";
@@ -2118,15 +2330,11 @@ const FACTORY_PR_STATUS_HUD_PLAN_PATH = ".factory-memory/preview-plan.json";
2118
2330
  */
2119
2331
  const FACTORY_PR_STATUS_HUD_IF = "always() && github.event_name == 'pull_request' && github.event.pull_request.draft != true";
2120
2332
  /**
2121
- * Least privilege for the job that runs the present step. A `permissions`
2122
- * block zeroes every unlisted GITHUB_TOKEN scope, and the inventory list
2123
- * reads pull requests, commits, and check-runs with that token (#825).
2333
+ * Least privilege for the job that runs the present step. The present step
2334
+ * is an inventory `list`, so the job needs exactly the inventory's read
2335
+ * permissions (#825, #859).
2124
2336
  */
2125
- const FACTORY_PR_STATUS_HUD_PERMISSIONS = Object.freeze({
2126
- checks: "read",
2127
- contents: "read",
2128
- "pull-requests": "read"
2129
- });
2337
+ const FACTORY_PR_STATUS_HUD_PERMISSIONS = PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS;
2130
2338
  const FACTORY_PR_STATUS_HUD_CONCURRENCY = Object.freeze({
2131
2339
  cancelInProgress: false,
2132
2340
  group: `status-hud-\${{ github.repository }}-\${{ github.event.pull_request.number }}`
@@ -2221,6 +2429,11 @@ const factoryPreviewCleanupTopology = (options) => {
2221
2429
  //#endregion
2222
2430
  //#region src/push-identity-workflow.ts
2223
2431
  const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
2432
+ /**
2433
+ * The artifact prefix and the five step IDs are module-private. Alpha.13
2434
+ * removed them from the public surface (#719). A caller reads a step ID from
2435
+ * the builder's returned `steps` (`step.id`) or from `artifactName`.
2436
+ */
2224
2437
  const FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX = "factory-push-identity";
2225
2438
  const FACTORY_PUSH_IDENTITY_RECORD_STEP_ID = "factory_push_identity_record";
2226
2439
  const FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID = "factory_push_identity_lookup";
@@ -3286,4 +3499,4 @@ const assertWorkflowShellParses = (yaml, options) => {
3286
3499
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
3287
3500
  };
3288
3501
  //#endregion
3289
- export { EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, 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_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, PREVIEW_PROOF_INVENTORY_CHECK_NAME, PreviewProofTransportError, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, productionImpactTargetOutput, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
3502
+ export { EXECUTE_ALCHEMY_ENTRY_MAX_BUFFER, FACTORY_CANDIDATE_IMPACT_BASIS_OUTPUT, FACTORY_CANDIDATE_IMPACT_DECISION_OUTPUT, FACTORY_CANDIDATE_IMPACT_INERT_OUTPUT, FACTORY_CANDIDATE_IMPACT_STEP_ID, FACTORY_CANDIDATE_IMPACT_UNSUBSCRIBED_OUTPUT, FACTORY_CANDIDATE_PULL_REQUEST_TYPES, FACTORY_LIFECYCLE_CONTRACT_JOB_ID, FACTORY_LIFECYCLE_CONTRACT_JOB_NAME, FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID, FACTORY_MERGE_FREEZE_CHECK_NAME, FACTORY_MERGE_FREEZE_IF, FACTORY_MERGE_FREEZE_JOB_ID, FACTORY_MERGE_FREEZE_JOB_NAME, FACTORY_MERGE_FREEZE_PERMISSIONS, FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION, FACTORY_MERGE_TARGET_REF_CONDITION, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_ID, FACTORY_PREVIEW_CLEANUP_AUDIT_JOB_NAME, 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_PULL_REQUEST_IF, 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_PR_STATUS_HUD_APP_TOKEN_STEP_ID, FACTORY_PR_STATUS_HUD_CONCURRENCY, FACTORY_PR_STATUS_HUD_IF, FACTORY_PR_STATUS_HUD_JOB_ID, FACTORY_PR_STATUS_HUD_JOB_NAME, FACTORY_PR_STATUS_HUD_PERMISSIONS, FACTORY_PR_STATUS_HUD_PLAN_PATH, FACTORY_PUSH_IDENTITY_SCHEMA_VERSION, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, PREVIEW_PROOF_INVENTORY_CHECK_NAME, PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS, PreviewProofTransportError, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryCandidateImpactWorkflow, factoryCandidateOrPushCondition, factoryLifecycleContractLane, factoryMergeFreezeJob, factoryMergeFreezeScript, factoryPrStatusHudWorkflow, factoryPreviewCleanupTopology, factoryProductionImpactWorkflow, factoryProofGateScript, factoryProofGateStep, factoryProofReuseSummaryStep, factoryProofTimingStartStep, factoryPushIdentityConsumer, factoryPushIdentityProducer, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, previewCleanupDestroyJobId, previewProofInventory, previewProofLifecycle, 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.21",
3
+ "version": "1.0.0-alpha.22",
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",
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  } from "./preview-proof-lifecycle.ts";
44
44
  export {
45
45
  PREVIEW_PROOF_INVENTORY_CHECK_NAME,
46
+ PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS,
46
47
  previewProofInventory,
47
48
  type PreviewProofInventoryAccess,
48
49
  type PreviewProofInventoryCleanupInput,
@@ -83,6 +84,7 @@ export {
83
84
  FACTORY_PROOF_GATE_IF,
84
85
  FACTORY_PROOF_GATE_MODE_OUTPUT,
85
86
  FACTORY_PROOF_GATE_OUTPUT,
87
+ FACTORY_PROOF_GATE_PULL_REQUEST_IF,
86
88
  FACTORY_PROOF_GATE_REASON_OUTPUT,
87
89
  FACTORY_PROOF_GATE_REASONS,
88
90
  FACTORY_PROOF_GATE_SHELL,
@@ -91,6 +93,7 @@ export {
91
93
  FACTORY_PROOF_GATE_STEP_NAME,
92
94
  type FactoryProofGateOptions,
93
95
  type FactoryProofGateReason,
96
+ type FactoryProofGateReuse,
94
97
  factoryProofGateScript,
95
98
  type FactoryProofGateStep,
96
99
  factoryProofGateStep,
@@ -125,6 +128,20 @@ export {
125
128
  type FactoryLifecycleContractLaneOptions,
126
129
  factoryLifecycleContractLane,
127
130
  } from "./lifecycle-contract-lane.ts";
131
+ export {
132
+ FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID,
133
+ FACTORY_MERGE_FREEZE_CHECK_NAME,
134
+ FACTORY_MERGE_FREEZE_IF,
135
+ FACTORY_MERGE_FREEZE_JOB_ID,
136
+ FACTORY_MERGE_FREEZE_JOB_NAME,
137
+ FACTORY_MERGE_FREEZE_PERMISSIONS,
138
+ FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION,
139
+ FACTORY_MERGE_TARGET_REF_CONDITION,
140
+ type FactoryMergeFreezeJob,
141
+ factoryMergeFreezeJob,
142
+ type FactoryMergeFreezeJobOptions,
143
+ factoryMergeFreezeScript,
144
+ } from "./merge-freeze-job.ts";
128
145
  export {
129
146
  FACTORY_PR_STATUS_HUD_APP_TOKEN_STEP_ID,
130
147
  FACTORY_PR_STATUS_HUD_CONCURRENCY,