@patronage/factory-ci 1.0.0-alpha.26 → 1.0.0-alpha.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -8
- package/dist/.build-fingerprint.json +1 -1
- package/dist/alchemy/index.d.ts +14 -2
- package/dist/alchemy/index.js +37 -8
- package/dist/index.d.ts +73 -81
- package/dist/index.js +98 -172
- package/package.json +1 -1
- package/src/alchemy/credential-preflight.ts +8 -2
- package/src/alchemy/secret-name-policy.ts +6 -0
- package/src/alchemy/stage-policy.ts +7 -6
- package/src/alchemy/url-implies-auth.ts +20 -1
- package/src/alchemy-baseline.ts +19 -6
- package/src/candidate-impact-workflow.ts +2 -0
- package/src/cursor-oidc.ts +33 -0
- package/src/github-app-token.ts +40 -2
- package/src/index.ts +10 -2
- package/src/pr-status-hud-workflow.ts +2 -0
- package/src/production-impact-workflow.ts +2 -0
- package/src/proof-reuse-gate.ts +23 -213
- package/src/proof-reuse-presentation.ts +4 -8
- package/src/runnable-cli.ts +27 -0
package/src/github-app-token.ts
CHANGED
|
@@ -57,6 +57,20 @@ export class GitHubApiError extends Error {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Installation-token permission subset. GitHub only grants permissions the
|
|
62
|
+
* App already has; a caller that wants least privilege names the subset here
|
|
63
|
+
* rather than taking the App's full installation grant (#949).
|
|
64
|
+
*/
|
|
65
|
+
export interface GithubAppInstallationPermissions {
|
|
66
|
+
checks?: "read" | "write";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The Factory App broker's token: repo-scoped Checks write, nothing else. */
|
|
70
|
+
export const FACTORY_APP_BROKER_TOKEN_PERMISSIONS = {
|
|
71
|
+
checks: "write",
|
|
72
|
+
} as const satisfies GithubAppInstallationPermissions;
|
|
73
|
+
|
|
60
74
|
export interface GithubAppTokenOptions {
|
|
61
75
|
/** Injectable `fetch` (tests, or a caller with its own instrumented one). */
|
|
62
76
|
fetch?: typeof fetch;
|
|
@@ -115,11 +129,12 @@ const githubAppJson = (
|
|
|
115
129
|
url: string,
|
|
116
130
|
jwt: string,
|
|
117
131
|
method: "GET" | "POST",
|
|
118
|
-
budget: { attempts?: number; timeoutMs: number }
|
|
132
|
+
budget: { attempts?: number; body?: string; timeoutMs: number }
|
|
119
133
|
): Promise<Record<string, unknown>> =>
|
|
120
134
|
githubJson(
|
|
121
135
|
{
|
|
122
136
|
attempts: budget.attempts,
|
|
137
|
+
...(budget.body === undefined ? {} : { body: budget.body }),
|
|
123
138
|
closeConnection: true,
|
|
124
139
|
fetch: request,
|
|
125
140
|
method,
|
|
@@ -157,6 +172,18 @@ export const mintInstallationToken = async (
|
|
|
157
172
|
input: {
|
|
158
173
|
credentials: GithubAppCredentials;
|
|
159
174
|
owner: string;
|
|
175
|
+
/**
|
|
176
|
+
* Installation permission subset. Omitted, GitHub grants the App's full
|
|
177
|
+
* installation permissions. The Factory App broker passes
|
|
178
|
+
* {@link FACTORY_APP_BROKER_TOKEN_PERMISSIONS}.
|
|
179
|
+
*/
|
|
180
|
+
permissions?: GithubAppInstallationPermissions;
|
|
181
|
+
/**
|
|
182
|
+
* Repository names (not `owner/repo`) to bind the token to. Omitted, the
|
|
183
|
+
* token covers every repository the installation can reach. The broker
|
|
184
|
+
* always names the one repository the Cursor OIDC claim authenticated.
|
|
185
|
+
*/
|
|
186
|
+
repositories?: readonly string[];
|
|
160
187
|
repo: string;
|
|
161
188
|
},
|
|
162
189
|
options: GithubAppTokenOptions = {}
|
|
@@ -184,12 +211,23 @@ export const mintInstallationToken = async (
|
|
|
184
211
|
installationId = installation.id;
|
|
185
212
|
}
|
|
186
213
|
|
|
214
|
+
const restriction =
|
|
215
|
+
input.permissions === undefined && input.repositories === undefined
|
|
216
|
+
? undefined
|
|
217
|
+
: JSON.stringify({
|
|
218
|
+
...(input.permissions === undefined
|
|
219
|
+
? {}
|
|
220
|
+
: { permissions: input.permissions }),
|
|
221
|
+
...(input.repositories === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { repositories: [...input.repositories] }),
|
|
224
|
+
});
|
|
187
225
|
const minted = await githubAppJson(
|
|
188
226
|
request,
|
|
189
227
|
`https://api.github.com/app/installations/${installationId}/access_tokens`,
|
|
190
228
|
jwt,
|
|
191
229
|
"POST",
|
|
192
|
-
budget
|
|
230
|
+
restriction === undefined ? budget : { ...budget, body: restriction }
|
|
193
231
|
);
|
|
194
232
|
if (typeof minted.token !== "string") {
|
|
195
233
|
throw new TypeError("GitHub App token response omitted token");
|
package/src/index.ts
CHANGED
|
@@ -71,9 +71,19 @@ export {
|
|
|
71
71
|
type WorkflowStep,
|
|
72
72
|
} from "./factory-workflow.ts";
|
|
73
73
|
export {
|
|
74
|
+
FACTORY_CURSOR_OIDC_AUDIENCE,
|
|
75
|
+
FACTORY_CURSOR_OIDC_ISSUER,
|
|
76
|
+
FACTORY_CURSOR_OIDC_JWKS_URL,
|
|
77
|
+
FACTORY_CURSOR_OIDC_MANAGED_RUNTIME,
|
|
78
|
+
FACTORY_APP_BROKER_ORIGIN,
|
|
79
|
+
FACTORY_APP_BROKER_PATH,
|
|
80
|
+
} from "./cursor-oidc.ts";
|
|
81
|
+
export {
|
|
82
|
+
FACTORY_APP_BROKER_TOKEN_PERMISSIONS,
|
|
74
83
|
GitHubApiError,
|
|
75
84
|
GitHubAppTokenTransportError,
|
|
76
85
|
type GithubAppCredentials,
|
|
86
|
+
type GithubAppInstallationPermissions,
|
|
77
87
|
githubAppJwt,
|
|
78
88
|
type GithubAppTokenOptions,
|
|
79
89
|
mintInstallationToken,
|
|
@@ -92,7 +102,6 @@ export {
|
|
|
92
102
|
FACTORY_PROOF_GATE_IF,
|
|
93
103
|
FACTORY_PROOF_GATE_MODE_OUTPUT,
|
|
94
104
|
FACTORY_PROOF_GATE_OUTPUT,
|
|
95
|
-
FACTORY_PROOF_GATE_PULL_REQUEST_IF,
|
|
96
105
|
FACTORY_PROOF_GATE_REASON_OUTPUT,
|
|
97
106
|
FACTORY_PROOF_GATE_REASONS,
|
|
98
107
|
FACTORY_PROOF_GATE_SHELL,
|
|
@@ -101,7 +110,6 @@ export {
|
|
|
101
110
|
FACTORY_PROOF_GATE_STEP_NAME,
|
|
102
111
|
type FactoryProofGateOptions,
|
|
103
112
|
type FactoryProofGateReason,
|
|
104
|
-
type FactoryProofGateReuse,
|
|
105
113
|
factoryProofGateScript,
|
|
106
114
|
type FactoryProofGateStep,
|
|
107
115
|
factoryProofGateStep,
|
|
@@ -3,6 +3,7 @@ import type { WorkflowStep } from "./factory-workflow.ts";
|
|
|
3
3
|
import { assertPinnedAction } from "./pinned-action.ts";
|
|
4
4
|
import { PREVIEW_PROOF_INVENTORY_LIST_PERMISSIONS } from "./preview-proof-inventory.ts";
|
|
5
5
|
import { FACTORY_PROOF_GATE_APP_ID } from "./proof-reuse-gate.ts";
|
|
6
|
+
import { assertRunnableCli } from "./runnable-cli.ts";
|
|
6
7
|
|
|
7
8
|
export const FACTORY_PR_STATUS_HUD_JOB_ID = "status-hud";
|
|
8
9
|
export const FACTORY_PR_STATUS_HUD_JOB_NAME = "Present PR status HUD";
|
|
@@ -56,6 +57,7 @@ export const factoryPrStatusHudWorkflow = (
|
|
|
56
57
|
"actions/create-github-app-token"
|
|
57
58
|
);
|
|
58
59
|
const cli = options.cli || defaultCli;
|
|
60
|
+
assertRunnableCli("cli", cli);
|
|
59
61
|
// Same unscoped mint as the freeze job. A subset (checks/issues/PRs)
|
|
60
62
|
// 422s: those permissions are not granted to this installation.
|
|
61
63
|
const mintStep: WorkflowStep = Object.freeze({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { WorkflowStep } from "./factory-workflow.ts";
|
|
2
2
|
import { impactDemandConditions } from "./impact-demand-condition.ts";
|
|
3
|
+
import { assertRunnableCli } from "./runnable-cli.ts";
|
|
3
4
|
|
|
4
5
|
export const FACTORY_PRODUCTION_IMPACT_STEP_ID = "production_impact";
|
|
5
6
|
export const FACTORY_PRODUCTION_IMPACT_DECISION_OUTPUT = "decision";
|
|
@@ -71,6 +72,7 @@ export const factoryProductionImpactWorkflow = (
|
|
|
71
72
|
const before = options.before ?? `\${{ github.event.before }}`;
|
|
72
73
|
const after = options.after ?? `\${{ github.sha }}`;
|
|
73
74
|
const cli = options.cli ?? "pnpm exec psf";
|
|
75
|
+
assertRunnableCli("cli", cli);
|
|
74
76
|
const profile = options.profilePath
|
|
75
77
|
? ` --profile ${shellQuote(options.profilePath)}`
|
|
76
78
|
: "";
|
package/src/proof-reuse-gate.ts
CHANGED
|
@@ -3,13 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* It answers one question, before checkout and before install: may this job
|
|
5
5
|
* reuse the local verification the factory already published for *this exact
|
|
6
|
-
* head*?
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* extends "this exact head" to "this exact tree": a squash merge whose result
|
|
10
|
-
* tree is byte-identical to the producing pull request's proven head tree
|
|
11
|
-
* reuses that head's proof (#611). Refusing runs hosted CI; it never fails
|
|
12
|
-
* the candidate. Three
|
|
6
|
+
* head*? It asks that only on a pull request, so every default-branch push
|
|
7
|
+
* executes the full hosted suite (#863). Refusing runs hosted CI; it never
|
|
8
|
+
* fails the candidate. Three
|
|
13
9
|
* repositories had independently grown their own answer to that question and
|
|
14
10
|
* had already drifted apart, so the answer lives here once, with the App
|
|
15
11
|
* identity, check name, step identity, output names, guard, and every trust
|
|
@@ -123,77 +119,25 @@ export const FACTORY_PROOF_GATE_SOURCE_URL_OUTPUT = "source-check-url";
|
|
|
123
119
|
export const FACTORY_PROOF_GATE_GUARD = `steps.${FACTORY_PROOF_GATE_STEP_ID}.outputs.${FACTORY_PROOF_GATE_OUTPUT} != 'true'`;
|
|
124
120
|
|
|
125
121
|
/**
|
|
126
|
-
* Pull requests
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* It buys runner time on the merge target and pays for it in coverage. No
|
|
130
|
-
* local proof is ever written for a merged SHA, so the direct read finds
|
|
131
|
-
* nothing after a squash merge. The push path therefore exists only together
|
|
132
|
-
* with the merge fallback below: when no generation exists at the merged SHA,
|
|
133
|
-
* the gate may look up the producing pull request and reuse its head proof,
|
|
134
|
-
* but only when the merge result's tree is exactly the proven head's tree.
|
|
135
|
-
* Deploy gates keep waiting for a green required check at the merged SHA; a
|
|
136
|
-
* reused proof turns that check green through the same guarded steps a pull
|
|
137
|
-
* request uses.
|
|
138
|
-
*
|
|
139
|
-
* The trade this mode accepts: a candidate's proof may be impact-scoped, and
|
|
140
|
-
* an impact-scoped proof is a sound gate for the candidate and an unsound
|
|
141
|
-
* gate for the merge target. #931 is that defect observed — a `main` push
|
|
142
|
-
* reused a candidate proof, executed no command, and left a red package
|
|
143
|
-
* unnoticed for three days. Choose this mode only when the consumer accepts
|
|
144
|
-
* that risk for speed.
|
|
145
|
-
*
|
|
146
|
-
* The push side is deliberately the merge target only — the repository's
|
|
147
|
-
* default branch — not every pushed branch. Which branches trigger a
|
|
148
|
-
* consumer's workflow at all stays repository-owned; this condition only
|
|
149
|
-
* keeps the gate from consulting proof on pushes that are not merges into
|
|
150
|
-
* the default branch.
|
|
151
|
-
*/
|
|
152
|
-
export const FACTORY_PROOF_GATE_IF =
|
|
153
|
-
"github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))";
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Pull requests only — the **default** condition, and the condition a
|
|
157
|
-
* consumer gets with `reuse: "pull-request"` (#873, default since #863).
|
|
122
|
+
* Pull requests only — the gate's single condition (#873, default since #863,
|
|
123
|
+
* the only mode since the merge-target opt-out was deleted).
|
|
158
124
|
*
|
|
159
125
|
* A merge target's guarantee is about the whole tree, not the diff. A
|
|
160
126
|
* candidate proof only ever binds the diff the candidate was scoped to, so
|
|
161
127
|
* reusing it on the merged commit proves less than the merge target needs.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
128
|
+
* #931 is that defect observed — a `main` push reused a candidate proof,
|
|
129
|
+
* executed no command, and left a red package unnoticed for three days. Every
|
|
130
|
+
* default-branch push therefore executes the full hosted suite, whatever
|
|
131
|
+
* proof exists.
|
|
166
132
|
*
|
|
167
133
|
* Which branches trigger the workflow at all stays repository-owned. This
|
|
168
134
|
* condition only keeps the gate from consulting proof outside a pull request.
|
|
169
135
|
*/
|
|
170
|
-
export const
|
|
171
|
-
"github.event_name == 'pull_request'";
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Which events may reuse proof.
|
|
175
|
-
*
|
|
176
|
-
* A two-value enum, not a free-text condition: a string lets a consumer write
|
|
177
|
-
* a condition this package cannot reason about — one that reuses proof on an
|
|
178
|
-
* unproven ref — and the emitted condition is a trust predicate. The consumer
|
|
179
|
-
* chooses the mode; `factory-ci` owns what each mode emits.
|
|
180
|
-
*
|
|
181
|
-
* - `pull-request` (default) pull requests only. Default-branch pushes
|
|
182
|
-
* execute the full hosted suite, and the merge fallback is not emitted.
|
|
183
|
-
* - `pull-request-and-default-branch` the opt-out: pull requests plus pushes
|
|
184
|
-
* to the merge target, with the merge fallback. It is faster on the merge
|
|
185
|
-
* target and it lets an impact-scoped candidate proof gate a whole tree
|
|
186
|
-
* (#931), so a consumer must ask for it.
|
|
187
|
-
*/
|
|
188
|
-
export type FactoryProofGateReuse =
|
|
189
|
-
| "pull-request"
|
|
190
|
-
| "pull-request-and-default-branch";
|
|
136
|
+
export const FACTORY_PROOF_GATE_IF = "github.event_name == 'pull_request'";
|
|
191
137
|
|
|
192
138
|
/**
|
|
193
139
|
* The complete refusal vocabulary. Deliberately few, because these are the
|
|
194
140
|
* only distinctions the gate can honestly make from its Checks API reads.
|
|
195
|
-
* The merge fallback adds no words: a fallback that establishes nothing
|
|
196
|
-
* leaves the direct read's refusal standing, with the specifics in `detail`.
|
|
197
141
|
*
|
|
198
142
|
* - `proven` a covering, passing, unambiguous proof for this exact head
|
|
199
143
|
* - `none` no App-verified factory check run at this commit
|
|
@@ -537,109 +481,7 @@ const safeLabel = (surface: string): string => {
|
|
|
537
481
|
return cleaned.length > 0 ? cleaned : "verification";
|
|
538
482
|
};
|
|
539
483
|
|
|
540
|
-
|
|
541
|
-
* The push-event merge fallback (#611), emitted only for
|
|
542
|
-
* `reuse: "pull-request-and-default-branch"`. The default `"pull-request"`
|
|
543
|
-
* mode omits it: that mode's whole point is that a default-branch push
|
|
544
|
-
* executes everything, so a push path that could still reuse proof would
|
|
545
|
-
* contradict the condition above it.
|
|
546
|
-
*/
|
|
547
|
-
const MERGE_FALLBACK_BLOCK = String.raw`# Merge fallback (#611). A squash merge mints a new commit, so the direct
|
|
548
|
-
# read at a pushed merge-target head finds nothing even when the factory
|
|
549
|
-
# proved the producing pull request head. Only when the direct read found no
|
|
550
|
-
# generation at all on a push event, look up the producing pull request and
|
|
551
|
-
# reuse its head proof — and only when the merge commit's TREE id equals the
|
|
552
|
-
# proven head's tree id, which makes the pushed content byte-identical to
|
|
553
|
-
# what was verified (a clean squash of an unchanged tip). Patch identity was
|
|
554
|
-
# considered and rejected as the comparator: git patch-id normalizes
|
|
555
|
-
# whitespace and ignores base motion, so an identical patch can still
|
|
556
|
-
# produce an integrated tree that was never tested. Everything else — no
|
|
557
|
-
# unique merged producing PR, an unreadable commit, a proof that is anything
|
|
558
|
-
# but proven, or tree drift from a dirty or stale merge — leaves the direct
|
|
559
|
-
# "none" refusal standing, so the full suite runs (fail open).
|
|
560
|
-
if [ "$reason" = 'none' ] && [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ]; then
|
|
561
|
-
producing_head=''
|
|
562
|
-
merge_tree=''
|
|
563
|
-
head_tree=''
|
|
564
|
-
if ! producing_pulls=$(gh api --method GET --paginate \
|
|
565
|
-
"repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/pulls" \
|
|
566
|
-
-f per_page=100 2>&1); then
|
|
567
|
-
detail="producing pull request unreadable: $producing_pulls"
|
|
568
|
-
elif ! producing_head=$(printf '%s' "$producing_pulls" | jq -r -s \
|
|
569
|
-
--arg sha "$HEAD_SHA" \
|
|
570
|
-
'[ add[]?
|
|
571
|
-
| select((.merged_at // null) != null)
|
|
572
|
-
| select((.merge_commit_sha // "") == $sha)
|
|
573
|
-
| ((.head.sha // "") | tostring) ]
|
|
574
|
-
| if length == 1 then .[0] else "" end' 2>&1); then
|
|
575
|
-
detail="producing pull request unreadable: $producing_head"
|
|
576
|
-
producing_head=''
|
|
577
|
-
elif ! [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]]; then
|
|
578
|
-
detail='no single merged producing pull request at this commit'
|
|
579
|
-
producing_head=''
|
|
580
|
-
elif ! merge_commit=$(gh api --method GET \
|
|
581
|
-
"repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA" 2>&1); then
|
|
582
|
-
detail="merge commit unreadable: $merge_commit"
|
|
583
|
-
elif ! merge_tree=$(printf '%s' "$merge_commit" | jq -r \
|
|
584
|
-
'(.commit.tree.sha // "") | tostring' 2>&1); then
|
|
585
|
-
detail="merge commit unreadable: $merge_tree"
|
|
586
|
-
merge_tree=''
|
|
587
|
-
elif ! [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]]; then
|
|
588
|
-
detail='merge commit carries no readable tree id'
|
|
589
|
-
merge_tree=''
|
|
590
|
-
elif ! head_commit=$(gh api --method GET \
|
|
591
|
-
"repos/$GITHUB_REPOSITORY/commits/$producing_head" 2>&1); then
|
|
592
|
-
detail="producing head commit unreadable: $head_commit"
|
|
593
|
-
elif ! head_tree=$(printf '%s' "$head_commit" | jq -r \
|
|
594
|
-
'(.commit.tree.sha // "") | tostring' 2>&1); then
|
|
595
|
-
detail="producing head commit unreadable: $head_tree"
|
|
596
|
-
head_tree=''
|
|
597
|
-
elif ! [[ "$head_tree" =~ ^[0-9a-f]{40}$ ]]; then
|
|
598
|
-
detail='producing head commit carries no readable tree id'
|
|
599
|
-
head_tree=''
|
|
600
|
-
elif [ "$merge_tree" != "$head_tree" ]; then
|
|
601
|
-
detail='tree drift: the merge result is not the proven head tree'
|
|
602
|
-
merge_tree=''
|
|
603
|
-
fi
|
|
604
|
-
if [[ "$producing_head" =~ ^[0-9a-f]{40}$ ]] \
|
|
605
|
-
&& [[ "$merge_tree" =~ ^[0-9a-f]{40}$ ]] \
|
|
606
|
-
&& [ "$merge_tree" = "$head_tree" ]; then
|
|
607
|
-
gate_lookup "$producing_head"
|
|
608
|
-
if [ "$lookup_reason" = 'proven' ]; then
|
|
609
|
-
reason=proven
|
|
610
|
-
mode="$lookup_mode"
|
|
611
|
-
missing=''
|
|
612
|
-
source_url="$lookup_url"
|
|
613
|
-
proof_head="$producing_head"
|
|
614
|
-
merge_note="the merge result's tree is byte-identical to the tree proven at pull request head $producing_head"
|
|
615
|
-
else
|
|
616
|
-
detail="producing pull request proof not reusable ($lookup_reason)"
|
|
617
|
-
fi
|
|
618
|
-
fi
|
|
619
|
-
fi`;
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* The fallback with the blank lines that surround it, or nothing at all. Kept
|
|
623
|
-
* as one piece so `pull-request-and-default-branch` emits the exact script
|
|
624
|
-
* that mode emitted before the option existed.
|
|
625
|
-
*
|
|
626
|
-
* Tests for the opt-out value, matching `factoryProofGateStep`'s condition
|
|
627
|
-
* ternary. The polarity is the point: the condition and the script are two
|
|
628
|
-
* halves of one decision, so an unrecognized value — an untyped JavaScript
|
|
629
|
-
* consumer's typo — must resolve to the safe default at both sites. Testing
|
|
630
|
-
* for `"pull-request"` here would pair the PR-only condition with a script
|
|
631
|
-
* that still carries the fallback, a combination no mode means.
|
|
632
|
-
*/
|
|
633
|
-
const mergeFallbackSection = (reuse: FactoryProofGateReuse): string =>
|
|
634
|
-
reuse === "pull-request-and-default-branch"
|
|
635
|
-
? `\n${MERGE_FALLBACK_BLOCK}\n\n`
|
|
636
|
-
: "\n";
|
|
637
|
-
|
|
638
|
-
const gateScript = (
|
|
639
|
-
required: readonly string[],
|
|
640
|
-
surface: string,
|
|
641
|
-
reuse: FactoryProofGateReuse
|
|
642
|
-
): string =>
|
|
484
|
+
const gateScript = (required: readonly string[], surface: string): string =>
|
|
643
485
|
String.raw`
|
|
644
486
|
set -uo pipefail
|
|
645
487
|
|
|
@@ -653,8 +495,6 @@ detail=''
|
|
|
653
495
|
mode=''
|
|
654
496
|
missing=''
|
|
655
497
|
source_url=''
|
|
656
|
-
proof_head="${shellExpansion("HEAD_SHA:-")}"
|
|
657
|
-
merge_note=''
|
|
658
498
|
|
|
659
499
|
# One proof lookup at one commit. Sets lookup_reason / lookup_mode /
|
|
660
500
|
# lookup_missing / lookup_url, or lookup_detail when the read itself
|
|
@@ -727,7 +567,8 @@ else
|
|
|
727
567
|
missing="$lookup_missing"
|
|
728
568
|
source_url="$lookup_url"
|
|
729
569
|
fi
|
|
730
|
-
|
|
570
|
+
|
|
571
|
+
# The binding is App-verified, but it still reaches markdown. Only a plain
|
|
731
572
|
# lowercase token is quoted back; anything else is reported as unknown.
|
|
732
573
|
case "$mode" in
|
|
733
574
|
'') ;;
|
|
@@ -755,7 +596,7 @@ say ''
|
|
|
755
596
|
|
|
756
597
|
case "$reason" in
|
|
757
598
|
proven)
|
|
758
|
-
echo "Factory proof: reusing local verification of $
|
|
599
|
+
echo "Factory proof: reusing local verification of $HEAD_SHA; skipping the $SURFACE suite."
|
|
759
600
|
say "Skipped. The factory already verified this exact content, so this job did not run the $SURFACE suite a second time."
|
|
760
601
|
say ''
|
|
761
602
|
if [ -n "$source_url" ]; then
|
|
@@ -763,20 +604,13 @@ case "$reason" in
|
|
|
763
604
|
else
|
|
764
605
|
say "- Reused proof: the \`$CHECK_NAME\` check run published by the pinned factory GitHub App."
|
|
765
606
|
fi
|
|
766
|
-
say "- Covers head: \`$
|
|
767
|
-
if [ -n "$merge_note" ]; then
|
|
768
|
-
say "- Merge-target reuse: $merge_note."
|
|
769
|
-
fi
|
|
607
|
+
say "- Covers head: \`$HEAD_SHA\`"
|
|
770
608
|
say "- Recorded mode: \`$mode\` (diagnostic only), outcome \`passed\`."
|
|
771
609
|
;;
|
|
772
610
|
none)
|
|
773
611
|
say 'Ran the full suite. No trusted factory proof covers this commit.'
|
|
774
612
|
say ''
|
|
775
613
|
say "The factory GitHub App has published no \`$CHECK_NAME\` check run at head \`$HEAD_SHA\`. Proof is keyed to the commit, so proof written for any other commit is not visible here. A pull request opened by hand carries no factory proof at all, which is normal."
|
|
776
|
-
if [ -n "$detail" ]; then
|
|
777
|
-
say ''
|
|
778
|
-
say "- Merge fallback: $detail"
|
|
779
|
-
fi
|
|
780
614
|
say ''
|
|
781
615
|
say '${CORRECTIVE_LINE}'
|
|
782
616
|
;;
|
|
@@ -846,13 +680,6 @@ export interface FactoryProofGateOptions {
|
|
|
846
680
|
* therefore requires a different coverage.
|
|
847
681
|
*/
|
|
848
682
|
readonly commands: readonly ProofReuseCommand[];
|
|
849
|
-
/**
|
|
850
|
-
* Which events may reuse proof. Defaults to `"pull-request"`: every
|
|
851
|
-
* default-branch push executes the full hosted suite (#873, #863).
|
|
852
|
-
* `"pull-request-and-default-branch"` is the opt-out a consumer chooses for
|
|
853
|
-
* speed on the merge target, and it accepts #931's risk.
|
|
854
|
-
*/
|
|
855
|
-
readonly reuse?: FactoryProofGateReuse;
|
|
856
683
|
/** Names the suite in the job summary. Changes no trust decision. */
|
|
857
684
|
readonly surface: string;
|
|
858
685
|
}
|
|
@@ -871,12 +698,11 @@ export interface FactoryProofGateOptions {
|
|
|
871
698
|
*/
|
|
872
699
|
export const factoryProofGateScript = ({
|
|
873
700
|
commands,
|
|
874
|
-
reuse = "pull-request",
|
|
875
701
|
surface,
|
|
876
702
|
}: FactoryProofGateOptions): string => {
|
|
877
703
|
const required = proofReuseRequiredCommands(commands);
|
|
878
704
|
return required
|
|
879
|
-
? gateScript(required, safeLabel(surface)
|
|
705
|
+
? gateScript(required, safeLabel(surface))
|
|
880
706
|
: UNUSABLE_SELECTION_SCRIPT;
|
|
881
707
|
};
|
|
882
708
|
|
|
@@ -887,9 +713,7 @@ export interface FactoryProofGateStep {
|
|
|
887
713
|
HEAD_SHA: string;
|
|
888
714
|
}>;
|
|
889
715
|
readonly id: typeof FACTORY_PROOF_GATE_STEP_ID;
|
|
890
|
-
readonly if:
|
|
891
|
-
| typeof FACTORY_PROOF_GATE_IF
|
|
892
|
-
| typeof FACTORY_PROOF_GATE_PULL_REQUEST_IF;
|
|
716
|
+
readonly if: typeof FACTORY_PROOF_GATE_IF;
|
|
893
717
|
readonly name: string;
|
|
894
718
|
readonly run: string;
|
|
895
719
|
readonly shell: typeof FACTORY_PROOF_GATE_SHELL;
|
|
@@ -899,14 +723,8 @@ export interface FactoryProofGateStep {
|
|
|
899
723
|
* The step itself, structurally accepted by gagen's `step()` without adding a
|
|
900
724
|
* gagen runtime dependency. It belongs first in the job it guards: a few API
|
|
901
725
|
* reads with the default `GITHUB_TOKEN`, no checkout, no install, so a proven
|
|
902
|
-
* head costs a runner nothing beyond job startup. The
|
|
903
|
-
*
|
|
904
|
-
* needs only `checks: read` (#873, #863). A consumer that opts into
|
|
905
|
-
* `reuse: "pull-request-and-default-branch"` also runs the push-event merge
|
|
906
|
-
* fallback, which reads the producing pull request (`pull-requests: read`)
|
|
907
|
-
* and the two commit objects whose tree ids it compares (`contents: read`).
|
|
908
|
-
* A job that grants less loses only the fallback — the failed read degrades
|
|
909
|
-
* to the full suite.
|
|
726
|
+
* head costs a runner nothing beyond job startup. The step runs on pull
|
|
727
|
+
* requests only, so it needs only `checks: read` (#873, #863).
|
|
910
728
|
*
|
|
911
729
|
* A **step, not a job**, and that is not a style preference. A separate gate
|
|
912
730
|
* job that errored would leave the guarded job `skipped`, and a summary job
|
|
@@ -922,23 +740,15 @@ export const factoryProofGateStep = (
|
|
|
922
740
|
continueOnError: true as const,
|
|
923
741
|
env: Object.freeze({
|
|
924
742
|
GH_TOKEN: githubExpression("secrets.GITHUB_TOKEN"),
|
|
925
|
-
// On a pull request the verified head is the PR branch tip
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
// fallback resolves from (#611). Under the PR-only default the step does
|
|
929
|
-
// not run on a push, so only the pull-request half is ever read.
|
|
743
|
+
// On a pull request the verified head is the PR branch tip. The step
|
|
744
|
+
// never runs on a push, so no reachable path reads the `github.sha`
|
|
745
|
+
// half.
|
|
930
746
|
HEAD_SHA: githubExpression(
|
|
931
747
|
"github.event.pull_request.head.sha || github.sha"
|
|
932
748
|
),
|
|
933
749
|
}),
|
|
934
750
|
id: FACTORY_PROOF_GATE_STEP_ID,
|
|
935
|
-
|
|
936
|
-
// this ternary tests for. Testing for `"pull-request"` instead would
|
|
937
|
-
// leave `undefined` on the push clause and silently keep the old default.
|
|
938
|
-
if:
|
|
939
|
-
options.reuse === "pull-request-and-default-branch"
|
|
940
|
-
? FACTORY_PROOF_GATE_IF
|
|
941
|
-
: FACTORY_PROOF_GATE_PULL_REQUEST_IF,
|
|
751
|
+
if: FACTORY_PROOF_GATE_IF,
|
|
942
752
|
name: FACTORY_PROOF_GATE_STEP_NAME,
|
|
943
753
|
run: factoryProofGateScript(options),
|
|
944
754
|
// Never omit: the runner's default `run:` shell supplies `-e`, which
|
|
@@ -61,10 +61,8 @@ say ''
|
|
|
61
61
|
|
|
62
62
|
if [ "${shellExpansion("GITHUB_EVENT_NAME:-")}" = 'push' ] && [ -z "${shellExpansion("PROOF_REASON:-")}" ]; then
|
|
63
63
|
# A push run with no recorded reason is one where the gate step's condition
|
|
64
|
-
# kept it off entirely, so the full suite ran with no reuse decision.
|
|
65
|
-
#
|
|
66
|
-
# 'pull-request-and-default-branch' reaches it only on a ref outside its
|
|
67
|
-
# merge target.
|
|
64
|
+
# kept it off entirely, so the full suite ran with no reuse decision. The
|
|
65
|
+
# gate runs on pull requests only (#863), so that is every push.
|
|
68
66
|
say '- Path: full execution, no proof consulted'
|
|
69
67
|
say '- Proof-reuse gate: did not run on this ref; the full suite ran.'
|
|
70
68
|
say "- Verified head: \`${shellExpansion("GITHUB_SHA:-unknown")}\`"
|
|
@@ -109,10 +107,8 @@ export const factoryProofReuseSummaryStep = (
|
|
|
109
107
|
CI_STARTED_MS: githubExpression(
|
|
110
108
|
`steps.${FACTORY_PROOF_TIMING_STEP_ID}.outputs.started_ms`
|
|
111
109
|
),
|
|
112
|
-
// PR runs bind to the PR branch tip; push runs to the pushed head
|
|
113
|
-
//
|
|
114
|
-
// fallback resolves from (#611). Under the PR-only default no push run
|
|
115
|
-
// records a reason, and this value only labels the summary.
|
|
110
|
+
// PR runs bind to the PR branch tip; push runs to the pushed head. No
|
|
111
|
+
// push run records a reason, and this value only labels the summary.
|
|
116
112
|
PROOF_HEAD_SHA: githubExpression(
|
|
117
113
|
"github.event.pull_request.head.sha || github.sha"
|
|
118
114
|
),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@patronage/software-factory` publishes two dist entries. `dist/index.js` is
|
|
3
|
+
* the package export: consumers import it, so it runs no command. `dist/cli.js`
|
|
4
|
+
* is the bin, and it is the only entry that runs one (software-factory#386).
|
|
5
|
+
*
|
|
6
|
+
* A workflow step that invokes `dist/index.js` therefore loads a library and
|
|
7
|
+
* exits 0 — a green step that did nothing. This guard makes a generator refuse
|
|
8
|
+
* that `cli` at generation time, where the failure is loud, instead of letting
|
|
9
|
+
* it reach CI where the failure is silent. Invoke the bin by name (`psf`,
|
|
10
|
+
* `pnpm exec psf`, `pnpm dlx psf`) or by the `dist/cli.js` path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// A token match, not a shell parser. The terminator admits the quote
|
|
14
|
+
// characters as well as whitespace, so `node "…/dist/index.js"` and the
|
|
15
|
+
// single-quoted form are caught too. It stays deliberately short of parsing
|
|
16
|
+
// the command: a `cli` string that hides the path from this scan is out of
|
|
17
|
+
// scope for a text guard.
|
|
18
|
+
const INERT_ENTRY_PATTERN = /(?:^|[/\\])dist[/\\]index\.js(?:["'\s]|$)/u;
|
|
19
|
+
|
|
20
|
+
/** Shared validator for every `cli` string a factory-ci generator emits. */
|
|
21
|
+
export const assertRunnableCli = (name: string, cli: string): void => {
|
|
22
|
+
if (INERT_ENTRY_PATTERN.test(cli)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`${name} must invoke the software-factory bin, not the package entry dist/index.js, which imports as a library and exits 0 without running the command. Use the bin name (for example "pnpm exec psf") or dist/cli.js. Got "${cli}".`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
};
|