@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.
@@ -0,0 +1,230 @@
1
+ import type { PinnedAction } from "./actions.ts";
2
+ import type { WorkflowStep } from "./factory-workflow.ts";
3
+ import { assertPinnedAction } from "./pinned-action.ts";
4
+ import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
5
+
6
+ /**
7
+ * The merge-freeze writer job (#356, ADR 0016 as amended; #429 for the
8
+ * merge-target namespace; #872 for this package).
9
+ *
10
+ * A consumer's generated merge-target-push Verify workflow is the ONLY
11
+ * producer of `patronage-factory/merge-freeze` generations: a red
12
+ * push-triggered Verify completes a generation active, a green one completes
13
+ * it inactive, and no command, scheduled job, or second workflow writes this
14
+ * check run. The operator override (`demand:waive --demand merge-freeze`)
15
+ * waives the demand for one candidate during a fix-forward; it never writes
16
+ * here.
17
+ *
18
+ * The write mirrors what the factory package's merge-freeze reader parses:
19
+ * create the run `in_progress` with no `started_at` (GitHub stamps the
20
+ * generation clock — the ordering rule itself is owned by the reader), then
21
+ * PATCH it completed with the reader-shaped state payload in `output.text`.
22
+ * The consumer's workflow test executes this exact script against a stubbed
23
+ * `gh` and parses the emitted payload with `validateMergeFreezeState`, so
24
+ * writer and reader cannot drift silently.
25
+ *
26
+ * Failure posture is deliberately fail-closed and the opposite of the
27
+ * proof-reuse gate's fail-open shell: this step runs under the runner's
28
+ * default `bash -e {0}`, so any API refusal aborts the step, the generation
29
+ * stays absent or `in_progress`, and every `pr:ready` arming-time read of
30
+ * that tip (#477) refuses until a later push writes a green generation.
31
+ *
32
+ * **Ownership line.** This package owns the check name, the App identity, the
33
+ * `create-github-app-token` inputs, the result fold, the merge-target `if`,
34
+ * and the job's `permissions`. The caller owns the runner, the `needs` list
35
+ * of every authoritative verification leaf, the timeout, and any extra env.
36
+ */
37
+
38
+ /** The one check-run name every merge-freeze reader pins. */
39
+ export const FACTORY_MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
40
+
41
+ export const FACTORY_MERGE_FREEZE_JOB_ID = "freeze";
42
+ export const FACTORY_MERGE_FREEZE_JOB_NAME = "Report the merge freeze";
43
+
44
+ /** Step id the token step exposes its installation token under. */
45
+ export const FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID = "factory-app-token";
46
+
47
+ /**
48
+ * The GitHub Actions expression that folds the needed jobs' results into the
49
+ * one word the script branches on. It mirrors the `verify` summary job's
50
+ * failure condition exactly: any failed or cancelled needed job is a red
51
+ * merge target.
52
+ */
53
+ export const FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION = `\${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }}`;
54
+
55
+ /**
56
+ * The literal factory merge-target namespace, as a `github.ref` predicate
57
+ * (#429).
58
+ *
59
+ * `main` plus the repository-owned `epic/**` integration branches, and
60
+ * nothing else. Deliberately literal rather than profile-declared: one
61
+ * implementation per contract, no configuration machinery for a two-element
62
+ * namespace whose second element is a namespace prefix the repository itself
63
+ * owns. `epic/` needs the trailing slash — `startsWith` would otherwise admit
64
+ * `epicness/…`, which is not a merge target.
65
+ *
66
+ * A caller's `push` trigger branch list names the same namespace. That list
67
+ * decides which events produce a run at all; this predicate decides which of
68
+ * those runs may write a generation.
69
+ */
70
+ export const FACTORY_MERGE_TARGET_REF_CONDITION = [
71
+ "github.ref == 'refs/heads/main'",
72
+ "startsWith(github.ref, 'refs/heads/epic/')",
73
+ ].join(" || ");
74
+
75
+ /**
76
+ * `always()` is load-bearing: the red path is the whole point, so the job must
77
+ * run when a needed verification job failed. `github.event_name == 'push'` is
78
+ * load-bearing twice over: a `pull_request` run must never write a generation
79
+ * (its `github.sha` is a synthetic merge commit that no base tip is ever read
80
+ * at), and a `pull_request` trigger with an `epic/**` base list would
81
+ * otherwise reach this job.
82
+ */
83
+ export const FACTORY_MERGE_FREEZE_IF = `always() && github.event_name == 'push' && (${FACTORY_MERGE_TARGET_REF_CONDITION})`;
84
+
85
+ /**
86
+ * Least privilege for the writer job: no `GITHUB_TOKEN` scope at all.
87
+ *
88
+ * Both Checks API calls run under the App installation token minted in the
89
+ * first step (`GH_TOKEN: steps.factory-app-token.outputs.token`), and the job
90
+ * never checks out the repository. So the job uses no `GITHUB_TOKEN` scope,
91
+ * and an empty block is the honest declaration. A `checks: write` here would
92
+ * hand every adopter an unused write scope (#884).
93
+ */
94
+ export const FACTORY_MERGE_FREEZE_PERMISSIONS = Object.freeze({} as const);
95
+
96
+ /**
97
+ * The script the freeze step runs. Environment contract:
98
+ * - `GH_TOKEN`: an installation token for the pinned Patronage Factory App —
99
+ * the check run must carry that App's identity or every reader rejects it.
100
+ * - `VERIFY_RESULT`: `success` or `failure` (see the fold expression above).
101
+ * - `GITHUB_SHA` / `GITHUB_REPOSITORY` / `GITHUB_SERVER_URL` /
102
+ * `GITHUB_RUN_ID` / `GITHUB_REF_NAME`: runner-provided.
103
+ *
104
+ * `GITHUB_REF_NAME` is the merge target this run pushed to — `main` or an
105
+ * `epic/**` integration branch (#429). The job is gated to `push` events on
106
+ * exactly those refs, so the branch name is always the short name of a
107
+ * repository-owned merge target and never a pull-request merge ref. It
108
+ * appears in both recovery sentences because a reader of an epic-targeting
109
+ * candidate must be told which branch to fix forward on: the generation lives
110
+ * on that branch's tip, and only a later green push to THAT branch clears it.
111
+ */
112
+ export const factoryMergeFreezeScript = (): string =>
113
+ [
114
+ `sha="$GITHUB_SHA"`,
115
+ `branch="$GITHUB_REF_NAME"`,
116
+ `repo="$GITHUB_REPOSITORY"`,
117
+ `run_url="$GITHUB_SERVER_URL/$repo/actions/runs/$GITHUB_RUN_ID"`,
118
+ `created=$(gh api --method POST "repos/$repo/check-runs" \\`,
119
+ ` -f "name=${FACTORY_MERGE_FREEZE_CHECK_NAME}" \\`,
120
+ ` -f "head_sha=$sha" \\`,
121
+ ` -f "status=in_progress" \\`,
122
+ ` -f "details_url=$run_url" \\`,
123
+ ` -f "output[title]=merge freeze generation in progress" \\`,
124
+ ` -f "output[summary]=This generation blocks factory merge handoffs until the merge-target Verify result is recorded.")`,
125
+ `id=$(jq -r '.id' <<<"$created")`,
126
+ `case "$id" in`,
127
+ ` ''|*[!0-9]*) echo "unusable check-run id: $id" >&2; exit 1;;`,
128
+ `esac`,
129
+ `if [ "$VERIFY_RESULT" = 'success' ]; then`,
130
+ ` active=false; outcome=inactive; conclusion=success`,
131
+ ` reason="Merge-target Verify passed on $branch at $sha ($run_url)."`,
132
+ `else`,
133
+ ` active=true; outcome=active; conclusion=failure`,
134
+ ` 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)."`,
135
+ `fi`,
136
+ `state=$(jq -cn --argjson active "$active" --argjson id "$id" --arg headSha "$sha" --arg outcome "$outcome" --arg reason "$reason" \\`,
137
+ ` '{active: $active, generationId: $id, headSha: $headSha, outcome: $outcome, reason: $reason, recordedAt: (now | todate), schemaVersion: 1}')`,
138
+ `gh api --method PATCH "repos/$repo/check-runs/$id" \\`,
139
+ ` -f "status=completed" \\`,
140
+ ` -f "conclusion=$conclusion" \\`,
141
+ ` -f "details_url=$run_url" \\`,
142
+ ` -f "output[title]=merge freeze $outcome" \\`,
143
+ ` -f "output[summary]=$reason" \\`,
144
+ ` -f "output[text]=$state" >/dev/null`,
145
+ `echo "merge-freeze generation $id completed $outcome on $branch at $sha"`,
146
+ ].join("\n");
147
+
148
+ export interface FactoryMergeFreezeJobOptions<TNeed> {
149
+ /** The pinned `actions/create-github-app-token` the caller declared. */
150
+ readonly createGithubAppToken: PinnedAction;
151
+ /**
152
+ * Every authoritative verification leaf this generation folds. The elements
153
+ * are whatever the caller's workflow generator uses for `needs:`; they are
154
+ * returned unchanged.
155
+ */
156
+ readonly needs: readonly TNeed[];
157
+ /**
158
+ * Override the result fold. Defaults to
159
+ * `FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION`, which is what a caller
160
+ * whose summary job folds `needs.*.result` wants. Supply one only when the
161
+ * caller's red condition is genuinely different.
162
+ */
163
+ readonly verifyResultExpression?: string;
164
+ }
165
+
166
+ export interface FactoryMergeFreezeJob<TNeed> {
167
+ readonly if: typeof FACTORY_MERGE_FREEZE_IF;
168
+ readonly jobId: typeof FACTORY_MERGE_FREEZE_JOB_ID;
169
+ readonly jobName: typeof FACTORY_MERGE_FREEZE_JOB_NAME;
170
+ readonly needs: readonly TNeed[];
171
+ readonly permissions: typeof FACTORY_MERGE_FREEZE_PERMISSIONS;
172
+ readonly steps: readonly WorkflowStep[];
173
+ }
174
+
175
+ /**
176
+ * Build the merge-freeze writer job.
177
+ *
178
+ * An empty `needs` list is refused: a fold over no verification leaf is
179
+ * always `success`, so such a job would write a green generation for a merge
180
+ * target nothing verified — the exact failure the freeze exists to prevent.
181
+ */
182
+ export const factoryMergeFreezeJob = <TNeed>(
183
+ options: FactoryMergeFreezeJobOptions<TNeed>
184
+ ): FactoryMergeFreezeJob<TNeed> => {
185
+ assertPinnedAction(
186
+ "createGithubAppToken",
187
+ options.createGithubAppToken,
188
+ "actions/create-github-app-token"
189
+ );
190
+ if (options.needs.length === 0) {
191
+ throw new Error(
192
+ "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."
193
+ );
194
+ }
195
+
196
+ // Same unscoped mint as the PR status HUD job. A subset (checks/issues/PRs)
197
+ // 422s: those permissions are not granted to this installation.
198
+ const mintStep: WorkflowStep = Object.freeze({
199
+ id: FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID,
200
+ name: "Mint the factory App token",
201
+ uses: options.createGithubAppToken.uses,
202
+ with: Object.freeze({
203
+ // `client-id` superseded `app-id` in v3.2.0 (#581, #592): both inputs
204
+ // reach the same underlying `createAppAuth({ appId })` call, which has
205
+ // always accepted either the numeric App id or the Client id, so a
206
+ // pinned App id keeps authenticating unchanged.
207
+ "client-id": FACTORY_PROOF_GATE_APP_ID,
208
+ "private-key": `\${{ secrets.FACTORY_GITHUB_APP_PRIVATE_KEY }}`,
209
+ }),
210
+ });
211
+ const reportStep: WorkflowStep = Object.freeze({
212
+ env: Object.freeze({
213
+ GH_TOKEN: `\${{ steps.${FACTORY_MERGE_FREEZE_APP_TOKEN_STEP_ID}.outputs.token }}`,
214
+ VERIFY_RESULT:
215
+ options.verifyResultExpression ??
216
+ FACTORY_MERGE_FREEZE_VERIFY_RESULT_EXPRESSION,
217
+ }),
218
+ name: "Complete the merge-freeze generation",
219
+ run: factoryMergeFreezeScript(),
220
+ });
221
+
222
+ return Object.freeze({
223
+ if: FACTORY_MERGE_FREEZE_IF,
224
+ jobId: FACTORY_MERGE_FREEZE_JOB_ID,
225
+ jobName: FACTORY_MERGE_FREEZE_JOB_NAME,
226
+ needs: Object.freeze([...options.needs]),
227
+ permissions: FACTORY_MERGE_FREEZE_PERMISSIONS,
228
+ steps: Object.freeze([mintStep, reportStep]),
229
+ });
230
+ };
@@ -1,6 +1,7 @@
1
1
  import type { PinnedAction } from "./actions.ts";
