@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/README.md +39 -1
- package/dist/index.d.ts +196 -6
- package/dist/index.js +303 -90
- package/package.json +1 -1
- package/src/index.ts +17 -0
- package/src/merge-freeze-job.ts +230 -0
- package/src/pr-status-hud-workflow.ts +6 -8
- package/src/preview-proof-inventory.ts +20 -2
- package/src/proof-reuse-gate.ts +148 -81
- package/src/push-identity-workflow.ts +11 -10
|
@@ -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.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 =
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/proof-reuse-gate.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|