2
2
  import type { WorkflowStep } from "./factory-workflow.ts";
3
3
  import { assertPinnedAction } from "./pinned-action.ts";
4
+ import { PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS } from "./preview-proof-inventory.ts";
4
5
  import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
5
6
 
6
7
  export const FACTORY_PR_STATUS_HUD_JOB_ID = "status-hud";
@@ -23,15 +24,12 @@ export interface FactoryPrStatusHudWorkflowOptions {
23
24
  }
24
25
 
25
26
  /**
26
- * Least privilege for the job that runs the present step. A `permissions`
27
- * block zeroes every unlisted GITHUB_TOKEN scope, and the inventory list
28
- * reads pull requests, commits, and check-runs with that token (#825).
27
+ * Least privilege for the job that runs the present step. The present step
28
+ * is an inventory `list`, so the job needs exactly the inventory's read
29
+ * permissions (#825, #859).
29
30
  */
30
- export const FACTORY_PR_STATUS_HUD_PERMISSIONS = Object.freeze({
31
- checks: "read",
32
- contents: "read",
33
- "pull-requests": "read",
34
- } as const);
31
+ export const FACTORY_PR_STATUS_HUD_PERMISSIONS =
32
+ PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS;
35
33
 
36
34
  export const FACTORY_PR_STATUS_HUD_CONCURRENCY = Object.freeze({
37
35
  cancelInProgress: false,
@@ -35,6 +35,21 @@ import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
35
35
  export const PREVIEW_PROOF_INVENTORY_CHECK_NAME =
36
36
  "patronage-factory/preview-proof";
37
37
 
38
+ /**
39
+ * Least privilege for a job whose `GITHUB_TOKEN` calls `list`. A
40
+ * `permissions` block zeroes every unlisted scope, and `list` reads five
41
+ * endpoints: `pulls/{pr}`, `pulls/{pr}/commits`, and the GraphQL force-push
42
+ * timeline (`pull-requests`), `compare` (`contents`), and
43
+ * `commits/{sha}/check-runs` (`checks`). A job
44
+ * that omits `pull-requests: read` fails with 403 on a same-repository PR
45
+ * and skips every destroy matrix (#859). Give the discovery job this object.
46
+ */
47
+ export const PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS = Object.freeze({
48
+ checks: "read",
49
+ contents: "read",
50
+ "pull-requests": "read",
51
+ } as const);
52
+
38
53
  const INVENTORY_KIND = "patronage-factory-preview-proof";
39
54
  const INVENTORY_SCHEMA_VERSION = 1;
40
55
  const DEFAULT_TIMEOUT_MS = 5000;
@@ -317,7 +332,8 @@ const replaceRegistration = (
317
332
  const memoryKey = (owner: string, repo: string, pr: number): string =>
318
333
  `${owner}/${repo}#${pr}`;
319
334
 
320
- export const memoryStore = (): PreviewProofInventoryStore => {
335
+ /** Reached by callers as `previewProofInventory.memoryStore`. */
336
+ const memoryStore = (): PreviewProofInventoryStore => {
321
337
  const byPr = new Map<string, PreviewProofRegistration[]>();
322
338
 
323
339
  return {
@@ -967,7 +983,8 @@ const takeNewestRegistrations = (
967
983
  return [...byKey.values()];
968
984
  };
969
985
 
970
- export const githubStore = (
986
+ /** Reached by callers as `previewProofInventory.githubStore`. */
987
+ const githubStore = (
971
988
  transport: PreviewProofInventoryTransport
972
989
  ): PreviewProofInventoryStore => {
973
990
  const request = transport.fetch ?? fetch;
@@ -1172,6 +1189,7 @@ const recordCleanup = async (
1172
1189
  export const previewProofInventory = {
1173
1190
  githubStore,
1174
1191
  list,
1192
+ listPermissions: PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS,
1175
1193
  memoryStore,
1176
1194
  persist,
1177
1195
  recordCleanup,
@@ -142,6 +142,41 @@ export const FACTORY_PROOF_GATE_GUARD = `steps.${FACTORY_PROOF_GATE_STEP_ID}.out
142
142
  export const FACTORY_PROOF_GATE_IF =
143
143
  "github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))";
144
144
 
145
+ /**
146
+ * Pull requests only — the condition a consumer gets with
147
+ * `reuse: "pull-request"` (#873).
148
+ *
149
+ * A repository may want its default-branch pushes to always execute the full
150
+ * hosted suite, whatever proof exists: the merged commit is what the fleet
151
+ * deploys, so a periodic unconditional run of every command is a deliberate
152
+ * cost some consumers choose to pay. This condition is that choice, expressed
153
+ * once here rather than as a consumer-written override string. The mode also
154
+ * skips the push-event merge fallback, so no push path can reuse proof even if
155
+ * the workflow reaches the step through some other trigger.
156
+ *
157
+ * Which branches trigger the workflow at all stays repository-owned. This
158
+ * condition only keeps the gate from consulting proof outside a pull request.
159
+ */
160
+ export const FACTORY_PROOF_GATE_PULL_REQUEST_IF =
161
+ "github.event_name == 'pull_request'";
162
+
163
+ /**
164
+ * Which events may reuse proof.
165
+ *
166
+ * A two-value enum, not a free-text condition: a string lets a consumer write
167
+ * a condition this package cannot reason about — one that reuses proof on an
168
+ * unproven ref — and the emitted condition is a trust predicate. The consumer
169
+ * chooses the mode; `factory-ci` owns what each mode emits.
170
+ *
171
+ * - `pull-request-and-default-branch` (default) pull requests plus pushes to
172
+ * the merge target, with the merge fallback. Today's behaviour.
173
+ * - `pull-request` pull requests only. Default-branch pushes execute the full
174
+ * hosted suite, and the merge fallback is not emitted.
175
+ */
176
+ export type FactoryProofGateReuse =
177
+ | "pull-request"
178
+ | "pull-request-and-default-branch";
179
+
145
180
  /**
146
181
  * The complete refusal vocabulary. Deliberately few, because these are the
147
182
  * only distinctions the gate can honestly make from its Checks API reads.
@@ -488,7 +523,99 @@ const safeLabel = (surface: string): string => {
488
523
  return cleaned.length > 0 ? cleaned : "verification";
489
524
  };
490
525
 
491
- const gateScript = (required: readonly string[], surface: string): string =>
526
+ /**
527
+ * The push-event merge fallback (#611), emitted only for the default reuse
528
+ * mode. `reuse: "pull-request"` omits it: that mode's whole point is that a
529
+ * default-branch push executes everything, so a push path that could still
530
+ * reuse proof would contradict the condition above it.
531
+ */
532
+ const MERGE_FALLBACK_BLOCK = String.raw`# Merge fallback (#611). A squash merge mints a new commit, so the direct
533
+ # read at a pushed merge-target head finds nothing even when the factory
534
+ # proved the producing pull request head. Only when the direct read found no
535
+ # generation at all on a push event, look up the producing pull request and
536
+ # reuse its head proof — and only when the merge commit's TREE id equals the
537
+ # proven head's tree id, which makes the pushed content byte-identical to
538
+ # what was verified (a clean squash of an unchanged tip). Patch identity was
539
+ # considered and rejected as the comparator: git patch-id normalizes
540
+ # whitespace and ignores base motion, so an identical patch can still
541
+ # produce an integrated tree that was never tested. Everything else — no
542
+ # unique merged producing PR, an unreadable commit, a proof that is anything
543
+ # but proven, or tree drift from a dirty or stale merge — leaves the direct
544
+ # "none" refusal standing, so the full suite runs (fail open).
545
+ if [ "$reason" = 'none' ] && [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
546
+ producing_head=''
547
+ merge_tree=''
548
+ head_tree=''
549
+ if ! producing_pulls=$(gh api --method GET --paginate \
550
+ "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/pulls" \
551
+ -f per_page=100 2>&1); then
552
+ detail="producing pull request unreadable: $producing_pulls"
553
+ elif ! producing_head=$(printf '%s' "$producing_pulls" | jq -r -s \
554
+ --arg sha "$HEAD_SHA" \
555
+ '[ add[]?
556
+ | select((.merged_at // null) != null)
557
+ | select((.merge_commit_sha // "") == $sha)
558
+ | ((.head.sha // "") | tostring) ]
559
+ | if length == 1 then .[0] else "" end' 2>&1); then
560
+ detail="producing pull request unreadable: $producing_head"
561
+ producing_head=''
562
+ elif ! [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]]; then
563
+ detail='no single merged producing pull request at this commit'
564
+ producing_head=''
565
+ elif ! merge_commit=$(gh api --method GET \
566
+ "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA" 2>&1); then
567
+ detail="merge commit unreadable: $merge_commit"
568
+ elif ! merge_tree=$(printf '%s' "$merge_commit" | jq -r \
569
+ '(.commit.tree.sha // "") | tostring' 2>&1); then
570
+ detail="merge commit unreadable: $merge_tree"
571
+ merge_tree=''
572
+ elif ! [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]]; then
573
+ detail='merge commit carries no readable tree id'
574
+ merge_tree=''
575
+ elif ! head_commit=$(gh api --method GET \
576
+ "repos/$GITHUB_REPOSITORY/commits/$producing_head" 2>&1); then
577
+ detail="producing head commit unreadable: $head_commit"
578
+ elif ! head_tree=$(printf '%s' "$head_commit" | jq -r \
579
+ '(.commit.tree.sha // "") | tostring' 2>&1); then
580
+ detail="producing head commit unreadable: $head_tree"
581
+ head_tree=''
582
+ elif ! [[ "$head_tree" =~ ^[0-9a-f]{40}$ ]]; then
583
+ detail='producing head commit carries no readable tree id'
584
+ head_tree=''
585
+ elif [ "$merge_tree" != "$head_tree" ]; then
586
+ detail='tree drift: the merge result is not the proven head tree'
587
+ merge_tree=''
588
+ fi
589
+ if [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]] \
590
+ && [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]] \
591
+ && [ "$merge_tree" = "$head_tree" ]; then
592
+ gate_lookup "$producing_head"
593
+ if [ "$lookup_reason" = 'proven' ]; then
594
+ reason=proven
595
+ mode="$lookup_mode"
596
+ missing=''
597
+ source_url="$lookup_url"
598
+ proof_head="$producing_head"
599
+ merge_note="the merge result's tree is byte-identical to the tree proven at pull request head $producing_head"
600
+ else
601
+ detail="producing pull request proof not reusable ($lookup_reason)"
602
+ fi
603
+ fi
604
+ fi`;
605
+
606
+ /**
607
+ * The fallback with the blank lines that surround it, or nothing at all. Kept
608
+ * as one piece so the default mode emits the exact script it emitted before
609
+ * this option existed.
610
+ */
611
+ const mergeFallbackSection = (reuse: FactoryProofGateReuse): string =>
612
+ reuse === "pull-request" ? "\n" : `\n${MERGE_FALLBACK_BLOCK}\n\n`;
613
+
614
+ const gateScript = (
615
+ required: readonly string[],
616
+ surface: string,
617
+ reuse: FactoryProofGateReuse
618
+ ): string =>
492
619
  String.raw`
493
620
  set -uo pipefail
494
621
 
@@ -576,82 +703,7 @@ else
576
703
  missing="$lookup_missing"
577
704
  source_url="$lookup_url"
578
705
  fi
579
-
580
- # Merge fallback (#611). A squash merge mints a new commit, so the direct
581
- # read at a pushed merge-target head finds nothing even when the factory
582
- # proved the producing pull request head. Only when the direct read found no
583
- # generation at all on a push event, look up the producing pull request and
584
- # reuse its head proof — and only when the merge commit's TREE id equals the
585
- # proven head's tree id, which makes the pushed content byte-identical to
586
- # what was verified (a clean squash of an unchanged tip). Patch identity was
587
- # considered and rejected as the comparator: git patch-id normalizes
588
- # whitespace and ignores base motion, so an identical patch can still
589
- # produce an integrated tree that was never tested. Everything else — no
590
- # unique merged producing PR, an unreadable commit, a proof that is anything
591
- # but proven, or tree drift from a dirty or stale merge — leaves the direct
592
- # "none" refusal standing, so the full suite runs (fail open).
593
- if [ "$reason" = 'none' ] && [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
594
- producing_head=''
595
- merge_tree=''
596
- head_tree=''
597
- if ! producing_pulls=$(gh api --method GET --paginate \
598
- "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/pulls" \
599
- -f per_page=100 2>&1); then
600
- detail="producing pull request unreadable: $producing_pulls"
601
- elif ! producing_head=$(printf '%s' "$producing_pulls" | jq -r -s \
602
- --arg sha "$HEAD_SHA" \
603
- '[ add[]?
604
- | select((.merged_at // null) != null)
605
- | select((.merge_commit_sha // "") == $sha)
606
- | ((.head.sha // "") | tostring) ]
607
- | if length == 1 then .[0] else "" end' 2>&1); then
608
- detail="producing pull request unreadable: $producing_head"
609
- producing_head=''
610
- elif ! [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]]; then
611
- detail='no single merged producing pull request at this commit'
612
- producing_head=''
613
- elif ! merge_commit=$(gh api --method GET \
614
- "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA" 2>&1); then
615
- detail="merge commit unreadable: $merge_commit"
616
- elif ! merge_tree=$(printf '%s' "$merge_commit" | jq -r \
617
- '(.commit.tree.sha // "") | tostring' 2>&1); then
618
- detail="merge commit unreadable: $merge_tree"
619
- merge_tree=''
620
- elif ! [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]]; then
621
- detail='merge commit carries no readable tree id'
622
- merge_tree=''
623
- elif ! head_commit=$(gh api --method GET \
624
- "repos/$GITHUB_REPOSITORY/commits/$producing_head" 2>&1); then
625
- detail="producing head commit unreadable: $head_commit"
626
- elif ! head_tree=$(printf '%s' "$head_commit" | jq -r \
627
- '(.commit.tree.sha // "") | tostring' 2>&1); then
628
- detail="producing head commit unreadable: $head_tree"
629
- head_tree=''
630
- elif ! [[ "$head_tree" =~ ^[0-9a-f]{40}$ ]]; then
631
- detail='producing head commit carries no readable tree id'
632
- head_tree=''
633
- elif [ "$merge_tree" != "$head_tree" ]; then
634
- detail='tree drift: the merge result is not the proven head tree'
635
- merge_tree=''
636
- fi
637
- if [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]] \
638
- && [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]] \
639
- && [ "$merge_tree" = "$head_tree" ]; then
640
- gate_lookup "$producing_head"
641
- if [ "$lookup_reason" = 'proven' ]; then
642
- reason=proven
643
- mode="$lookup_mode"
644
- missing=''
645
- source_url="$lookup_url"
646
- proof_head="$producing_head"
647
- merge_note="the merge result's tree is byte-identical to the tree proven at pull request head $producing_head"
648
- else
649
- detail="producing pull request proof not reusable ($lookup_reason)"
650
- fi
651
- fi
652
- fi
653
-
654
- # The binding is App-verified, but it still reaches markdown. Only a plain
706
+ ${mergeFallbackSection(reuse)}# The binding is App-verified, but it still reaches markdown. Only a plain
655
707
  # lowercase token is quoted back; anything else is reported as unknown.
656
708
  case "$mode" in
657
709
  '') ;;
@@ -770,6 +822,13 @@ export interface FactoryProofGateOptions {
770
822
  * therefore requires a different coverage.
771
823
  */
772
824
  readonly commands: readonly ProofReuseCommand[];
825
+ /**
826
+ * Which events may reuse proof. Defaults to
827
+ * `"pull-request-and-default-branch"`, the behaviour every consumer has
828
+ * today. `"pull-request"` makes default-branch pushes execute the full
829
+ * hosted suite (#873).
830
+ */
831
+ readonly reuse?: FactoryProofGateReuse;
773
832
  /** Names the suite in the job summary. Changes no trust decision. */
774
833
  readonly surface: string;
775
834
  }
@@ -788,11 +847,12 @@ export interface FactoryProofGateOptions {
788
847
  */
789
848
  export const factoryProofGateScript = ({
790
849
  commands,
850
+ reuse = "pull-request-and-default-branch",
791
851
  surface,
792
852
  }: FactoryProofGateOptions): string => {
793
853
  const required = proofReuseRequiredCommands(commands);
794
854
  return required
795
- ? gateScript(required, safeLabel(surface))
855
+ ? gateScript(required, safeLabel(surface), reuse)
796
856
  : UNUSABLE_SELECTION_SCRIPT;
797
857
  };
798
858
 
@@ -803,7 +863,9 @@ export interface FactoryProofGateStep {
803
863
  HEAD_SHA: string;
804
864
  }>;
805
865
  readonly id: typeof FACTORY_PROOF_GATE_STEP_ID;
806
- readonly if: typeof FACTORY_PROOF_GATE_IF;
866
+ readonly if:
867
+ | typeof FACTORY_PROOF_GATE_IF
868
+ | typeof FACTORY_PROOF_GATE_PULL_REQUEST_IF;
807
869
  readonly name: string;
808
870
  readonly run: string;
809
871
  readonly shell: typeof FACTORY_PROOF_GATE_SHELL;
@@ -817,7 +879,9 @@ export interface FactoryProofGateStep {
817
879
  * `checks: read`; the push-event merge fallback additionally reads the
818
880
  * producing pull request (`pull-requests: read`) and the two commit objects
819
881
  * whose tree ids it compares (`contents: read`). A job that grants less
820
- * loses only the fallback — the failed read degrades to the full suite.
882
+ * loses only the fallback — the failed read degrades to the full suite. A
883
+ * consumer that passes `reuse: "pull-request"` emits neither the push clause
884
+ * nor the fallback, and needs only `checks: read` (#873).
821
885
  *
822
886
  * A **step, not a job**, and that is not a style preference. A separate gate
823
887
  * job that errored would leave the guarded job `skipped`, and a summary job
@@ -841,7 +905,10 @@ export const factoryProofGateStep = (
841
905
  ),
842
906
  }),
843
907
  id: FACTORY_PROOF_GATE_STEP_ID,
844
- if: FACTORY_PROOF_GATE_IF,
908
+ if:
909
+ options.reuse === "pull-request"
910
+ ? FACTORY_PROOF_GATE_PULL_REQUEST_IF
911
+ : FACTORY_PROOF_GATE_IF,
845
912
  name: FACTORY_PROOF_GATE_STEP_NAME,
846
913
  run: factoryProofGateScript(options),
847
914
  // Never omit: the runner's default `run:` shell supplies `-e`, which
@@ -3,16 +3,17 @@ import type { WorkflowStep } from "./factory-workflow.ts";
3
3
  import { assertPinnedAction } from "./pinned-action.ts";
4
4
 
5
5
  export const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
6
- export const FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX = "factory-push-identity";
7
- export const FACTORY_PUSH_IDENTITY_RECORD_STEP_ID =
8
- "factory_push_identity_record";
9
- export const FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID =
10
- "factory_push_identity_lookup";
11
- export const FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID =
12
- "factory_push_identity_download";
13
- export const FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID =
14
- "factory_push_identity_checkout";
15
- export const FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID = "factory_push_identity";
6
+ /**
7
+ * The artifact prefix and the five step IDs are module-private. Alpha.13
8
+ * removed them from the public surface (#719). A caller reads a step ID from
9
+ * the builder's returned `steps` (`step.id`) or from `artifactName`.
10
+ */
11
+ const FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX = "factory-push-identity";
12
+ const FACTORY_PUSH_IDENTITY_RECORD_STEP_ID = "factory_push_identity_record";
13
+ const FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID = "factory_push_identity_lookup";
14
+ const FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID = "factory_push_identity_download";
15
+ const FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID = "factory_push_identity_checkout";
16
+ const FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID = "factory_push_identity";
16
17
 
17
18
  /** Versioned document uploaded by a push-triggered verification run. */
18
19
  export interface FactoryPushIdentityEnvelope {