@patronage/software-factory 1.0.0-alpha.34 → 1.0.0-alpha.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +288 -170
- package/dist/index.js +1559 -1175
- package/dist/schemas.d.ts +177 -177
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { z } from "zod";
|
|
|
6
6
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
7
7
|
import * as nodeFs from "node:fs";
|
|
8
8
|
import { appendFileSync, closeSync, constants, cpSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { isDeepStrictEqual, promisify } from "node:util";
|
|
10
|
+
import { FACTORY_APP_BROKER_ORIGIN, FACTORY_APP_BROKER_PATH, FACTORY_CURSOR_OIDC_AUDIENCE, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, previewProofInventory, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
|
|
9
11
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
10
12
|
import picomatch from "picomatch";
|
|
11
13
|
import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
@@ -14,13 +16,11 @@ import { setImmediate } from "node:timers";
|
|
|
14
16
|
import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
15
17
|
import { Worker } from "node:worker_threads";
|
|
16
18
|
import { parse } from "yaml";
|
|
17
|
-
import { promisify } from "node:util";
|
|
18
|
-
import { FACTORY_APP_BROKER_ORIGIN, FACTORY_APP_BROKER_PATH, FACTORY_CURSOR_OIDC_AUDIENCE, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, previewProofInventory, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
|
|
19
19
|
import http from "node:http";
|
|
20
20
|
import { pathToFileURL } from "node:url";
|
|
21
21
|
//#region package.json
|
|
22
22
|
var name = "@patronage/software-factory";
|
|
23
|
-
var version = "1.0.0-alpha.
|
|
23
|
+
var version = "1.0.0-alpha.35";
|
|
24
24
|
//#endregion
|
|
25
25
|
//#region src/cli-entry.ts
|
|
26
26
|
/**
|
|
@@ -7082,1239 +7082,1351 @@ const RESOLVED_PR_VERIFY_MODES = new Set([
|
|
|
7082
7082
|
]);
|
|
7083
7083
|
const isResolvedPrVerifyMode = (value) => RESOLVED_PR_VERIFY_MODES.has(value);
|
|
7084
7084
|
//#endregion
|
|
7085
|
-
//#region src/
|
|
7085
|
+
//#region src/catch-up-recognition-record.ts
|
|
7086
|
+
const objectShaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
7087
|
+
const catchUpRecognitionSchema = z.object({
|
|
7088
|
+
baseRef: z.string().min(1),
|
|
7089
|
+
baseTipSha: objectShaSchema,
|
|
7090
|
+
mergedParentSha: objectShaSchema,
|
|
7091
|
+
mergedTreeSha: objectShaSchema,
|
|
7092
|
+
upstreamRef: z.string().min(1)
|
|
7093
|
+
}).strict();
|
|
7094
|
+
//#endregion
|
|
7095
|
+
//#region src/policy-resolution.ts
|
|
7096
|
+
const COMMIT_SHA_PATTERN$3 = /^[0-9a-f]{40}$/u;
|
|
7097
|
+
const digestSchema = z.string().regex(/^[0-9a-f]{64}$/u);
|
|
7086
7098
|
/**
|
|
7087
|
-
*
|
|
7088
|
-
*
|
|
7099
|
+
* The authority resolved: the live tip of the PR's own base ref served a
|
|
7100
|
+
* parseable profile, and demand was resolved from the union of that policy and
|
|
7101
|
+
* the candidate's.
|
|
7089
7102
|
*
|
|
7090
|
-
*
|
|
7091
|
-
*
|
|
7092
|
-
*
|
|
7093
|
-
* is a
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
const PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION = 1;
|
|
7097
|
-
const OUTCOMES = ["aborted", "passed"];
|
|
7098
|
-
const isOutcome = (value) => OUTCOMES.includes(value);
|
|
7099
|
-
/**
|
|
7100
|
-
* The payload's outcome must agree with the check run's own conclusion, which
|
|
7101
|
-
* comes from `verifyProofPassed`: a proof with no recorded outcome is
|
|
7102
|
-
* unfinished (fail closed).
|
|
7103
|
+
* Every identity field is REQUIRED. A cross-stage reader compares these digests
|
|
7104
|
+
* to decide whether two stages judged the same candidate under the same policy;
|
|
7105
|
+
* an `authoritative` record with a digest missing would read to that reader as
|
|
7106
|
+
* "nothing disagrees", which is a silent downgrade of exactly the demand this
|
|
7107
|
+
* record exists to protect. `reason` is forbidden here — a resolved authority
|
|
7108
|
+
* has nothing to explain.
|
|
7103
7109
|
*/
|
|
7104
|
-
const
|
|
7105
|
-
|
|
7110
|
+
const authoritativePolicyResolutionSchema = z.object({
|
|
7111
|
+
/** sha256 of the canonical base `review` subtree. */
|
|
7112
|
+
basePolicyDigest: digestSchema,
|
|
7113
|
+
/** The PR's own base ref, whose live tip is the authority. */
|
|
7114
|
+
baseRefName: z.string().min(1),
|
|
7115
|
+
/** sha256 of the canonical candidate `review` subtree. */
|
|
7116
|
+
candidatePolicyDigest: digestSchema,
|
|
7117
|
+
/** sha256 of the merged (base ∪ candidate) protected-path set. */
|
|
7118
|
+
effectiveDigest: digestSchema,
|
|
7119
|
+
/** The base-ref tip the base policy was read at. */
|
|
7120
|
+
policyBaseSha: z.string().regex(COMMIT_SHA_PATTERN$3),
|
|
7121
|
+
/**
|
|
7122
|
+
* Whether the candidate's `review` subtree differs from the base's. The
|
|
7123
|
+
* digests prove which policies were read; this says whether they agreed,
|
|
7124
|
+
* which is the fact that explains a demand the candidate's own profile
|
|
7125
|
+
* would not have produced.
|
|
7126
|
+
*/
|
|
7127
|
+
reviewPolicyChanged: z.boolean(),
|
|
7128
|
+
status: z.literal("authoritative")
|
|
7129
|
+
}).strict();
|
|
7106
7130
|
/**
|
|
7107
|
-
*
|
|
7108
|
-
*
|
|
7109
|
-
*
|
|
7110
|
-
*
|
|
7131
|
+
* The authority could not be read, and `reason` says why — required, because an
|
|
7132
|
+
* unresolved record with no reason is an unactionable refusal. `pr:ready`
|
|
7133
|
+
* refuses before it writes a proof, so a Slice A ready proof never carries one;
|
|
7134
|
+
* this member is the recorded form of that refusal for the stages that carry it
|
|
7135
|
+
* forward.
|
|
7111
7136
|
*/
|
|
7112
|
-
const
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
|
|
7122
|
-
mode: value.mode,
|
|
7123
|
-
...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
|
|
7124
|
-
outcome: outcomeFor$1(value),
|
|
7125
|
-
proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
|
|
7126
|
-
schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
|
|
7127
|
-
...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
|
|
7128
|
-
...typeof value.repository === "string" ? { repository: value.repository } : {},
|
|
7129
|
-
...carriesReleases && value.verificationCommands !== void 0 ? { verificationCommands: value.verificationCommands } : {}
|
|
7130
|
-
};
|
|
7131
|
-
};
|
|
7132
|
-
const renderPrVerifyCheckPayloadText = (payload) => `\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
7137
|
+
const unresolvedPolicyResolutionSchema = z.object({
|
|
7138
|
+
/** The base ref whose policy could not be read. */
|
|
7139
|
+
baseRefName: z.string().min(1),
|
|
7140
|
+
/** Why the authority is unresolved. */
|
|
7141
|
+
reason: z.string().min(1),
|
|
7142
|
+
status: z.literal("unresolved")
|
|
7143
|
+
}).strict();
|
|
7144
|
+
/** How a gate resolved the review policy it evaluated a candidate under. */
|
|
7145
|
+
const policyResolutionSchema = z.discriminatedUnion("status", [authoritativePolicyResolutionSchema, unresolvedPolicyResolutionSchema]);
|
|
7133
7146
|
//#endregion
|
|
7134
|
-
//#region src/
|
|
7135
|
-
const
|
|
7136
|
-
"pr-ready": "patronage-factory/pr-ready",
|
|
7137
|
-
"pr-verify": "patronage-factory/pr-verify"
|
|
7138
|
-
};
|
|
7139
|
-
const hqLaneRefUrl = (base, repo, number) => `${base}/${encodeURIComponent(repo)}/${number}`;
|
|
7147
|
+
//#region src/verification-battery.ts
|
|
7148
|
+
const UNCONDITIONAL_BASIS = "unconditional: the profile declares no impactTarget for this command";
|
|
7140
7149
|
/**
|
|
7141
|
-
*
|
|
7142
|
-
*
|
|
7150
|
+
* The `vetoedTargets` contract, enforced at RUNTIME as well as at the type
|
|
7151
|
+
* level. TypeScript makes the parameter required for callers it compiles;
|
|
7152
|
+
* `new Set(undefined)` is a perfectly good empty set, so an untyped JavaScript
|
|
7153
|
+
* caller that omits it would otherwise scope exactly as if it had inspected
|
|
7154
|
+
* the envelopes and found nothing — the silent state requiring the parameter
|
|
7155
|
+
* exists to eliminate.
|
|
7143
7156
|
*
|
|
7144
|
-
*
|
|
7145
|
-
*
|
|
7146
|
-
*
|
|
7147
|
-
* #473 wave 2 measured — with no rollup row saying why. So no `pr-ready`
|
|
7148
|
-
* publication comes through here at all: `pr:ready` publishes once, completed,
|
|
7149
|
-
* through {@link ensureFactoryCheckRunPublished}, whose result the caller
|
|
7150
|
-
* reads. It used to publish an in-progress run here while hosted checks
|
|
7151
|
-
* settled, on the reasoning that a missing in-progress check cannot green
|
|
7152
|
-
* anything — true, and beside the point, because a *present* one cannot be
|
|
7153
|
-
* un-blocked and nothing ever completed it (#526).
|
|
7154
|
-
*/
|
|
7155
|
-
function publishFactoryCheckSafely(publisher, input) {
|
|
7156
|
-
try {
|
|
7157
|
-
publisher?.(input);
|
|
7158
|
-
} catch (error) {
|
|
7159
|
-
console.warn(`${FACTORY_CHECK_NAMES[input.gate]} proof mirror failed without affecting the gate: ${error instanceof Error ? error.message : String(error)}`);
|
|
7160
|
-
}
|
|
7161
|
-
}
|
|
7162
|
-
/**
|
|
7163
|
-
* The detached, fire-and-forget publisher does not retry: it runs on a tail the
|
|
7164
|
-
* process may never await, so sleeping there buys no durability. Reliability is
|
|
7165
|
-
* bought on the awaited path (`ensureFactoryCheckRunPublished`), which the
|
|
7166
|
-
* composed `pr:publish` flow runs once the head SHA is on GitHub.
|
|
7167
|
-
*/
|
|
7168
|
-
const DEFAULT_CHECK_RUN_RETRY = {
|
|
7169
|
-
attempts: 1,
|
|
7170
|
-
delayMs: 0
|
|
7171
|
-
};
|
|
7172
|
-
/**
|
|
7173
|
-
* The awaited path blocks `pr:publish`, so it is bounded twice: by attempts and
|
|
7174
|
-
* by a wall-clock budget. Publication can no longer fail the gate; it must not
|
|
7175
|
-
* be able to hang it either.
|
|
7157
|
+
* A malformed CALL is API misuse, not data doubt: it fails loudly (ADR 0024)
|
|
7158
|
+
* rather than degrading to the conservative floor, because a producer that
|
|
7159
|
+
* never resolved the veto set has a bug its author must see.
|
|
7176
7160
|
*/
|
|
7177
|
-
const
|
|
7178
|
-
|
|
7179
|
-
budgetMs: 2e4,
|
|
7180
|
-
delayMs: 750
|
|
7181
|
-
};
|
|
7182
|
-
const defaultSleep = async (ms) => {
|
|
7183
|
-
const { setTimeout: delay } = await import("node:timers/promises");
|
|
7184
|
-
await delay(ms);
|
|
7161
|
+
const assertVetoedTargets = (vetoedTargets, caller) => {
|
|
7162
|
+
if (!Array.isArray(vetoedTargets)) throw new TypeError(`${caller} requires vetoedTargets: the target names with a current, candidate-bound FAILING envelope. Resolve it with boundFailingCheckNames and pass the result; pass [] to mean "inspected the envelopes and found no bound failure". It has no default because omitting it would silently scope work whose standing demand nothing on this head could meet.`);
|
|
7185
7163
|
};
|
|
7186
7164
|
/**
|
|
7187
|
-
*
|
|
7188
|
-
*
|
|
7189
|
-
*
|
|
7190
|
-
*
|
|
7191
|
-
*
|
|
7165
|
+
* Plan one verification battery against the candidate's impact stamp.
|
|
7166
|
+
*
|
|
7167
|
+
* Pure and total: it makes a decision, it never reads a proof, a profile file,
|
|
7168
|
+
* or the filesystem, and no INPUT can make it throw — every doubt path is a
|
|
7169
|
+
* decision, not an exception. A malformed CALL is the one exception to that,
|
|
7170
|
+
* and deliberately so: omitting `vetoedTargets` is API misuse rather than data
|
|
7171
|
+
* doubt, and it throws (see {@link assertVetoedTargets}).
|
|
7172
|
+
*
|
|
7173
|
+
* `stamp` must already be trusted by
|
|
7174
|
+
* the caller — inside `pr:verify` that is the stamp just computed for this
|
|
7175
|
+
* candidate's own identity triple; anywhere else it is `trustedImpactStamp`'s
|
|
7176
|
+
* output or `undefined`.
|
|
7192
7177
|
*/
|
|
7193
|
-
const
|
|
7194
|
-
|
|
7195
|
-
|
|
7178
|
+
const planVerificationBattery = ({ commands, stamp, vetoedTargets }) => {
|
|
7179
|
+
assertVetoedTargets(vetoedTargets, "planVerificationBattery");
|
|
7180
|
+
const vetoed = new Set(vetoedTargets);
|
|
7181
|
+
const dispositions = [];
|
|
7182
|
+
const execute = [];
|
|
7183
|
+
const notRequired = [];
|
|
7184
|
+
for (const command of commands) {
|
|
7185
|
+
const { impactTarget, name } = command;
|
|
7186
|
+
if (impactTarget === void 0) {
|
|
7187
|
+
dispositions.push({
|
|
7188
|
+
basis: UNCONDITIONAL_BASIS,
|
|
7189
|
+
disposition: "executed",
|
|
7190
|
+
name
|
|
7191
|
+
});
|
|
7192
|
+
execute.push(command);
|
|
7193
|
+
continue;
|
|
7194
|
+
}
|
|
7195
|
+
const decision = impactStampScopeDecision({
|
|
7196
|
+
stamp,
|
|
7197
|
+
surface: "verification-battery",
|
|
7198
|
+
targetName: impactTarget
|
|
7199
|
+
});
|
|
7200
|
+
const vetoedHere = decision.scoped && vetoed.has(impactTarget);
|
|
7201
|
+
const basis = vetoedHere ? `veto: a current envelope bound to this candidate records target "${impactTarget}" as FAILING, so the stamp's release is withheld (${decision.reason})` : decision.reason;
|
|
7202
|
+
const scoped = decision.scoped && !vetoedHere;
|
|
7203
|
+
dispositions.push({
|
|
7204
|
+
basis,
|
|
7205
|
+
disposition: scoped ? "not-required" : "executed",
|
|
7206
|
+
impactTarget,
|
|
7207
|
+
name
|
|
7208
|
+
});
|
|
7209
|
+
if (scoped) notRequired.push({
|
|
7210
|
+
basis,
|
|
7211
|
+
impactTarget,
|
|
7212
|
+
name
|
|
7213
|
+
});
|
|
7214
|
+
else execute.push(command);
|
|
7215
|
+
}
|
|
7216
|
+
return {
|
|
7217
|
+
dispositions,
|
|
7218
|
+
execute,
|
|
7219
|
+
notRequired
|
|
7220
|
+
};
|
|
7196
7221
|
};
|
|
7197
7222
|
/**
|
|
7198
|
-
* The
|
|
7199
|
-
*
|
|
7223
|
+
* The completeness invariant as an enforceable control (ADR 0024: controls
|
|
7224
|
+
* fail loudly), shared by both pr:verify proof schemas so they cannot drift.
|
|
7200
7225
|
*
|
|
7201
|
-
*
|
|
7202
|
-
*
|
|
7203
|
-
*
|
|
7226
|
+
* Four rules:
|
|
7227
|
+
* 1. A withheld command names one the resolved mode actually selected.
|
|
7228
|
+
* 2. A command is never recorded as both executed and not-required.
|
|
7229
|
+
* 3. Withheld commands are distinct — a duplicated disposition would let one
|
|
7230
|
+
* name carry two different bases.
|
|
7231
|
+
* 4. On a PASSED proof, every selected command has a disposition: silence is
|
|
7232
|
+
* not a disposition, and "absent from both lists" is exactly the silent
|
|
7233
|
+
* skip this epic forbids.
|
|
7204
7234
|
*
|
|
7205
|
-
*
|
|
7206
|
-
*
|
|
7207
|
-
* pre-push in exactly the same way. That proof is incomplete — applicability
|
|
7208
|
-
* classifies it `typed-aborted` and readiness answers "re-run pr:verify" — so
|
|
7209
|
-
* telling the operator it is stored and needs no rerun would be false in every
|
|
7210
|
-
* clause, and false in the expensive direction. A failed verification keeps the
|
|
7211
|
-
* plain diagnostic (#316).
|
|
7212
|
-
*/
|
|
7213
|
-
const isPrePushVerifyBinding = (input, error) => input.gate === "pr-verify" && input.conclusion === "success" && error instanceof GitHubApiError && error.status === 422;
|
|
7214
|
-
/**
|
|
7215
|
-
* What to tell an operator when the head SHA is not on GitHub yet.
|
|
7235
|
+
* Rule 4 is scoped to `outcome: "passed"` because an ABORTED proof legitimately
|
|
7236
|
+
* records partial execution — the run stopped at the failing command.
|
|
7216
7237
|
*
|
|
7217
|
-
*
|
|
7218
|
-
*
|
|
7219
|
-
*
|
|
7220
|
-
*
|
|
7221
|
-
* with no re-execution, so say that instead (#316).
|
|
7238
|
+
* The check is one-directional on purpose. Every SELECTED command must be
|
|
7239
|
+
* accounted for, but `executedCommands` may legitimately carry MORE than the
|
|
7240
|
+
* profile selected — full mode appends the `workspace:install-resolves` probe,
|
|
7241
|
+
* which is real work no profile declares.
|
|
7222
7242
|
*/
|
|
7223
|
-
const
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7243
|
+
const assertBatteryCompleteness = (proof, context) => {
|
|
7244
|
+
const selected = proof.verificationCommands.map((command) => command.name);
|
|
7245
|
+
const selectedSet = new Set(selected);
|
|
7246
|
+
const executed = new Set((proof.executedCommands ?? []).map((command) => command.name));
|
|
7247
|
+
const notRequired = proof.notRequiredCommands ?? [];
|
|
7248
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7249
|
+
for (const [index, entry] of notRequired.entries()) {
|
|
7250
|
+
if (!selectedSet.has(entry.name)) context.addIssue({
|
|
7251
|
+
code: "custom",
|
|
7252
|
+
message: `notRequiredCommands entry "${entry.name}" is not one of this proof's verificationCommands.`,
|
|
7253
|
+
path: [
|
|
7254
|
+
"notRequiredCommands",
|
|
7255
|
+
index,
|
|
7256
|
+
"name"
|
|
7257
|
+
]
|
|
7258
|
+
});
|
|
7259
|
+
if (executed.has(entry.name)) context.addIssue({
|
|
7260
|
+
code: "custom",
|
|
7261
|
+
message: `Command "${entry.name}" is recorded both as executed and as not-required.`,
|
|
7262
|
+
path: [
|
|
7263
|
+
"notRequiredCommands",
|
|
7264
|
+
index,
|
|
7265
|
+
"name"
|
|
7266
|
+
]
|
|
7267
|
+
});
|
|
7268
|
+
if (seen.has(entry.name)) context.addIssue({
|
|
7269
|
+
code: "custom",
|
|
7270
|
+
message: `notRequiredCommands records "${entry.name}" more than once.`,
|
|
7271
|
+
path: [
|
|
7272
|
+
"notRequiredCommands",
|
|
7273
|
+
index,
|
|
7274
|
+
"name"
|
|
7275
|
+
]
|
|
7276
|
+
});
|
|
7277
|
+
seen.add(entry.name);
|
|
7278
|
+
}
|
|
7279
|
+
if (proof.outcome !== "passed") return;
|
|
7280
|
+
const disposed = new Set([...executed, ...seen]);
|
|
7281
|
+
const undisposed = selected.filter((name) => !disposed.has(name));
|
|
7282
|
+
if (undisposed.length > 0) context.addIssue({
|
|
7283
|
+
code: "custom",
|
|
7284
|
+
message: `A passed pr:verify proof must give every selected verification command a disposition; [${undisposed.join(", ")}] appear in verificationCommands but in neither executedCommands nor notRequiredCommands.`,
|
|
7285
|
+
path: ["executedCommands"]
|
|
7286
|
+
});
|
|
7287
|
+
};
|
|
7227
7288
|
/**
|
|
7228
|
-
*
|
|
7289
|
+
* The stamp-authorization control: a passed proof may only claim a command
|
|
7290
|
+
* was NOT REQUIRED if its own recorded stamp says so.
|
|
7229
7291
|
*
|
|
7230
|
-
*
|
|
7231
|
-
*
|
|
7232
|
-
*
|
|
7233
|
-
*
|
|
7234
|
-
*
|
|
7292
|
+
* `assertBatteryCompleteness` proves the two lists partition the selected
|
|
7293
|
+
* commands — that no command is silently absent. It does not prove the
|
|
7294
|
+
* withholding was EARNED. Without this rule a proof could name every expensive
|
|
7295
|
+
* command in `notRequiredCommands`, carry no stamp at all (or a conservative
|
|
7296
|
+
* one), and still read as full verification: the omission would be recorded,
|
|
7297
|
+
* accounted for, and completely unauthorized.
|
|
7235
7298
|
*
|
|
7236
|
-
*
|
|
7237
|
-
*
|
|
7238
|
-
*
|
|
7239
|
-
*
|
|
7299
|
+
* Two things have to hold, and the first is what keeps the second honest:
|
|
7300
|
+
*
|
|
7301
|
+
* 1. COHERENCE. The entry's `impactTarget` must equal the `impactTarget` the
|
|
7302
|
+
* proof records for that same command in `verificationCommands`. The entry
|
|
7303
|
+
* does not get to nominate its own target; the proof's command→target
|
|
7304
|
+
* mapping (written by `pr:verify` from the loaded profile) does. Without
|
|
7305
|
+
* this, authorization validates a self-reported field and a crafted proof
|
|
7306
|
+
* can withhold an affected command while pointing at an unrelated released
|
|
7307
|
+
* target.
|
|
7308
|
+
* 2. AUTHORIZATION. That recorded target must be one the proof's own stamp
|
|
7309
|
+
* provably released.
|
|
7310
|
+
*
|
|
7311
|
+
* The proof is one artifact and a determined forger controls all of it; this
|
|
7312
|
+
* is internal-coherence belt-and-braces in the same trust domain as the rest
|
|
7313
|
+
* of the proof, and write-time truth stays `pr:verify`'s job.
|
|
7314
|
+
*
|
|
7315
|
+
* The authorizing evidence is the proof's OWN `impactStamp` — the same stamp
|
|
7316
|
+
* `pr:verify` computed for this candidate's identity triple and recorded here,
|
|
7317
|
+
* so authorization is bound to the same identities the proof binds. The
|
|
7318
|
+
* predicate is the shared {@link impactStampScopeDecision}, not a second
|
|
7319
|
+
* reading of the stamp: an entry is authorized exactly when the stamp would
|
|
7320
|
+
* have released that target's command in the first place (target-scoped
|
|
7321
|
+
* basis, this build's stamp version, exactly one row for the target,
|
|
7322
|
+
* `not-affected`). A conservative stamp, an unknown version, a
|
|
7323
|
+
* self-contradictory stamp, an unclassified name, or an `affected` verdict all
|
|
7324
|
+
* fail the same way scoping itself would.
|
|
7325
|
+
*
|
|
7326
|
+
* Scoped to `outcome: "passed"` for the same reason as the completeness rule:
|
|
7327
|
+
* an aborted run records partial execution, so it makes no false completeness
|
|
7328
|
+
* claim.
|
|
7329
|
+
*/
|
|
7330
|
+
const assertNotRequiredStampAuthorization = (proof, context) => {
|
|
7331
|
+
const notRequired = proof.notRequiredCommands ?? [];
|
|
7332
|
+
if (proof.outcome !== "passed" || notRequired.length === 0) return;
|
|
7333
|
+
const { impactStamp } = proof;
|
|
7334
|
+
if (impactStamp === void 0) {
|
|
7335
|
+
context.addIssue({
|
|
7336
|
+
code: "custom",
|
|
7337
|
+
message: `A passed pr:verify proof that withholds verification commands must record the impact stamp that authorized the withholding; [${notRequired.map((entry) => entry.name).join(", ")}] are recorded not-required by a proof carrying no impactStamp.`,
|
|
7338
|
+
path: ["impactStamp"]
|
|
7339
|
+
});
|
|
7340
|
+
return;
|
|
7341
|
+
}
|
|
7342
|
+
const recordedTargets = new Map(proof.verificationCommands.map((command) => [command.name, command.impactTarget]));
|
|
7343
|
+
for (const [index, entry] of notRequired.entries()) {
|
|
7344
|
+
const recorded = recordedTargets.get(entry.name);
|
|
7345
|
+
if (recorded !== entry.impactTarget) {
|
|
7346
|
+
context.addIssue({
|
|
7347
|
+
code: "custom",
|
|
7348
|
+
message: recorded === void 0 ? `notRequiredCommands entry "${entry.name}" claims impactTarget "${entry.impactTarget}", but this proof records no impactTarget for that command; only a command the profile scopes can be withheld.` : `notRequiredCommands entry "${entry.name}" claims impactTarget "${entry.impactTarget}", but this proof records impactTarget "${recorded}" for that command.`,
|
|
7349
|
+
path: [
|
|
7350
|
+
"notRequiredCommands",
|
|
7351
|
+
index,
|
|
7352
|
+
"impactTarget"
|
|
7353
|
+
]
|
|
7354
|
+
});
|
|
7355
|
+
continue;
|
|
7356
|
+
}
|
|
7357
|
+
const decision = impactStampScopeDecision({
|
|
7358
|
+
stamp: impactStamp,
|
|
7359
|
+
surface: "verification-battery",
|
|
7360
|
+
targetName: entry.impactTarget
|
|
7361
|
+
});
|
|
7362
|
+
if (!decision.scoped) context.addIssue({
|
|
7363
|
+
code: "custom",
|
|
7364
|
+
message: `notRequiredCommands entry "${entry.name}" is not authorized by this proof's impactStamp: ${decision.reason}.`,
|
|
7365
|
+
path: [
|
|
7366
|
+
"notRequiredCommands",
|
|
7367
|
+
index,
|
|
7368
|
+
"impactTarget"
|
|
7369
|
+
]
|
|
7370
|
+
});
|
|
7371
|
+
}
|
|
7372
|
+
};
|
|
7373
|
+
/** Console lines naming every withheld command and why. Never silent. */
|
|
7374
|
+
const batteryScopeSummaryLines = (plan) => {
|
|
7375
|
+
if (plan.notRequired.length === 0) return [];
|
|
7376
|
+
return ["Verification battery scoped by the impact stamp (not-required):", ...plan.notRequired.map((entry) => `- ${entry.name} (${entry.impactTarget}): ${entry.basis}`)];
|
|
7377
|
+
};
|
|
7378
|
+
//#endregion
|
|
7379
|
+
//#region src/pr-verify-proof-rules.ts
|
|
7380
|
+
const assertPrVerifyProofRules = (proof, context) => {
|
|
7381
|
+
if (proof.notDemandedRecords !== void 0 && proof.impactStamp === void 0) context.addIssue({
|
|
7382
|
+
code: "custom",
|
|
7383
|
+
message: "notDemandedRecords requires impactStamp; package-level not-demanded records are bound to the stamp identity.",
|
|
7384
|
+
path: ["notDemandedRecords"]
|
|
7385
|
+
});
|
|
7386
|
+
assertBatteryCompleteness(proof, context);
|
|
7387
|
+
assertNotRequiredStampAuthorization(proof, context);
|
|
7388
|
+
};
|
|
7389
|
+
/**
|
|
7390
|
+
* The pr:verify proof versions a reader accepts: the current one only (#920
|
|
7391
|
+
* item 1). #917 narrowed the pr:ready reader the same way. A pr:verify proof
|
|
7392
|
+
* is per-candidate and short-lived — it is rewritten on every head — so no
|
|
7393
|
+
* stored v1–v3 proof can outlive the release that drops it. Pre-1.0 posture
|
|
7394
|
+
* applies: one current contract, no compat reader. A consumer on an older
|
|
7395
|
+
* factory must adopt this release before HQ ingests its pr:verify proofs.
|
|
7396
|
+
*/
|
|
7397
|
+
const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS = [4];
|
|
7398
|
+
const prVerifyTestFailureSchema = z.object({
|
|
7399
|
+
file: z.string().min(1),
|
|
7400
|
+
kind: z.enum([
|
|
7401
|
+
"assertion",
|
|
7402
|
+
"error",
|
|
7403
|
+
"timeout"
|
|
7404
|
+
]),
|
|
7405
|
+
title: z.string().min(1)
|
|
7406
|
+
});
|
|
7407
|
+
const executedCommandSchema = z.object({
|
|
7408
|
+
command: z.string().min(1),
|
|
7409
|
+
counts: z.object({
|
|
7410
|
+
testFiles: z.number().nonnegative().optional(),
|
|
7411
|
+
tests: z.number().nonnegative().optional()
|
|
7412
|
+
}).optional(),
|
|
7413
|
+
durationMs: z.number().nonnegative(),
|
|
7414
|
+
exitCode: z.number(),
|
|
7415
|
+
failures: z.array(prVerifyTestFailureSchema).min(1).optional(),
|
|
7416
|
+
name: z.string().min(1),
|
|
7417
|
+
scope: z.enum([
|
|
7418
|
+
"docs-only",
|
|
7419
|
+
"trivial",
|
|
7420
|
+
"full"
|
|
7421
|
+
])
|
|
7422
|
+
});
|
|
7423
|
+
const notRequiredCommandSchema = z.object({
|
|
7424
|
+
basis: z.string().min(1),
|
|
7425
|
+
impactTarget: z.string().min(1),
|
|
7426
|
+
name: z.string().min(1)
|
|
7427
|
+
});
|
|
7428
|
+
const notDemandedRecordSchema = z.object({
|
|
7429
|
+
basis: z.string().min(1),
|
|
7430
|
+
name: z.string().min(1)
|
|
7431
|
+
});
|
|
7432
|
+
const prVerifySchemaVersionSchema = z.literal(4);
|
|
7433
|
+
const prVerifyProofSchema = z.object({
|
|
7434
|
+
authoringSession: z.string().trim().min(1),
|
|
7435
|
+
base: z.string().min(1),
|
|
7436
|
+
baseSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
7437
|
+
baselineFullProofs: z.array(z.object({
|
|
7438
|
+
base: z.string().min(1),
|
|
7439
|
+
changedFiles: z.array(z.string()),
|
|
7440
|
+
headSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
7441
|
+
profilePath: z.string().min(1),
|
|
7442
|
+
projectKey: z.string().min(1),
|
|
7443
|
+
repository: z.string().min(1)
|
|
7444
|
+
})).optional(),
|
|
7445
|
+
catchUpRecognition: catchUpRecognitionSchema.optional(),
|
|
7446
|
+
changedFiles: z.array(z.string()),
|
|
7447
|
+
classification: z.enum([
|
|
7448
|
+
"docs/process-only",
|
|
7449
|
+
"trivial",
|
|
7450
|
+
"non-trivial"
|
|
7451
|
+
]),
|
|
7452
|
+
classificationReasons: z.array(z.string()),
|
|
7453
|
+
command: z.literal("patronage-factory pr:verify"),
|
|
7454
|
+
durationMs: z.number().nonnegative(),
|
|
7455
|
+
endedAt: z.iso.datetime(),
|
|
7456
|
+
executedCommands: z.array(executedCommandSchema).optional(),
|
|
7457
|
+
headSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
7458
|
+
impactStamp: impactStampSchema.optional(),
|
|
7459
|
+
mergeBaseSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
7460
|
+
mode: z.enum([
|
|
7461
|
+
"docs-only",
|
|
7462
|
+
"trivial",
|
|
7463
|
+
"full"
|
|
7464
|
+
]),
|
|
7465
|
+
notDemandedRecords: z.array(notDemandedRecordSchema).min(1).optional(),
|
|
7466
|
+
notRequiredCommands: z.array(notRequiredCommandSchema).min(1).optional(),
|
|
7467
|
+
outcome: z.enum(["aborted", "passed"]).optional(),
|
|
7468
|
+
patchId: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
7469
|
+
policyResolution: policyResolutionSchema.optional(),
|
|
7470
|
+
profilePath: z.string().min(1),
|
|
7471
|
+
projectKey: z.string().min(1),
|
|
7472
|
+
repository: z.string().min(1),
|
|
7473
|
+
schemaVersion: prVerifySchemaVersionSchema,
|
|
7474
|
+
startedAt: z.iso.datetime(),
|
|
7475
|
+
verificationCommands: z.array(z.object({
|
|
7476
|
+
command: z.string().min(1),
|
|
7477
|
+
description: z.string().min(1),
|
|
7478
|
+
impactTarget: z.string().min(1).optional(),
|
|
7479
|
+
name: z.string().min(1),
|
|
7480
|
+
scope: z.enum([
|
|
7481
|
+
"docs-only",
|
|
7482
|
+
"trivial",
|
|
7483
|
+
"full"
|
|
7484
|
+
])
|
|
7485
|
+
}))
|
|
7486
|
+
}).superRefine(assertPrVerifyProofRules);
|
|
7487
|
+
function validatePrVerifyProof(value) {
|
|
7488
|
+
return prVerifyProofSchema.parse(value);
|
|
7489
|
+
}
|
|
7490
|
+
//#endregion
|
|
7491
|
+
//#region src/pr-verify-check-payload.ts
|
|
7492
|
+
/**
|
|
7493
|
+
* Machine-readable binding carried on the `patronage-factory/pr-verify` check
|
|
7494
|
+
* run (#247), alongside the human-readable prose summary.
|
|
7495
|
+
*
|
|
7496
|
+
* It records *what was verified*, for the head SHA it was written for. It
|
|
7497
|
+
* grants nothing and gates nothing on its own: a consumer still has to verify
|
|
7498
|
+
* the producing App, the conclusion, and its own freshness rule. The payload
|
|
7499
|
+
* is a fenced JSON document in `output.text`.
|
|
7500
|
+
*/
|
|
7501
|
+
const PR_VERIFY_CHECK_PAYLOAD_KIND = "pr-verify-proof-binding";
|
|
7502
|
+
const PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION = 1;
|
|
7503
|
+
const OUTCOMES = ["aborted", "passed"];
|
|
7504
|
+
const isOutcome = (value) => OUTCOMES.includes(value);
|
|
7505
|
+
/**
|
|
7506
|
+
* The payload's outcome must agree with the check run's own conclusion, which
|
|
7507
|
+
* comes from `verifyProofPassed`: a proof with no recorded outcome is
|
|
7508
|
+
* unfinished (fail closed).
|
|
7509
|
+
*/
|
|
7510
|
+
const outcomeFor$1 = (value) => isOutcome(value.outcome) ? value.outcome : "aborted";
|
|
7511
|
+
const asClassification = (value) => DIFF_CLASSIFICATIONS.includes(value) ? value : "unknown";
|
|
7512
|
+
/**
|
|
7513
|
+
* Project a local pr:verify proof onto the durable check-run payload. Accepts
|
|
7514
|
+
* `unknown` because the publisher is handed an opaque proof value; anything
|
|
7515
|
+
* unrecognizable degrades to `undefined` so the check run still publishes its
|
|
7516
|
+
* prose.
|
|
7240
7517
|
*/
|
|
7241
|
-
const
|
|
7242
|
-
timeoutMs: budget.timeoutMs ?? 5e3,
|
|
7243
|
-
...budget.transportAttempts === void 0 ? {} : { transportAttempts: budget.transportAttempts }
|
|
7244
|
-
});
|
|
7245
|
-
function verifyOutput(proof) {
|
|
7518
|
+
const prVerifyCheckPayloadFor = (proof) => {
|
|
7246
7519
|
const value = proof;
|
|
7247
|
-
|
|
7248
|
-
const
|
|
7249
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7520
|
+
if (!value || typeof value.headSha !== "string" || value.headSha.length === 0 || !isResolvedPrVerifyMode(value.mode)) return;
|
|
7521
|
+
const carriesReleases = value.notRequiredCommands !== void 0;
|
|
7522
|
+
const payload = {
|
|
7523
|
+
classification: asClassification(value.classification),
|
|
7524
|
+
executedCommands: (value.executedCommands ?? []).map(({ name }) => name).filter((name) => typeof name === "string"),
|
|
7525
|
+
headSha: value.headSha,
|
|
7526
|
+
...carriesReleases && value.impactStamp !== void 0 ? { impactStamp: value.impactStamp } : {},
|
|
7527
|
+
kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
|
|
7528
|
+
mode: value.mode,
|
|
7529
|
+
...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
|
|
7530
|
+
outcome: outcomeFor$1(value),
|
|
7531
|
+
proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
|
|
7532
|
+
schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
|
|
7533
|
+
...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
|
|
7534
|
+
...typeof value.repository === "string" ? { repository: value.repository } : {},
|
|
7535
|
+
...carriesReleases && value.verificationCommands !== void 0 ? { verificationCommands: value.verificationCommands } : {}
|
|
7253
7536
|
};
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
const named = value.blockedReasons ?? [];
|
|
7264
|
-
if (named.length > 0) return named.map((reason) => ({
|
|
7265
|
-
...reason.code === void 0 ? {} : { code: reason.code },
|
|
7266
|
-
detail: reason.detail ?? "unrecorded"
|
|
7267
|
-
}));
|
|
7268
|
-
return (value.blockingReasons ?? []).map((reason) => ({ detail: typeof reason === "string" ? reason : "unrecorded" }));
|
|
7537
|
+
try {
|
|
7538
|
+
const verificationProof = validatePrVerifyProof(proof);
|
|
7539
|
+
const transported = {
|
|
7540
|
+
...payload,
|
|
7541
|
+
verificationProof
|
|
7542
|
+
};
|
|
7543
|
+
if (new TextEncoder().encode(renderPrVerifyCheckPayloadText(transported)).length <= 6e4) return transported;
|
|
7544
|
+
} catch {}
|
|
7545
|
+
return payload;
|
|
7269
7546
|
};
|
|
7270
|
-
const
|
|
7271
|
-
|
|
7547
|
+
const readTransportedVerifyProof = (value, text) => {
|
|
7548
|
+
if (value === void 0 || new TextEncoder().encode(text).length > 6e4) return {};
|
|
7549
|
+
return { verificationProof: validatePrVerifyProof(value) };
|
|
7272
7550
|
};
|
|
7273
|
-
const readyIdentityField = (value) => value === void 0 || value.trim().length === 0 ? "unrecorded" : `\`${value}\``;
|
|
7274
7551
|
/**
|
|
7275
|
-
*
|
|
7276
|
-
*
|
|
7277
|
-
*
|
|
7278
|
-
* body prose showed them — so a human merging from the checks page could not
|
|
7279
|
-
* see which model produced the review that admitted the candidate.
|
|
7280
|
-
*
|
|
7281
|
-
* This is recording made visible, not a control. The attended merge is the
|
|
7282
|
-
* control.
|
|
7552
|
+
* Parse the binding back out of check-run `output.text` (preferred) or
|
|
7553
|
+
* `output.summary`. Absence / unparseable / wrong shape ⇒ `undefined`, so a
|
|
7554
|
+
* consumer fails closed to "no proof" rather than to a partial record.
|
|
7283
7555
|
*/
|
|
7284
|
-
const
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
const
|
|
7289
|
-
|
|
7290
|
-
|
|
7556
|
+
const parsePrVerifyCheckPayload = (text) => {
|
|
7557
|
+
if (!text?.trim()) return;
|
|
7558
|
+
const candidate = (text.match(/```(?:json)?\s*(?<body>[\s\S]*?)```/u)?.groups?.body ?? text).trim();
|
|
7559
|
+
try {
|
|
7560
|
+
const parsed = JSON.parse(candidate);
|
|
7561
|
+
if (parsed.schemaVersion !== PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION || parsed.kind !== "pr-verify-proof-binding" || typeof parsed.headSha !== "string" || parsed.headSha.length === 0 || !isResolvedPrVerifyMode(parsed.mode) || !isOutcome(parsed.outcome)) return;
|
|
7562
|
+
return {
|
|
7563
|
+
...readTransportedVerifyProof(parsed.verificationProof, text),
|
|
7564
|
+
classification: asClassification(parsed.classification),
|
|
7565
|
+
executedCommands: (parsed.executedCommands ?? []).filter((name) => typeof name === "string"),
|
|
7566
|
+
headSha: parsed.headSha,
|
|
7567
|
+
...parsed.impactStamp === void 0 ? {} : { impactStamp: parsed.impactStamp },
|
|
7568
|
+
kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
|
|
7569
|
+
mode: parsed.mode,
|
|
7570
|
+
...parsed.notRequiredCommands === void 0 ? {} : { notRequiredCommands: parsed.notRequiredCommands },
|
|
7571
|
+
outcome: parsed.outcome,
|
|
7572
|
+
proofSchemaVersion: typeof parsed.proofSchemaVersion === "number" ? parsed.proofSchemaVersion : 0,
|
|
7573
|
+
schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
|
|
7574
|
+
...typeof parsed.patchId === "string" ? { patchId: parsed.patchId } : {},
|
|
7575
|
+
...typeof parsed.repository === "string" ? { repository: parsed.repository } : {},
|
|
7576
|
+
...parsed.verificationCommands === void 0 ? {} : { verificationCommands: parsed.verificationCommands }
|
|
7577
|
+
};
|
|
7578
|
+
} catch {}
|
|
7291
7579
|
};
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
};
|
|
7299
|
-
}
|
|
7300
|
-
function factoryCheckOutput(gate, proof) {
|
|
7301
|
-
return {
|
|
7302
|
-
"pr-ready": readyOutput,
|
|
7303
|
-
"pr-verify": verifyOutput
|
|
7304
|
-
}[gate](proof);
|
|
7305
|
-
}
|
|
7306
|
-
async function runGhDetails(args, cwd) {
|
|
7307
|
-
const { execFile } = await import("node:child_process");
|
|
7308
|
-
const { stdout } = await promisify(execFile)("gh", args, {
|
|
7309
|
-
cwd,
|
|
7310
|
-
encoding: "utf-8",
|
|
7311
|
-
timeout: GITHUB_PUBLISH_TIMEOUT_MS
|
|
7312
|
-
});
|
|
7313
|
-
return stdout;
|
|
7314
|
-
}
|
|
7315
|
-
async function resolveFactoryDetailsUrl(input, run = runGhDetails) {
|
|
7316
|
-
const hqBase = input.hqLaneBaseUrl;
|
|
7317
|
-
if (hqBase !== void 0 && input.pr !== void 0) return hqLaneRefUrl(hqBase, input.repo, input.pr);
|
|
7318
|
-
const raw = await run([
|
|
7319
|
-
"pr",
|
|
7320
|
-
"view",
|
|
7321
|
-
...input.pr === void 0 ? [] : [
|
|
7322
|
-
String(input.pr),
|
|
7323
|
-
"--repo",
|
|
7324
|
-
`${input.owner}/${input.repo}`
|
|
7325
|
-
],
|
|
7326
|
-
"--json",
|
|
7327
|
-
"comments,number,url"
|
|
7328
|
-
], input.cwd);
|
|
7329
|
-
const pr = JSON.parse(raw);
|
|
7330
|
-
if (hqBase !== void 0 && typeof pr.number === "number") return hqLaneRefUrl(hqBase, input.repo, pr.number);
|
|
7331
|
-
return (pr.comments?.find((comment) => comment.body?.includes("patronage-factory-readiness-ledger:start")))?.url ?? pr.url ?? `https://github.com/${input.owner}/${input.repo}/commit/${input.sha}`;
|
|
7332
|
-
}
|
|
7333
|
-
const checkRunRequestBody = (input, detailsUrl) => {
|
|
7334
|
-
const status = input.status ?? "completed";
|
|
7335
|
-
return {
|
|
7336
|
-
...status === "completed" ? { conclusion: input.conclusion } : {},
|
|
7337
|
-
details_url: detailsUrl,
|
|
7338
|
-
head_sha: input.sha,
|
|
7339
|
-
name: FACTORY_CHECK_NAMES[input.gate],
|
|
7340
|
-
output: factoryCheckOutput(input.gate, input.proof),
|
|
7341
|
-
status
|
|
7342
|
-
};
|
|
7580
|
+
const renderPrVerifyCheckPayloadText = (payload) => `\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
7581
|
+
//#endregion
|
|
7582
|
+
//#region src/github-check-runs.ts
|
|
7583
|
+
const FACTORY_CHECK_NAMES = {
|
|
7584
|
+
"pr-ready": "patronage-factory/pr-ready",
|
|
7585
|
+
"pr-verify": "patronage-factory/pr-verify"
|
|
7343
7586
|
};
|
|
7587
|
+
const hqLaneRefUrl = (base, repo, number) => `${base}/${encodeURIComponent(repo)}/${number}`;
|
|
7344
7588
|
/**
|
|
7345
|
-
*
|
|
7346
|
-
*
|
|
7347
|
-
* The retry exists for one specific failure: GitHub rejects a check run whose
|
|
7348
|
-
* `head_sha` it has not seen. `pr:verify` routinely runs before the branch is
|
|
7349
|
-
* pushed, and even after a push the commit can take a moment to be visible.
|
|
7350
|
-
* Without a retry the POST 422s, falls through to a commit status that 422s
|
|
7351
|
-
* too, and the run reads as "verification did not happen" (#247).
|
|
7352
|
-
*
|
|
7353
|
-
* Retries are bounded three ways: a maximum attempt count, an optional
|
|
7354
|
-
* wall-clock budget, and retryability of the failure itself — a 401/403/404
|
|
7355
|
-
* means the credentials are wrong, and re-sending them cannot help.
|
|
7589
|
+
* Fire-and-forget publication for a check run that is a *mirror* of a local
|
|
7590
|
+
* verdict — swallowing the failure cannot change what the gate decided.
|
|
7356
7591
|
*
|
|
7357
|
-
*
|
|
7358
|
-
*
|
|
7359
|
-
*
|
|
7360
|
-
*
|
|
7592
|
+
* Not every branded check is a mirror any more (#477). `patronage-factory/
|
|
7593
|
+
* pr-ready` is a source-pinned required check in the branch ruleset: a
|
|
7594
|
+
* swallowed failure there leaves a candidate armed, unmergeable, and — as epic
|
|
7595
|
+
* #473 wave 2 measured — with no rollup row saying why. So no `pr-ready`
|
|
7596
|
+
* publication comes through here at all: `pr:ready` publishes once, completed,
|
|
7597
|
+
* through {@link ensureFactoryCheckRunPublished}, whose result the caller
|
|
7598
|
+
* reads. It used to publish an in-progress run here while hosted checks
|
|
7599
|
+
* settled, on the reasoning that a missing in-progress check cannot green
|
|
7600
|
+
* anything — true, and beside the point, because a *present* one cannot be
|
|
7601
|
+
* un-blocked and nothing ever completed it (#526).
|
|
7361
7602
|
*/
|
|
7362
|
-
|
|
7363
|
-
const { attempts, budgetMs, delayMs } = dependencies.retry ?? DEFAULT_CHECK_RUN_RETRY;
|
|
7364
|
-
const clock = dependencies.now ?? Date.now;
|
|
7365
|
-
const remaining = remainingAttempts ?? Math.max(1, attempts);
|
|
7366
|
-
const deadline = deadlineMs ?? (budgetMs === void 0 ? void 0 : clock() + budgetMs);
|
|
7367
|
-
const request = dependencies.fetch ?? fetch;
|
|
7368
|
-
const timeoutMs = dependencies.timeoutMs ?? 5e3;
|
|
7603
|
+
function publishFactoryCheckSafely(publisher, input) {
|
|
7369
7604
|
try {
|
|
7370
|
-
|
|
7371
|
-
timeoutMs,
|
|
7372
|
-
transportAttempts: 1
|
|
7373
|
-
});
|
|
7374
|
-
return await requestGitHubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/check-runs`, {
|
|
7375
|
-
body: JSON.stringify(checkRunRequestBody(input, detailsUrl)),
|
|
7376
|
-
headers: {
|
|
7377
|
-
Authorization: `Bearer ${token}`,
|
|
7378
|
-
"Content-Type": "application/json"
|
|
7379
|
-
},
|
|
7380
|
-
method: "POST"
|
|
7381
|
-
}, timeoutMs);
|
|
7605
|
+
publisher?.(input);
|
|
7382
7606
|
} catch (error) {
|
|
7383
|
-
|
|
7384
|
-
await (dependencies.sleep ?? defaultSleep)(delayMs);
|
|
7385
|
-
return await postFactoryCheckRun({
|
|
7386
|
-
config,
|
|
7387
|
-
deadlineMs: deadline,
|
|
7388
|
-
dependencies,
|
|
7389
|
-
detailsUrl,
|
|
7390
|
-
input,
|
|
7391
|
-
remainingAttempts: remaining - 1
|
|
7392
|
-
});
|
|
7607
|
+
console.warn(`${FACTORY_CHECK_NAMES[input.gate]} proof mirror failed without affecting the gate: ${error instanceof Error ? error.message : String(error)}`);
|
|
7393
7608
|
}
|
|
7394
7609
|
}
|
|
7395
7610
|
/**
|
|
7396
|
-
*
|
|
7397
|
-
*
|
|
7398
|
-
*
|
|
7611
|
+
* The detached, fire-and-forget publisher does not retry: it runs on a tail the
|
|
7612
|
+
* process may never await, so sleeping there buys no durability. Reliability is
|
|
7613
|
+
* bought on the awaited path (`ensureFactoryCheckRunPublished`), which the
|
|
7614
|
+
* composed `pr:publish` flow runs once the head SHA is on GitHub.
|
|
7399
7615
|
*/
|
|
7400
|
-
const
|
|
7616
|
+
const DEFAULT_CHECK_RUN_RETRY = {
|
|
7617
|
+
attempts: 1,
|
|
7618
|
+
delayMs: 0
|
|
7619
|
+
};
|
|
7401
7620
|
/**
|
|
7402
|
-
*
|
|
7403
|
-
*
|
|
7404
|
-
*
|
|
7405
|
-
* on the exact path where that register has already cost a full redundant
|
|
7406
|
-
* verification once (PR #314, #316). Nothing is wrong: the commit link is the
|
|
7407
|
-
* correct Details target until a pull request exists, and `pr:publish` binds
|
|
7408
|
-
* the check to the pull request afterwards.
|
|
7621
|
+
* The awaited path blocks `pr:publish`, so it is bounded twice: by attempts and
|
|
7622
|
+
* by a wall-clock budget. Publication can no longer fail the gate; it must not
|
|
7623
|
+
* be able to hang it either.
|
|
7409
7624
|
*/
|
|
7410
|
-
const
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
}
|
|
7420
|
-
}
|
|
7625
|
+
const DURABLE_CHECK_RUN_RETRY = {
|
|
7626
|
+
attempts: 3,
|
|
7627
|
+
budgetMs: 2e4,
|
|
7628
|
+
delayMs: 750
|
|
7629
|
+
};
|
|
7630
|
+
const defaultSleep = async (ms) => {
|
|
7631
|
+
const { setTimeout: delay } = await import("node:timers/promises");
|
|
7632
|
+
await delay(ms);
|
|
7633
|
+
};
|
|
7421
7634
|
/**
|
|
7422
|
-
*
|
|
7423
|
-
*
|
|
7424
|
-
*
|
|
7425
|
-
*
|
|
7426
|
-
*
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
*
|
|
7434
|
-
*
|
|
7435
|
-
*
|
|
7436
|
-
* Three pins keep the answer usable as proof:
|
|
7635
|
+
* Worth another attempt: 422 is what GitHub answers for a head SHA it has not
|
|
7636
|
+
* seen yet (the whole reason the retry exists), 5xx and rate limiting are
|
|
7637
|
+
* transient, and a thrown non-HTTP error is a network/timeout failure. A 401 /
|
|
7638
|
+
* 403 / 404 means the credentials or installation are wrong, and retrying that
|
|
7639
|
+
* only burns the caller's time budget.
|
|
7640
|
+
*/
|
|
7641
|
+
const isRetryableCheckRunFailure = (error) => {
|
|
7642
|
+
if (!(error instanceof GitHubApiError)) return true;
|
|
7643
|
+
return error.status === 422 || error.status === 429 || error.status >= 500;
|
|
7644
|
+
};
|
|
7645
|
+
/**
|
|
7646
|
+
* The pre-push state of the detached `pr:verify` publisher: GitHub 422s a check
|
|
7647
|
+
* run whose `head_sha` it has never seen.
|
|
7437
7648
|
*
|
|
7438
|
-
*
|
|
7439
|
-
*
|
|
7440
|
-
*
|
|
7441
|
-
* the pinned requirement. Left unpinned it could confirm a publication the
|
|
7442
|
-
* pinned check never got, or refuse every genuine one. This is the same
|
|
7443
|
-
* fail-closed rule `checkRunProducedByFactoryApp` applies to durable records
|
|
7444
|
-
* below. A run served without any `app` id is refused rather than filtered
|
|
7445
|
-
* out: dropping it fails closed for the newest-run clause but open for
|
|
7446
|
-
* terminality, and an unfinished run of unknown provenance still blocks the
|
|
7447
|
-
* gate.
|
|
7448
|
-
* - `per_page=100` with a required `total_count` that equals the page — a
|
|
7449
|
-
* truncated page holds neither the whole set nor a defensible newest run, and
|
|
7450
|
-
* `filter=all` returns strictly more runs than `latest` did, so this guard now
|
|
7451
|
-
* carries more weight. Truncation is unconfirmed, not paginated: a busy head
|
|
7452
|
-
* refuses rather than judging a publication against a page that silently
|
|
7453
|
-
* omits the run blocking it. An absent or malformed count cannot establish
|
|
7454
|
-
* completeness at all, so it refuses too.
|
|
7455
|
-
* - an unparsable or absent `started_at` — unorderable state is refused before
|
|
7456
|
-
* selection rather than sorting to an extreme and being chosen.
|
|
7649
|
+
* Scoped to `pr:verify` because that is the gate whose proof `pr:publish`
|
|
7650
|
+
* rebinds after the push (`republishVerifyProofCheckRun`); the other gates run
|
|
7651
|
+
* against a head GitHub already has.
|
|
7457
7652
|
*
|
|
7458
|
-
*
|
|
7459
|
-
*
|
|
7653
|
+
* Scoped to a *successful* verification because `pr:verify` also publishes this
|
|
7654
|
+
* gate from its aborted path (`writePartialProof`, `pr-verify.ts`), which 422s
|
|
7655
|
+
* pre-push in exactly the same way. That proof is incomplete — applicability
|
|
7656
|
+
* classifies it `typed-aborted` and readiness answers "re-run pr:verify" — so
|
|
7657
|
+
* telling the operator it is stored and needs no rerun would be false in every
|
|
7658
|
+
* clause, and false in the expensive direction. A failed verification keeps the
|
|
7659
|
+
* plain diagnostic (#316).
|
|
7460
7660
|
*/
|
|
7461
|
-
|
|
7462
|
-
const request = dependencies.fetch ?? fetch;
|
|
7463
|
-
const timeoutMs = dependencies.timeoutMs ?? 5e3;
|
|
7464
|
-
const token = dependencies.token ?? await installationToken(input, config, request, (dependencies.now ?? Date.now)(), { timeoutMs });
|
|
7465
|
-
const query = new URLSearchParams({
|
|
7466
|
-
app_id: String(config.appId),
|
|
7467
|
-
check_name: FACTORY_CHECK_NAMES[input.gate],
|
|
7468
|
-
filter: "all",
|
|
7469
|
-
per_page: "100"
|
|
7470
|
-
});
|
|
7471
|
-
const served = await requestGitHubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/commits/${input.sha}/check-runs?${query.toString()}`, {
|
|
7472
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
7473
|
-
method: "GET"
|
|
7474
|
-
}, timeoutMs);
|
|
7475
|
-
const page = Array.isArray(served.check_runs) ? served.check_runs : [];
|
|
7476
|
-
const totalCount = served.total_count;
|
|
7477
|
-
if (!(typeof totalCount === "number" && Number.isSafeInteger(totalCount))) return {
|
|
7478
|
-
kind: "unavailable",
|
|
7479
|
-
reason: "the served page reports no usable total_count"
|
|
7480
|
-
};
|
|
7481
|
-
if (totalCount !== page.length) return {
|
|
7482
|
-
kind: "unavailable",
|
|
7483
|
-
reason: `${page.length} runs on the page against a total_count of ${totalCount}`
|
|
7484
|
-
};
|
|
7485
|
-
if (page.some((run) => (run.app?.id ?? null) === null)) return {
|
|
7486
|
-
kind: "unavailable",
|
|
7487
|
-
reason: "a served run has no app identity"
|
|
7488
|
-
};
|
|
7489
|
-
const runs = page.filter((run) => String(run.app?.id ?? "") === String(config.appId));
|
|
7490
|
-
const orderable = runs.flatMap((run) => {
|
|
7491
|
-
const orderMs = generationOrderMs(run.started_at);
|
|
7492
|
-
return Number.isFinite(orderMs) ? [{
|
|
7493
|
-
orderMs,
|
|
7494
|
-
run
|
|
7495
|
-
}] : [];
|
|
7496
|
-
});
|
|
7497
|
-
if (orderable.length !== runs.length) return {
|
|
7498
|
-
kind: "unavailable",
|
|
7499
|
-
reason: "a served run has no usable started_at"
|
|
7500
|
-
};
|
|
7501
|
-
const newest = selectNewestGeneration(orderable);
|
|
7502
|
-
if (newest.kind === "ambiguous") return {
|
|
7503
|
-
kind: "unavailable",
|
|
7504
|
-
reason: `${newest.tied.length} served runs share the newest started_at`
|
|
7505
|
-
};
|
|
7506
|
-
if (newest.kind === "none") return {
|
|
7507
|
-
kind: "unavailable",
|
|
7508
|
-
reason: "no run is served for this name"
|
|
7509
|
-
};
|
|
7510
|
-
return {
|
|
7511
|
-
kind: "newest",
|
|
7512
|
-
run: newest.generation.run,
|
|
7513
|
-
runs
|
|
7514
|
-
};
|
|
7515
|
-
}
|
|
7516
|
-
/** Runs GitHub has not finished. A non-terminal run blocks its required check. */
|
|
7517
|
-
const unfinishedRuns = (runs) => runs.filter((run) => run.status !== "completed");
|
|
7661
|
+
const isPrePushVerifyBinding = (input, error) => input.gate === "pr-verify" && input.conclusion === "success" && error instanceof GitHubApiError && error.status === 422;
|
|
7518
7662
|
/**
|
|
7519
|
-
*
|
|
7520
|
-
* run, in the status that was asked for, and — for a completed one — with the
|
|
7521
|
-
* conclusion that was asked for.
|
|
7663
|
+
* What to tell an operator when the head SHA is not on GitHub yet.
|
|
7522
7664
|
*
|
|
7523
|
-
*
|
|
7524
|
-
*
|
|
7525
|
-
*
|
|
7526
|
-
*
|
|
7527
|
-
*
|
|
7665
|
+
* Reported as a bare "unable to post check run", this reads as a defect to
|
|
7666
|
+
* chase rather than the ordinary pre-push state, and on PR #314 it prompted a
|
|
7667
|
+
* second full verification after the push — every command re-run for nothing.
|
|
7668
|
+
* The proof is already written and `pr:publish` binds it to this same commit
|
|
7669
|
+
* with no re-execution, so say that instead (#316).
|
|
7528
7670
|
*/
|
|
7529
|
-
const
|
|
7530
|
-
|
|
7531
|
-
|
|
7532
|
-
|
|
7533
|
-
return status !== "completed" || served.conclusion === input.conclusion;
|
|
7534
|
-
};
|
|
7671
|
+
const prePushVerifyBindingNotice = (sha) => `${FACTORY_CHECK_NAMES["pr-verify"]}: commit ${sha.slice(0, 7)} is not on GitHub yet, so there is no commit to bind the check to. That is the normal state before the branch is pushed, not a failure.
|
|
7672
|
+
The proof is complete and stored. After you push, pr:publish binds this check to the same commit and reuses that proof — no verification command runs again.
|
|
7673
|
+
Re-verify only if the tree changes: a new commit, an amend, or a rebase.
|
|
7674
|
+
`;
|
|
7535
7675
|
/**
|
|
7536
|
-
*
|
|
7537
|
-
* head: the newest run of the name is this call's own, and no run of the name
|
|
7538
|
-
* survives unfinished.
|
|
7676
|
+
* Mint an installation token for the Patronage Factory App.
|
|
7539
7677
|
*
|
|
7540
|
-
* The
|
|
7541
|
-
*
|
|
7542
|
-
*
|
|
7543
|
-
*
|
|
7544
|
-
*
|
|
7545
|
-
*
|
|
7546
|
-
*
|
|
7547
|
-
*
|
|
7678
|
+
* The mechanism — app JWT, installation lookup, token exchange — lives in
|
|
7679
|
+
* `@patronage/factory-ci` (#617), because paitronage's proof-comment publisher
|
|
7680
|
+
* had grown a second copy of it. What stays here is what is this repository's:
|
|
7681
|
+
* where the credentials come from (`user-config`), and the publish timeout the
|
|
7682
|
+
* rest of these calls are bound by. Nothing is cached, as before.
|
|
7683
|
+
*
|
|
7684
|
+
* Retry budget is the caller's. The fire-and-forget publisher passes one
|
|
7685
|
+
* attempt because it already has a commit-status fallback (#938). The
|
|
7686
|
+
* confirmation read-back leaves the default, because it has no fallback and
|
|
7687
|
+
* a stale keep-alive is the class #923 covers.
|
|
7548
7688
|
*/
|
|
7549
|
-
const
|
|
7689
|
+
const installationToken = (repository, config, request, now, budget = {}) => mintFactoryInstallationToken(repository, config, request, now, {
|
|
7690
|
+
timeoutMs: budget.timeoutMs ?? 5e3,
|
|
7691
|
+
...budget.transportAttempts === void 0 ? {} : { transportAttempts: budget.transportAttempts }
|
|
7692
|
+
});
|
|
7693
|
+
function verifyOutput(proof) {
|
|
7694
|
+
const value = proof;
|
|
7695
|
+
const commands = value.executedCommands?.length ?? 0;
|
|
7696
|
+
const payload = prVerifyCheckPayloadFor(proof);
|
|
7697
|
+
return {
|
|
7698
|
+
summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed. ${unsubscribedPathsLine(value.impactStamp?.unsubscribedPaths)}`,
|
|
7699
|
+
...payload ? { text: renderPrVerifyCheckPayloadText(payload) } : {},
|
|
7700
|
+
title: `pr:verify ${value.outcome ?? "unknown"}`
|
|
7701
|
+
};
|
|
7702
|
+
}
|
|
7550
7703
|
/**
|
|
7551
|
-
*
|
|
7552
|
-
*
|
|
7553
|
-
*
|
|
7554
|
-
* the
|
|
7704
|
+
* The refusals to name, preferring the attributed projection (#391) so each
|
|
7705
|
+
* line carries its demand code. `blockingReasons` is the fallback because the
|
|
7706
|
+
* throwing path in `pr:ready` publishes a proof that carries only that field.
|
|
7707
|
+
* The two projections hold the same refusals in the same order, so reading
|
|
7708
|
+
* either one tells the same story.
|
|
7555
7709
|
*/
|
|
7556
|
-
const
|
|
7557
|
-
const
|
|
7558
|
-
if (
|
|
7559
|
-
|
|
7710
|
+
const readyReasonLines = (value) => {
|
|
7711
|
+
const named = value.blockedReasons ?? [];
|
|
7712
|
+
if (named.length > 0) return named.map((reason) => ({
|
|
7713
|
+
...reason.code === void 0 ? {} : { code: reason.code },
|
|
7714
|
+
detail: reason.detail ?? "unrecorded"
|
|
7715
|
+
}));
|
|
7716
|
+
return (value.blockingReasons ?? []).map((reason) => ({ detail: typeof reason === "string" ? reason : "unrecorded" }));
|
|
7717
|
+
};
|
|
7718
|
+
const readyReasonBlock = (reasons) => {
|
|
7719
|
+
return `\n\n${reasons.length === 1 ? "1 blocking reason:" : `${reasons.length} blocking reasons:`}\n\n${reasons.map((reason) => `- ${reason.code === void 0 ? "" : `**${reason.code}** — `}${reason.detail}`).join("\n")}`;
|
|
7560
7720
|
};
|
|
7721
|
+
const readyIdentityField = (value) => value === void 0 || value.trim().length === 0 ? "unrecorded" : `\`${value}\``;
|
|
7561
7722
|
/**
|
|
7562
|
-
*
|
|
7563
|
-
*
|
|
7723
|
+
* #1037: the review identity the proof already records, printed where the
|
|
7724
|
+
* merge decision is made. `pr:ready` recorded the review session, model, and
|
|
7725
|
+
* producer, and the authoring session it was checked against, but only the PR
|
|
7726
|
+
* body prose showed them — so a human merging from the checks page could not
|
|
7727
|
+
* see which model produced the review that admitted the candidate.
|
|
7564
7728
|
*
|
|
7565
|
-
*
|
|
7566
|
-
*
|
|
7567
|
-
* present — so it must not be reported through the "unable to republish"
|
|
7568
|
-
* register, which drives `pr:ready`'s "this run's verdict never reached the
|
|
7569
|
-
* required check" notice.
|
|
7729
|
+
* This is recording made visible, not a control. The attended merge is the
|
|
7730
|
+
* control.
|
|
7570
7731
|
*/
|
|
7571
|
-
|
|
7572
|
-
const
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
7582
|
-
|
|
7583
|
-
|
|
7584
|
-
|
|
7585
|
-
}
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7732
|
+
const readyIdentityBlock = (value) => {
|
|
7733
|
+
const runs = value.ledger?.reviewRuns ?? [];
|
|
7734
|
+
if (runs.length === 0 && value.authoringSession === void 0) return "";
|
|
7735
|
+
return `\n\nRecorded review identity:\n\n${[...runs.map((run) => {
|
|
7736
|
+
const rung = run.rung === void 0 ? "" : ` at rung \`${run.rung}\``;
|
|
7737
|
+
return `- ${run.kind ?? "review"}${rung}: session ${readyIdentityField(run.sessionId)} · model ${readyIdentityField(run.model)} · producer ${readyIdentityField(run.producer)}`;
|
|
7738
|
+
}), `- authoring session: ${readyIdentityField(value.authoringSession)}`].join("\n")}`;
|
|
7739
|
+
};
|
|
7740
|
+
function readyOutput(proof) {
|
|
7741
|
+
const value = proof;
|
|
7742
|
+
const reasons = readyReasonLines(value);
|
|
7743
|
+
return {
|
|
7744
|
+
summary: `${`Schema v${value.schemaVersion}; readiness verdict **${value.status ?? "unknown"}**;${reasons.length === 0 ? " no blocking reasons;" : ""} ledger head \`${value.ledger?.headSha ?? "unknown"}\`.`}${reasons.length === 0 ? "" : readyReasonBlock(reasons)}${readyIdentityBlock(value)}`,
|
|
7745
|
+
title: `pr:ready ${value.status ?? "unknown"}`
|
|
7746
|
+
};
|
|
7747
|
+
}
|
|
7748
|
+
function factoryCheckOutput(gate, proof) {
|
|
7749
|
+
return {
|
|
7750
|
+
"pr-ready": readyOutput,
|
|
7751
|
+
"pr-verify": verifyOutput
|
|
7752
|
+
}[gate](proof);
|
|
7753
|
+
}
|
|
7754
|
+
async function runGhDetails(args, cwd) {
|
|
7755
|
+
const { execFile } = await import("node:child_process");
|
|
7756
|
+
const { stdout } = await promisify(execFile)("gh", args, {
|
|
7757
|
+
cwd,
|
|
7758
|
+
encoding: "utf-8",
|
|
7759
|
+
timeout: GITHUB_PUBLISH_TIMEOUT_MS
|
|
7760
|
+
});
|
|
7761
|
+
return stdout;
|
|
7762
|
+
}
|
|
7763
|
+
async function resolveFactoryDetailsUrl(input, run = runGhDetails) {
|
|
7764
|
+
const hqBase = input.hqLaneBaseUrl;
|
|
7765
|
+
if (hqBase !== void 0 && input.pr !== void 0) return hqLaneRefUrl(hqBase, input.repo, input.pr);
|
|
7766
|
+
const raw = await run([
|
|
7767
|
+
"pr",
|
|
7768
|
+
"view",
|
|
7769
|
+
...input.pr === void 0 ? [] : [
|
|
7770
|
+
String(input.pr),
|
|
7771
|
+
"--repo",
|
|
7772
|
+
`${input.owner}/${input.repo}`
|
|
7773
|
+
],
|
|
7774
|
+
"--json",
|
|
7775
|
+
"comments,number,url"
|
|
7776
|
+
], input.cwd);
|
|
7777
|
+
const pr = JSON.parse(raw);
|
|
7778
|
+
if (hqBase !== void 0 && typeof pr.number === "number") return hqLaneRefUrl(hqBase, input.repo, pr.number);
|
|
7779
|
+
return (pr.comments?.find((comment) => comment.body?.includes("patronage-factory-readiness-ledger:start")))?.url ?? pr.url ?? `https://github.com/${input.owner}/${input.repo}/commit/${input.sha}`;
|
|
7591
7780
|
}
|
|
7781
|
+
const checkRunRequestBody = (input, detailsUrl) => {
|
|
7782
|
+
const status = input.status ?? "completed";
|
|
7783
|
+
return {
|
|
7784
|
+
...status === "completed" ? { conclusion: input.conclusion } : {},
|
|
7785
|
+
details_url: detailsUrl,
|
|
7786
|
+
head_sha: input.sha,
|
|
7787
|
+
name: FACTORY_CHECK_NAMES[input.gate],
|
|
7788
|
+
output: factoryCheckOutput(input.gate, input.proof),
|
|
7789
|
+
status
|
|
7790
|
+
};
|
|
7791
|
+
};
|
|
7592
7792
|
/**
|
|
7593
|
-
*
|
|
7594
|
-
* the head SHA visible on GitHub (pushed the branch, created the PR) can make
|
|
7595
|
-
* the proof reliably present for that SHA before it returns (#247).
|
|
7596
|
-
*
|
|
7597
|
-
* Never posts a commit-status fallback: the commit status is a human-readable
|
|
7598
|
-
* mirror, not a proof surface, so a caller that needs an App-verified check
|
|
7599
|
-
* run must be told plainly whether it got one. Returns `true` only when the
|
|
7600
|
-
* App-owned check run landed. Throws {@link FactoryAppUnevaluableError} when
|
|
7601
|
-
* Factory App access is fail-closed (missing mint or Cursor OIDC exchange,
|
|
7602
|
-
* #949): `pr:ready` must not notice that miss and exit 0, and must not publish
|
|
7603
|
-
* a red check that reads as candidate refusal (#1125). An optional-App skip
|
|
7604
|
-
* still returns `false`.
|
|
7793
|
+
* POST the check run, retrying a bounded number of times.
|
|
7605
7794
|
*
|
|
7606
|
-
*
|
|
7607
|
-
*
|
|
7608
|
-
*
|
|
7609
|
-
*
|
|
7610
|
-
*
|
|
7611
|
-
* request back (`arm-auto-merge.ts`); publication now does the same.
|
|
7795
|
+
* The retry exists for one specific failure: GitHub rejects a check run whose
|
|
7796
|
+
* `head_sha` it has not seen. `pr:verify` routinely runs before the branch is
|
|
7797
|
+
* pushed, and even after a push the commit can take a moment to be visible.
|
|
7798
|
+
* Without a retry the POST 422s, falls through to a commit status that 422s
|
|
7799
|
+
* too, and the run reads as "verification did not happen" (#247).
|
|
7612
7800
|
*
|
|
7613
|
-
*
|
|
7614
|
-
*
|
|
7615
|
-
* the
|
|
7616
|
-
* still be unfinished. A surviving `in_progress` run blocks the required check
|
|
7617
|
-
* on its own, so confirming past it would report success on a pull request
|
|
7618
|
-
* GitHub will never merge. One read, no polling: an unconfirmed publication
|
|
7619
|
-
* returns `false`, which `pr:ready` already turns into a notice and an
|
|
7620
|
-
* idempotent re-dispatch.
|
|
7801
|
+
* Retries are bounded three ways: a maximum attempt count, an optional
|
|
7802
|
+
* wall-clock budget, and retryability of the failure itself — a 401/403/404
|
|
7803
|
+
* means the credentials are wrong, and re-sending them cannot help.
|
|
7621
7804
|
*
|
|
7622
|
-
*
|
|
7623
|
-
*
|
|
7624
|
-
*
|
|
7625
|
-
*
|
|
7626
|
-
* its invocation does. No retry, no poll, no re-confirm.
|
|
7805
|
+
* Throws the last error when every attempt fails; callers decide whether to
|
|
7806
|
+
* fall back or to swallow. Returns the created check run, whose `id` is the
|
|
7807
|
+
* only handle on *which* run this call produced — what the read-back below
|
|
7808
|
+
* requires the served run to be.
|
|
7627
7809
|
*/
|
|
7628
|
-
async function
|
|
7629
|
-
const {
|
|
7630
|
-
const
|
|
7631
|
-
const
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
return false;
|
|
7636
|
-
}
|
|
7810
|
+
async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUrl, input, remainingAttempts }) {
|
|
7811
|
+
const { attempts, budgetMs, delayMs } = dependencies.retry ?? DEFAULT_CHECK_RUN_RETRY;
|
|
7812
|
+
const clock = dependencies.now ?? Date.now;
|
|
7813
|
+
const remaining = remainingAttempts ?? Math.max(1, attempts);
|
|
7814
|
+
const deadline = deadlineMs ?? (budgetMs === void 0 ? void 0 : clock() + budgetMs);
|
|
7815
|
+
const request = dependencies.fetch ?? fetch;
|
|
7816
|
+
const timeoutMs = dependencies.timeoutMs ?? 5e3;
|
|
7637
7817
|
try {
|
|
7638
|
-
const
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
};
|
|
7642
|
-
const tokenDependencies = {
|
|
7643
|
-
...dependencies,
|
|
7644
|
-
token: access.token
|
|
7645
|
-
};
|
|
7646
|
-
const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, onDiagnostic);
|
|
7647
|
-
const created = await postFactoryCheckRun({
|
|
7648
|
-
config,
|
|
7649
|
-
dependencies: {
|
|
7650
|
-
retry: DURABLE_CHECK_RUN_RETRY,
|
|
7651
|
-
...tokenDependencies
|
|
7652
|
-
},
|
|
7653
|
-
detailsUrl,
|
|
7654
|
-
input
|
|
7818
|
+
const token = dependencies.token ?? await installationToken(input, config, request, (dependencies.now ?? Date.now)(), {
|
|
7819
|
+
timeoutMs,
|
|
7820
|
+
transportAttempts: 1
|
|
7655
7821
|
});
|
|
7656
|
-
return await
|
|
7822
|
+
return await requestGitHubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/check-runs`, {
|
|
7823
|
+
body: JSON.stringify(checkRunRequestBody(input, detailsUrl)),
|
|
7824
|
+
headers: {
|
|
7825
|
+
Authorization: `Bearer ${token}`,
|
|
7826
|
+
"Content-Type": "application/json"
|
|
7827
|
+
},
|
|
7828
|
+
method: "POST"
|
|
7829
|
+
}, timeoutMs);
|
|
7830
|
+
} catch (error) {
|
|
7831
|
+
if (remaining <= 1 || !isRetryableCheckRunFailure(error) || deadline !== void 0 && clock() + delayMs >= deadline) throw error;
|
|
7832
|
+
await (dependencies.sleep ?? defaultSleep)(delayMs);
|
|
7833
|
+
return await postFactoryCheckRun({
|
|
7657
7834
|
config,
|
|
7658
|
-
|
|
7659
|
-
dependencies
|
|
7835
|
+
deadlineMs: deadline,
|
|
7836
|
+
dependencies,
|
|
7837
|
+
detailsUrl,
|
|
7660
7838
|
input,
|
|
7661
|
-
|
|
7839
|
+
remainingAttempts: remaining - 1
|
|
7662
7840
|
});
|
|
7663
|
-
} catch (error) {
|
|
7664
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
7665
|
-
onDiagnostic?.(`${FACTORY_CHECK_NAMES[input.gate]}: unable to republish check run: ${message}\n`);
|
|
7666
|
-
return false;
|
|
7667
7841
|
}
|
|
7668
7842
|
}
|
|
7669
|
-
function createFactoryCheckPublisher(output, dependencies = {}) {
|
|
7670
|
-
const postStatus = dependencies.postCommitStatus ?? createGhCommitStatusPoster(output);
|
|
7671
|
-
let publishQueue = Promise.resolve();
|
|
7672
|
-
return (input) => {
|
|
7673
|
-
const publish = async () => {
|
|
7674
|
-
await Promise.resolve();
|
|
7675
|
-
const checkName = FACTORY_CHECK_NAMES[input.gate];
|
|
7676
|
-
const { conclusion } = input;
|
|
7677
|
-
const status = input.status ?? "completed";
|
|
7678
|
-
if (status === "completed" && conclusion === void 0) throw new Error("A completed factory check requires a conclusion.");
|
|
7679
|
-
const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, (m) => output.stderr.write(m));
|
|
7680
|
-
const postFallback = (suppressFailureDiagnostic = false) => {
|
|
7681
|
-
try {
|
|
7682
|
-
postStatus({
|
|
7683
|
-
context: checkName,
|
|
7684
|
-
cwd: input.cwd,
|
|
7685
|
-
description: status === "in_progress" ? `${input.gate} gate is running` : `${input.gate} gate ${conclusion === "success" ? "passed" : "failed"}`,
|
|
7686
|
-
owner: input.owner,
|
|
7687
|
-
repo: input.repo,
|
|
7688
|
-
sha: input.sha,
|
|
7689
|
-
state: status === "in_progress" ? "pending" : conclusion ?? "failure",
|
|
7690
|
-
suppressFailureDiagnostic,
|
|
7691
|
-
targetUrl: detailsUrl
|
|
7692
|
-
});
|
|
7693
|
-
} catch (error) {
|
|
7694
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
7695
|
-
output.stderr.write(`${checkName}: unable to post fallback status: ${message}\n`);
|
|
7696
|
-
}
|
|
7697
|
-
};
|
|
7698
|
-
const access = await resolveFactoryAppAccess(input, {
|
|
7699
|
-
...dependencies,
|
|
7700
|
-
transportAttempts: 1
|
|
7701
|
-
});
|
|
7702
|
-
if (access.kind !== "ok") {
|
|
7703
|
-
output.stderr.write(`${checkName}: ${access.message}`);
|
|
7704
|
-
if (!access.failClosed) postFallback();
|
|
7705
|
-
return;
|
|
7706
|
-
}
|
|
7707
|
-
const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp ?? {
|
|
7708
|
-
appId: access.appId,
|
|
7709
|
-
privateKeyPath: "factory-app-broker"
|
|
7710
|
-
};
|
|
7711
|
-
try {
|
|
7712
|
-
await postFactoryCheckRun({
|
|
7713
|
-
config,
|
|
7714
|
-
dependencies: {
|
|
7715
|
-
...dependencies,
|
|
7716
|
-
token: access.token
|
|
7717
|
-
},
|
|
7718
|
-
detailsUrl,
|
|
7719
|
-
input
|
|
7720
|
-
});
|
|
7721
|
-
output.stdout.write(`${checkName} check run posted\n`);
|
|
7722
|
-
} catch (error) {
|
|
7723
|
-
const prePush = isPrePushVerifyBinding(input, error);
|
|
7724
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
7725
|
-
output.stderr.write(prePush ? prePushVerifyBindingNotice(input.sha) : `${checkName}: unable to post check run: ${message}\n`);
|
|
7726
|
-
postFallback(prePush);
|
|
7727
|
-
}
|
|
7728
|
-
};
|
|
7729
|
-
publishQueue = publishQueue.then(publish, publish);
|
|
7730
|
-
runDetachedBestEffort(() => publishQueue);
|
|
7731
|
-
};
|
|
7732
|
-
}
|
|
7733
|
-
//#endregion
|
|
7734
|
-
//#region src/repository-profile-path.ts
|
|
7735
7843
|
/**
|
|
7736
|
-
*
|
|
7737
|
-
* `
|
|
7738
|
-
*
|
|
7739
|
-
* symlinked checkout is not mistaken for a profile outside the repository.
|
|
7844
|
+
* Whether the Details-URL lookup failed only because the branch has no pull
|
|
7845
|
+
* request yet. `gh pr view` exits non-zero in that case, which is the ordinary
|
|
7846
|
+
* state of every `pr:verify` run made before the push (#323).
|
|
7740
7847
|
*/
|
|
7741
|
-
const
|
|
7742
|
-
try {
|
|
7743
|
-
return realpathSync(value);
|
|
7744
|
-
} catch {
|
|
7745
|
-
return value;
|
|
7746
|
-
}
|
|
7747
|
-
};
|
|
7848
|
+
const isNoPullRequestYet = (message) => /no pull requests found for branch/iu.test(message);
|
|
7748
7849
|
/**
|
|
7749
|
-
*
|
|
7750
|
-
*
|
|
7751
|
-
*
|
|
7850
|
+
* What to tell an operator when there is no pull request to link to yet.
|
|
7851
|
+
*
|
|
7852
|
+
* Reported as "unable to resolve Details URL", this reads as a defect to chase
|
|
7853
|
+
* on the exact path where that register has already cost a full redundant
|
|
7854
|
+
* verification once (PR #314, #316). Nothing is wrong: the commit link is the
|
|
7855
|
+
* correct Details target until a pull request exists, and `pr:publish` binds
|
|
7856
|
+
* the check to the pull request afterwards.
|
|
7752
7857
|
*/
|
|
7753
|
-
const
|
|
7858
|
+
const noPullRequestDetailsUrlNotice = (gate) => `${FACTORY_CHECK_NAMES[gate]}: no pull request exists for this branch yet, so the Details link points at the commit. That is the normal state before the branch is pushed, not a failure.
|
|
7859
|
+
`;
|
|
7860
|
+
async function resolveDetailsUrlSafely(input, dependencies, onDiagnostic) {
|
|
7754
7861
|
try {
|
|
7755
|
-
return
|
|
7756
|
-
} catch {
|
|
7757
|
-
|
|
7862
|
+
return await (dependencies.resolveDetailsUrl ?? resolveFactoryDetailsUrl)(input);
|
|
7863
|
+
} catch (error) {
|
|
7864
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7865
|
+
onDiagnostic?.(isNoPullRequestYet(message) ? noPullRequestDetailsUrlNotice(input.gate) : `${FACTORY_CHECK_NAMES[input.gate]}: unable to resolve Details URL; using commit: ${message}\n`);
|
|
7866
|
+
return `https://github.com/${input.owner}/${input.repo}/commit/${input.sha}`;
|
|
7758
7867
|
}
|
|
7759
|
-
}
|
|
7868
|
+
}
|
|
7760
7869
|
/**
|
|
7761
|
-
*
|
|
7870
|
+
* Read back every run GitHub serves for this check name on this head SHA.
|
|
7762
7871
|
*
|
|
7763
|
-
*
|
|
7764
|
-
*
|
|
7765
|
-
*
|
|
7766
|
-
*
|
|
7767
|
-
*
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
return rootRelative.length > 0 && !rootRelative.startsWith("../") ? rootRelative : cwdRelative;
|
|
7777
|
-
};
|
|
7778
|
-
/** A path that names no file inside the root it was measured from. */
|
|
7779
|
-
const escapesRoot = (relativePath) => relativePath.length === 0 || relativePath === ".." || relativePath.startsWith("../") || path.isAbsolute(relativePath);
|
|
7780
|
-
/**
|
|
7781
|
-
* The same path, for a caller that must REFUSE a profile the repository does
|
|
7782
|
-
* not contain.
|
|
7872
|
+
* `filter=all`, not `filter=latest` (#524). `latest` was chosen here on the
|
|
7873
|
+
* rationale that it is the view the required-check rollup reads; that rationale
|
|
7874
|
+
* is false, measured on PR #523's head `77cf425`. Two `patronage-factory/
|
|
7875
|
+
* pr-ready` runs existed there — `91323051988` (`in_progress`, stranded by
|
|
7876
|
+
* older code) and `91323113996` (`completed`/`success`). `latest` served the
|
|
7877
|
+
* completed one, while the pull request's rollup reported *both*
|
|
7878
|
+
* (`PENDING` and `SUCCESS`) and `mergeStateStatus` stayed `BLOCKED`. So `latest`
|
|
7879
|
+
* can report a publication confirmed while the gate still blocks on a different
|
|
7880
|
+
* run of the same name — false confidence in exactly the direction this
|
|
7881
|
+
* read-back exists to eliminate. The set the gate evaluates is every run of the
|
|
7882
|
+
* name, which is what this asks for.
|
|
7883
|
+
*
|
|
7884
|
+
* Three pins keep the answer usable as proof:
|
|
7783
7885
|
*
|
|
7784
|
-
*
|
|
7785
|
-
*
|
|
7786
|
-
*
|
|
7787
|
-
*
|
|
7788
|
-
*
|
|
7886
|
+
* - `app_id` — the required check is source-pinned to the factory App, so a
|
|
7887
|
+
* same-named run from another installed app (a GitHub Actions job named
|
|
7888
|
+
* `patronage-factory/pr-ready` materializes under integration 15368) is not
|
|
7889
|
+
* the pinned requirement. Left unpinned it could confirm a publication the
|
|
7890
|
+
* pinned check never got, or refuse every genuine one. This is the same
|
|
7891
|
+
* fail-closed rule `checkRunProducedByFactoryApp` applies to durable records
|
|
7892
|
+
* below. A run served without any `app` id is refused rather than filtered
|
|
7893
|
+
* out: dropping it fails closed for the newest-run clause but open for
|
|
7894
|
+
* terminality, and an unfinished run of unknown provenance still blocks the
|
|
7895
|
+
* gate.
|
|
7896
|
+
* - `per_page=100` with a required `total_count` that equals the page — a
|
|
7897
|
+
* truncated page holds neither the whole set nor a defensible newest run, and
|
|
7898
|
+
* `filter=all` returns strictly more runs than `latest` did, so this guard now
|
|
7899
|
+
* carries more weight. Truncation is unconfirmed, not paginated: a busy head
|
|
7900
|
+
* refuses rather than judging a publication against a page that silently
|
|
7901
|
+
* omits the run blocking it. An absent or malformed count cannot establish
|
|
7902
|
+
* completeness at all, so it refuses too.
|
|
7903
|
+
* - an unparsable or absent `started_at` — unorderable state is refused before
|
|
7904
|
+
* selection rather than sorting to an extreme and being chosen.
|
|
7789
7905
|
*
|
|
7790
|
-
*
|
|
7791
|
-
*
|
|
7792
|
-
* A cwd that is not a git checkout has no root to measure from and keeps the
|
|
7793
|
-
* previous cwd-relative rule exactly.
|
|
7906
|
+
* Ordering among what survives goes through the sequencing owner rather than a
|
|
7907
|
+
* second rule here.
|
|
7794
7908
|
*/
|
|
7795
|
-
|
|
7796
|
-
const
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7909
|
+
async function servedFactoryCheckRun(input, config, dependencies) {
|
|
7910
|
+
const request = dependencies.fetch ?? fetch;
|
|
7911
|
+
const timeoutMs = dependencies.timeoutMs ?? 5e3;
|
|
7912
|
+
const token = dependencies.token ?? await installationToken(input, config, request, (dependencies.now ?? Date.now)(), { timeoutMs });
|
|
7913
|
+
const query = new URLSearchParams({
|
|
7914
|
+
app_id: String(config.appId),
|
|
7915
|
+
check_name: FACTORY_CHECK_NAMES[input.gate],
|
|
7916
|
+
filter: "all",
|
|
7917
|
+
per_page: "100"
|
|
7800
7918
|
});
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
const
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
const named = stamp.targets.find((target) => target.impact === "affected" && target.subscribedPaths.includes(file));
|
|
7811
|
-
if (named) return {
|
|
7812
|
-
kind: "target",
|
|
7813
|
-
target: named.name
|
|
7919
|
+
const served = await requestGitHubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/commits/${input.sha}/check-runs?${query.toString()}`, {
|
|
7920
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
7921
|
+
method: "GET"
|
|
7922
|
+
}, timeoutMs);
|
|
7923
|
+
const page = Array.isArray(served.check_runs) ? served.check_runs : [];
|
|
7924
|
+
const totalCount = served.total_count;
|
|
7925
|
+
if (!(typeof totalCount === "number" && Number.isSafeInteger(totalCount))) return {
|
|
7926
|
+
kind: "unavailable",
|
|
7927
|
+
reason: "the served page reports no usable total_count"
|
|
7814
7928
|
};
|
|
7815
|
-
|
|
7816
|
-
|
|
7817
|
-
|
|
7818
|
-
target: affected.name
|
|
7929
|
+
if (totalCount !== page.length) return {
|
|
7930
|
+
kind: "unavailable",
|
|
7931
|
+
reason: `${page.length} runs on the page against a total_count of ${totalCount}`
|
|
7819
7932
|
};
|
|
7820
|
-
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
switch (ownership.kind) {
|
|
7824
|
-
case "inert": return `${file}: repository-inert ownership (stamp.inertPaths)`;
|
|
7825
|
-
case "target": return `${file}: affects product target "${ownership.target}"`;
|
|
7826
|
-
default: return `${file}: no declared owner; fail closed to non-trivial`;
|
|
7827
|
-
}
|
|
7828
|
-
}
|
|
7829
|
-
function relativeProfilePath(options) {
|
|
7830
|
-
return repositoryRelativeProfilePath({
|
|
7831
|
-
cwd: options.cwd,
|
|
7832
|
-
profilePath: options.profilePath
|
|
7833
|
-
});
|
|
7834
|
-
}
|
|
7835
|
-
function applyProfileEditGuard(files, relativeProfile, profilePath, base) {
|
|
7836
|
-
if (!files.some((file) => file === relativeProfile || file === profilePath)) return base;
|
|
7837
|
-
return {
|
|
7838
|
-
classification: "non-trivial",
|
|
7839
|
-
reasons: [...base.reasons, `${relativeProfile}: edits the ownership declarations; forced non-trivial`]
|
|
7933
|
+
if (page.some((run) => (run.app?.id ?? null) === null)) return {
|
|
7934
|
+
kind: "unavailable",
|
|
7935
|
+
reason: "a served run has no app identity"
|
|
7840
7936
|
};
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7937
|
+
const runs = page.filter((run) => String(run.app?.id ?? "") === String(config.appId));
|
|
7938
|
+
const orderable = runs.flatMap((run) => {
|
|
7939
|
+
const orderMs = generationOrderMs(run.started_at);
|
|
7940
|
+
return Number.isFinite(orderMs) ? [{
|
|
7941
|
+
orderMs,
|
|
7942
|
+
run
|
|
7943
|
+
}] : [];
|
|
7944
|
+
});
|
|
7945
|
+
if (orderable.length !== runs.length) return {
|
|
7946
|
+
kind: "unavailable",
|
|
7947
|
+
reason: "a served run has no usable started_at"
|
|
7851
7948
|
};
|
|
7852
|
-
const
|
|
7853
|
-
if (
|
|
7854
|
-
|
|
7855
|
-
|
|
7949
|
+
const newest = selectNewestGeneration(orderable);
|
|
7950
|
+
if (newest.kind === "ambiguous") return {
|
|
7951
|
+
kind: "unavailable",
|
|
7952
|
+
reason: `${newest.tied.length} served runs share the newest started_at`
|
|
7856
7953
|
};
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
}));
|
|
7861
|
-
const reasons = ownerships.map(({ file, ownership }) => reasonForOwnership(file, ownership));
|
|
7862
|
-
if (ownerships.every(({ ownership }) => ownership.kind === "inert")) return {
|
|
7863
|
-
classification: "docs/process-only",
|
|
7864
|
-
reasons
|
|
7954
|
+
if (newest.kind === "none") return {
|
|
7955
|
+
kind: "unavailable",
|
|
7956
|
+
reason: "no run is served for this name"
|
|
7865
7957
|
};
|
|
7866
7958
|
return {
|
|
7867
|
-
|
|
7868
|
-
|
|
7959
|
+
kind: "newest",
|
|
7960
|
+
run: newest.generation.run,
|
|
7961
|
+
runs
|
|
7869
7962
|
};
|
|
7870
7963
|
}
|
|
7964
|
+
/** Runs GitHub has not finished. A non-terminal run blocks its required check. */
|
|
7965
|
+
const unfinishedRuns = (runs) => runs.filter((run) => run.status !== "completed");
|
|
7871
7966
|
/**
|
|
7872
|
-
*
|
|
7873
|
-
* the
|
|
7874
|
-
*
|
|
7875
|
-
* ownership — a conservative stamp (unreadable or unsupported lockfile sides,
|
|
7876
|
-
* no declared targets, computation doubt) refuses the reduced rung, so a
|
|
7877
|
-
* fail-closed stamp can never coexist with a reduced verification battery
|
|
7878
|
-
* (#742 review cycle 3).
|
|
7967
|
+
* Whether the served run *is* the publication that was just posted: the same
|
|
7968
|
+
* run, in the status that was asked for, and — for a completed one — with the
|
|
7969
|
+
* conclusion that was asked for.
|
|
7879
7970
|
*
|
|
7880
|
-
*
|
|
7881
|
-
*
|
|
7882
|
-
*
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
const base = applyProfileEditGuard(files, relativeProfilePath(options), options.profilePath, classifyDiff(files, stamp));
|
|
7886
|
-
if (stamp === void 0 || base.classification === "non-trivial") return base;
|
|
7887
|
-
if (stamp.basis !== "target-scoped") return {
|
|
7888
|
-
classification: "non-trivial",
|
|
7889
|
-
reasons: [...base.reasons, "impact stamp is conservative; the docs/process-only rung needs a target-scoped stamp with zero affected targets"]
|
|
7890
|
-
};
|
|
7891
|
-
const affected = stamp.targets.filter((target) => target.impact === "affected");
|
|
7892
|
-
if (affected.length > 0) return {
|
|
7893
|
-
classification: "non-trivial",
|
|
7894
|
-
reasons: [...base.reasons, `impact stamp records ${affected.length} affected target(s); forced non-trivial`]
|
|
7895
|
-
};
|
|
7896
|
-
return base;
|
|
7897
|
-
}
|
|
7898
|
-
/**
|
|
7899
|
-
* Path predicate for consumers that do not already hold a stamp (pr-ready's
|
|
7900
|
-
* docs-only delta). Computes one stamp through {@link computeImpactStamp} —
|
|
7901
|
-
* the only glob-evaluation site — then reads ownership from it.
|
|
7971
|
+
* Requiring the id is what separates "GitHub serves a run that looks like
|
|
7972
|
+
* mine" from "GitHub serves mine". An older identical success from a previous
|
|
7973
|
+
* invocation satisfies every field comparison while this POST is still
|
|
7974
|
+
* invisible, and confirming on it is exactly the assumed-vs-live trust this
|
|
7975
|
+
* read-back exists to end.
|
|
7902
7976
|
*/
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
const
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
readLockfile: () => void 0
|
|
7910
|
-
});
|
|
7911
|
-
return applyProfileEditGuard(files, relativeProfile, options.profilePath, classifyDiff(files, stamp));
|
|
7912
|
-
}
|
|
7913
|
-
//#endregion
|
|
7914
|
-
//#region src/catch-up-recognition-record.ts
|
|
7915
|
-
const objectShaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
7916
|
-
const catchUpRecognitionSchema = z.object({
|
|
7917
|
-
baseRef: z.string().min(1),
|
|
7918
|
-
baseTipSha: objectShaSchema,
|
|
7919
|
-
mergedParentSha: objectShaSchema,
|
|
7920
|
-
mergedTreeSha: objectShaSchema,
|
|
7921
|
-
upstreamRef: z.string().min(1)
|
|
7922
|
-
}).strict();
|
|
7923
|
-
//#endregion
|
|
7924
|
-
//#region src/policy-resolution.ts
|
|
7925
|
-
const COMMIT_SHA_PATTERN$3 = /^[0-9a-f]{40}$/u;
|
|
7926
|
-
const digestSchema = z.string().regex(/^[0-9a-f]{64}$/u);
|
|
7977
|
+
const publishedRunIsServed = (input, createdId, served) => {
|
|
7978
|
+
if (createdId === void 0 || served.id !== createdId) return false;
|
|
7979
|
+
const status = input.status ?? "completed";
|
|
7980
|
+
if (served.status !== status) return false;
|
|
7981
|
+
return status !== "completed" || served.conclusion === input.conclusion;
|
|
7982
|
+
};
|
|
7927
7983
|
/**
|
|
7928
|
-
*
|
|
7929
|
-
*
|
|
7930
|
-
*
|
|
7984
|
+
* Whether the required check this publication targets is *satisfied* on the
|
|
7985
|
+
* head: the newest run of the name is this call's own, and no run of the name
|
|
7986
|
+
* survives unfinished.
|
|
7931
7987
|
*
|
|
7932
|
-
*
|
|
7933
|
-
*
|
|
7934
|
-
*
|
|
7935
|
-
*
|
|
7936
|
-
*
|
|
7937
|
-
*
|
|
7988
|
+
* The second clause is what makes the answer the *gate's* answer rather than
|
|
7989
|
+
* one collapsed view of it (#524). A stranded `in_progress` run of a
|
|
7990
|
+
* required-check name blocks its pull request permanently and is not cleared by
|
|
7991
|
+
* publishing a newer completed run, so a confirmation that ignores it is a lie
|
|
7992
|
+
* in the one direction that matters. A publication that is itself non-terminal
|
|
7993
|
+
* therefore never confirms — correctly, since it cannot satisfy the check
|
|
7994
|
+
* either. No factory gate makes one for this name any more: `pr:ready`'s
|
|
7995
|
+
* in-progress writer, the last of them, is deleted (#526).
|
|
7938
7996
|
*/
|
|
7939
|
-
const
|
|
7940
|
-
/** sha256 of the canonical base `review` subtree. */
|
|
7941
|
-
basePolicyDigest: digestSchema,
|
|
7942
|
-
/** The PR's own base ref, whose live tip is the authority. */
|
|
7943
|
-
baseRefName: z.string().min(1),
|
|
7944
|
-
/** sha256 of the canonical candidate `review` subtree. */
|
|
7945
|
-
candidatePolicyDigest: digestSchema,
|
|
7946
|
-
/** sha256 of the merged (base ∪ candidate) protected-path set. */
|
|
7947
|
-
effectiveDigest: digestSchema,
|
|
7948
|
-
/** The base-ref tip the base policy was read at. */
|
|
7949
|
-
policyBaseSha: z.string().regex(COMMIT_SHA_PATTERN$3),
|
|
7950
|
-
/**
|
|
7951
|
-
* Whether the candidate's `review` subtree differs from the base's. The
|
|
7952
|
-
* digests prove which policies were read; this says whether they agreed,
|
|
7953
|
-
* which is the fact that explains a demand the candidate's own profile
|
|
7954
|
-
* would not have produced.
|
|
7955
|
-
*/
|
|
7956
|
-
reviewPolicyChanged: z.boolean(),
|
|
7957
|
-
status: z.literal("authoritative")
|
|
7958
|
-
}).strict();
|
|
7997
|
+
const servedRunConfirmsPublication = (input, createdId, selection) => publishedRunIsServed(input, createdId, selection.run) && unfinishedRuns(selection.runs).length === 0;
|
|
7959
7998
|
/**
|
|
7960
|
-
*
|
|
7961
|
-
*
|
|
7962
|
-
*
|
|
7963
|
-
*
|
|
7964
|
-
* forward.
|
|
7999
|
+
* Which of the two refusals happened, reported in the order an operator can act
|
|
8000
|
+
* on: this call's own publication first, then any *other* run holding the check
|
|
8001
|
+
* open. Once the first clause holds, this call's run is terminal, so everything
|
|
8002
|
+
* the second clause names belongs to something else.
|
|
7965
8003
|
*/
|
|
7966
|
-
const
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
status: z.literal("unresolved")
|
|
7972
|
-
}).strict();
|
|
7973
|
-
/** How a gate resolved the review policy it evaluated a candidate under. */
|
|
7974
|
-
const policyResolutionSchema = z.discriminatedUnion("status", [authoritativePolicyResolutionSchema, unresolvedPolicyResolutionSchema]);
|
|
7975
|
-
//#endregion
|
|
7976
|
-
//#region src/verification-battery.ts
|
|
7977
|
-
const UNCONDITIONAL_BASIS = "unconditional: the profile declares no impactTarget for this command";
|
|
8004
|
+
const unconfirmedDetail = (input, createdId, selection) => {
|
|
8005
|
+
const served = selection.run;
|
|
8006
|
+
if (!publishedRunIsServed(input, createdId, served)) return served.id === createdId ? `status ${served.status ?? "unknown"}${served.conclusion ? `, conclusion ${served.conclusion}` : ""}` : `run ${served.id ?? "unknown"} rather than the run ${createdId ?? "unknown"} this call created`;
|
|
8007
|
+
return `${unfinishedRuns(selection.runs).map((run) => `run ${run.id ?? "unknown"} (${run.status ?? "unknown"})`).join(", ")} unfinished for this name alongside it — a non-terminal run blocks the required check whatever this call published, and publishing a newer one does not clear it`;
|
|
8008
|
+
};
|
|
7978
8009
|
/**
|
|
7979
|
-
*
|
|
7980
|
-
*
|
|
7981
|
-
* `new Set(undefined)` is a perfectly good empty set, so an untyped JavaScript
|
|
7982
|
-
* caller that omits it would otherwise scope exactly as if it had inspected
|
|
7983
|
-
* the envelopes and found nothing — the silent state requiring the parameter
|
|
7984
|
-
* exists to eliminate.
|
|
8010
|
+
* Confirm the publication against what GitHub serves, and say plainly which of
|
|
8011
|
+
* the two events failed when it cannot.
|
|
7985
8012
|
*
|
|
7986
|
-
*
|
|
7987
|
-
*
|
|
7988
|
-
*
|
|
8013
|
+
* Only the read is wrapped. A token, timeout, or 5xx failure here is not a
|
|
8014
|
+
* failure to publish — the POST already succeeded and the check is probably
|
|
8015
|
+
* present — so it must not be reported through the "unable to republish"
|
|
8016
|
+
* register, which drives `pr:ready`'s "this run's verdict never reached the
|
|
8017
|
+
* required check" notice.
|
|
7989
8018
|
*/
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
8019
|
+
async function confirmPublishedCheckRun({ config, createdId, dependencies, input, onDiagnostic }) {
|
|
8020
|
+
const checkName = FACTORY_CHECK_NAMES[input.gate];
|
|
8021
|
+
let selection;
|
|
8022
|
+
try {
|
|
8023
|
+
selection = await servedFactoryCheckRun(input, config, dependencies);
|
|
8024
|
+
} catch (error) {
|
|
8025
|
+
selection = {
|
|
8026
|
+
kind: "unavailable",
|
|
8027
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
8028
|
+
};
|
|
8029
|
+
}
|
|
8030
|
+
if (selection.kind === "unavailable") {
|
|
8031
|
+
onDiagnostic?.(`${checkName}: the check run was accepted but could not be read back for ${input.sha.slice(0, 7)} (${selection.reason}), so the publication is not confirmed. The check itself may well be present.\n`);
|
|
8032
|
+
return false;
|
|
8033
|
+
}
|
|
8034
|
+
if (!servedRunConfirmsPublication(input, createdId, selection)) {
|
|
8035
|
+
onDiagnostic?.(`${checkName}: the check run was accepted but for ${input.sha.slice(0, 7)} GitHub serves ${unconfirmedDetail(input, createdId, selection)}, so the publication is not confirmed.\n`);
|
|
8036
|
+
return false;
|
|
8037
|
+
}
|
|
8038
|
+
return true;
|
|
8039
|
+
}
|
|
7993
8040
|
/**
|
|
7994
|
-
*
|
|
8041
|
+
* Publish a factory check run and *wait* for it, so a caller that has just made
|
|
8042
|
+
* the head SHA visible on GitHub (pushed the branch, created the PR) can make
|
|
8043
|
+
* the proof reliably present for that SHA before it returns (#247).
|
|
7995
8044
|
*
|
|
7996
|
-
*
|
|
7997
|
-
*
|
|
7998
|
-
*
|
|
7999
|
-
*
|
|
8000
|
-
*
|
|
8045
|
+
* Never posts a commit-status fallback: the commit status is a human-readable
|
|
8046
|
+
* mirror, not a proof surface, so a caller that needs an App-verified check
|
|
8047
|
+
* run must be told plainly whether it got one. Returns `true` only when the
|
|
8048
|
+
* App-owned check run landed. Throws {@link FactoryAppUnevaluableError} when
|
|
8049
|
+
* Factory App access is fail-closed (missing mint or Cursor OIDC exchange,
|
|
8050
|
+
* #949): `pr:ready` must not notice that miss and exit 0, and must not publish
|
|
8051
|
+
* a red check that reads as candidate refusal (#1125). An optional-App skip
|
|
8052
|
+
* still returns `false`.
|
|
8001
8053
|
*
|
|
8002
|
-
*
|
|
8003
|
-
* the
|
|
8004
|
-
*
|
|
8005
|
-
*
|
|
8054
|
+
* "Landed" means GitHub serves it, not that the POST was accepted (#520). On
|
|
8055
|
+
* PR #519 the POST was accepted, `pr:ready` reported ready and armed, and the
|
|
8056
|
+
* source-pinned required check read `in_progress` across three runs — so GitHub
|
|
8057
|
+
* never scheduled the merge and emitted no rollup row saying why. Arming
|
|
8058
|
+
* already refuses to infer its outcome from the invocation and reads the pull
|
|
8059
|
+
* request back (`arm-auto-merge.ts`); publication now does the same.
|
|
8060
|
+
*
|
|
8061
|
+
* "Landed" is judged against every run of the name from the pinned App, not
|
|
8062
|
+
* against the one GitHub collapses to (#524): the newest must be *this* run, in
|
|
8063
|
+
* the status and conclusion that were published, and no run of the name may
|
|
8064
|
+
* still be unfinished. A surviving `in_progress` run blocks the required check
|
|
8065
|
+
* on its own, so confirming past it would report success on a pull request
|
|
8066
|
+
* GitHub will never merge. One read, no polling: an unconfirmed publication
|
|
8067
|
+
* returns `false`, which `pr:ready` already turns into a notice and an
|
|
8068
|
+
* idempotent re-dispatch.
|
|
8069
|
+
*
|
|
8070
|
+
* Confirmation is a point-in-time read, deliberately: a later publication for
|
|
8071
|
+
* the same name changes the answer — a completed one by becoming the newest, an
|
|
8072
|
+
* unfinished one by holding the check open beside this verdict rather than
|
|
8073
|
+
* replacing it — and every remaining writer publishes once, as the last thing
|
|
8074
|
+
* its invocation does. No retry, no poll, no re-confirm.
|
|
8006
8075
|
*/
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
const
|
|
8010
|
-
const
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
disposition: scoped ? "not-required" : "executed",
|
|
8035
|
-
impactTarget,
|
|
8036
|
-
name
|
|
8076
|
+
async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
|
|
8077
|
+
const { onDiagnostic } = dependencies;
|
|
8078
|
+
const checkName = FACTORY_CHECK_NAMES[input.gate];
|
|
8079
|
+
const access = await resolveFactoryAppAccess(input, dependencies);
|
|
8080
|
+
if (access.kind !== "ok") {
|
|
8081
|
+
onDiagnostic?.(`${checkName}: ${access.message}`);
|
|
8082
|
+
if (access.failClosed) throw new FactoryAppUnevaluableError(`${checkName}: ${access.message.trim()}`);
|
|
8083
|
+
return false;
|
|
8084
|
+
}
|
|
8085
|
+
try {
|
|
8086
|
+
const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp ?? {
|
|
8087
|
+
appId: access.appId,
|
|
8088
|
+
privateKeyPath: "factory-app-broker"
|
|
8089
|
+
};
|
|
8090
|
+
const tokenDependencies = {
|
|
8091
|
+
...dependencies,
|
|
8092
|
+
token: access.token
|
|
8093
|
+
};
|
|
8094
|
+
const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, onDiagnostic);
|
|
8095
|
+
const created = await postFactoryCheckRun({
|
|
8096
|
+
config,
|
|
8097
|
+
dependencies: {
|
|
8098
|
+
retry: DURABLE_CHECK_RUN_RETRY,
|
|
8099
|
+
...tokenDependencies
|
|
8100
|
+
},
|
|
8101
|
+
detailsUrl,
|
|
8102
|
+
input
|
|
8037
8103
|
});
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8104
|
+
return await confirmPublishedCheckRun({
|
|
8105
|
+
config,
|
|
8106
|
+
createdId: typeof created.id === "number" ? created.id : void 0,
|
|
8107
|
+
dependencies: tokenDependencies,
|
|
8108
|
+
input,
|
|
8109
|
+
onDiagnostic
|
|
8042
8110
|
});
|
|
8043
|
-
|
|
8111
|
+
} catch (error) {
|
|
8112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8113
|
+
onDiagnostic?.(`${FACTORY_CHECK_NAMES[input.gate]}: unable to republish check run: ${message}\n`);
|
|
8114
|
+
return false;
|
|
8115
|
+
}
|
|
8116
|
+
}
|
|
8117
|
+
function createFactoryCheckPublisher(output, dependencies = {}) {
|
|
8118
|
+
const postStatus = dependencies.postCommitStatus ?? createGhCommitStatusPoster(output);
|
|
8119
|
+
let publishQueue = Promise.resolve();
|
|
8120
|
+
return (input) => {
|
|
8121
|
+
const publish = async () => {
|
|
8122
|
+
await Promise.resolve();
|
|
8123
|
+
const checkName = FACTORY_CHECK_NAMES[input.gate];
|
|
8124
|
+
const { conclusion } = input;
|
|
8125
|
+
const status = input.status ?? "completed";
|
|
8126
|
+
if (status === "completed" && conclusion === void 0) throw new Error("A completed factory check requires a conclusion.");
|
|
8127
|
+
const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, (m) => output.stderr.write(m));
|
|
8128
|
+
const postFallback = (suppressFailureDiagnostic = false) => {
|
|
8129
|
+
try {
|
|
8130
|
+
postStatus({
|
|
8131
|
+
context: checkName,
|
|
8132
|
+
cwd: input.cwd,
|
|
8133
|
+
description: status === "in_progress" ? `${input.gate} gate is running` : `${input.gate} gate ${conclusion === "success" ? "passed" : "failed"}`,
|
|
8134
|
+
owner: input.owner,
|
|
8135
|
+
repo: input.repo,
|
|
8136
|
+
sha: input.sha,
|
|
8137
|
+
state: status === "in_progress" ? "pending" : conclusion ?? "failure",
|
|
8138
|
+
suppressFailureDiagnostic,
|
|
8139
|
+
targetUrl: detailsUrl
|
|
8140
|
+
});
|
|
8141
|
+
} catch (error) {
|
|
8142
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8143
|
+
output.stderr.write(`${checkName}: unable to post fallback status: ${message}\n`);
|
|
8144
|
+
}
|
|
8145
|
+
};
|
|
8146
|
+
const access = await resolveFactoryAppAccess(input, {
|
|
8147
|
+
...dependencies,
|
|
8148
|
+
transportAttempts: 1
|
|
8149
|
+
});
|
|
8150
|
+
if (access.kind !== "ok") {
|
|
8151
|
+
output.stderr.write(`${checkName}: ${access.message}`);
|
|
8152
|
+
if (!access.failClosed) postFallback();
|
|
8153
|
+
return;
|
|
8154
|
+
}
|
|
8155
|
+
const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp ?? {
|
|
8156
|
+
appId: access.appId,
|
|
8157
|
+
privateKeyPath: "factory-app-broker"
|
|
8158
|
+
};
|
|
8159
|
+
try {
|
|
8160
|
+
await postFactoryCheckRun({
|
|
8161
|
+
config,
|
|
8162
|
+
dependencies: {
|
|
8163
|
+
...dependencies,
|
|
8164
|
+
token: access.token
|
|
8165
|
+
},
|
|
8166
|
+
detailsUrl,
|
|
8167
|
+
input
|
|
8168
|
+
});
|
|
8169
|
+
output.stdout.write(`${checkName} check run posted\n`);
|
|
8170
|
+
} catch (error) {
|
|
8171
|
+
const prePush = isPrePushVerifyBinding(input, error);
|
|
8172
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8173
|
+
output.stderr.write(prePush ? prePushVerifyBindingNotice(input.sha) : `${checkName}: unable to post check run: ${message}\n`);
|
|
8174
|
+
postFallback(prePush);
|
|
8175
|
+
}
|
|
8176
|
+
};
|
|
8177
|
+
publishQueue = publishQueue.then(publish, publish);
|
|
8178
|
+
runDetachedBestEffort(() => publishQueue);
|
|
8179
|
+
};
|
|
8180
|
+
}
|
|
8181
|
+
const configuredFactoryAppId = (config) => config === void 0 ? void 0 : String(config.appId);
|
|
8182
|
+
const checkRunProducedByFactoryApp = ({ app, expectedAppId }) => {
|
|
8183
|
+
if (!expectedAppId || !app) return;
|
|
8184
|
+
if (app.id !== void 0 && app.id !== null && String(app.id) === expectedAppId) return {
|
|
8185
|
+
identity: String(app.id),
|
|
8186
|
+
mode: "app"
|
|
8187
|
+
};
|
|
8188
|
+
if (typeof app.slug === "string" && app.slug.length > 0 && app.slug === expectedAppId) return {
|
|
8189
|
+
identity: app.slug,
|
|
8190
|
+
mode: "app"
|
|
8191
|
+
};
|
|
8192
|
+
};
|
|
8193
|
+
const completeCheckRunPages = (pages) => {
|
|
8194
|
+
if (!Array.isArray(pages) || pages.length === 0 || pages.some((page) => !Array.isArray(page?.check_runs) || !Number.isSafeInteger(page.total_count) || page.total_count < 0)) return;
|
|
8195
|
+
const runs = pages.flatMap((page) => page.check_runs);
|
|
8196
|
+
return pages.every((page) => page.total_count === runs.length) ? runs : void 0;
|
|
8197
|
+
};
|
|
8198
|
+
/**
|
|
8199
|
+
* Read the machine-readable `pr:verify` binding for one commit (#247).
|
|
8200
|
+
*
|
|
8201
|
+
* Deliberately narrow: it reads check runs only, and only those whose `app`
|
|
8202
|
+
* identity matches the configured factory App. It never consults commit
|
|
8203
|
+
* statuses. `patronage-factory/pr-verify` is postable as a commit status by any
|
|
8204
|
+
* token with write access, so that surface stays a human-readable mirror and
|
|
8205
|
+
* can never satisfy a consumer that wants proof. Absent, foreign, or
|
|
8206
|
+
* unparseable ⇒ `undefined` (fail closed).
|
|
8207
|
+
*/
|
|
8208
|
+
function fetchPrVerifyProofBinding({ expectedAppId, githubApp, owner, repo, sha, runJson = runGhJson }) {
|
|
8209
|
+
const resolvedAppId = expectedAppId ?? configuredFactoryAppId(githubApp ?? tryUserConfig()?.config.githubApp);
|
|
8210
|
+
if (!resolvedAppId) return;
|
|
8211
|
+
const checkName = FACTORY_CHECK_NAMES["pr-verify"];
|
|
8212
|
+
let pages;
|
|
8213
|
+
try {
|
|
8214
|
+
pages = runJson([
|
|
8215
|
+
"api",
|
|
8216
|
+
"--paginate",
|
|
8217
|
+
"--slurp",
|
|
8218
|
+
`/repos/${owner}/${repo}/commits/${sha}/check-runs?check_name=${encodeURIComponent(checkName)}&filter=all&per_page=100`
|
|
8219
|
+
]);
|
|
8220
|
+
} catch {
|
|
8221
|
+
return;
|
|
8044
8222
|
}
|
|
8223
|
+
const runs = completeCheckRunPages(pages);
|
|
8224
|
+
if (!runs) return;
|
|
8225
|
+
const ordered = runs.filter((run) => run?.name === checkName && checkRunProducedByFactoryApp({
|
|
8226
|
+
app: run.app,
|
|
8227
|
+
expectedAppId: resolvedAppId
|
|
8228
|
+
})).map((run) => ({
|
|
8229
|
+
orderMs: generationOrderMs(run.started_at),
|
|
8230
|
+
run
|
|
8231
|
+
}));
|
|
8232
|
+
if (ordered.some(({ orderMs }) => !Number.isFinite(orderMs))) return;
|
|
8233
|
+
const selected = selectNewestGeneration(ordered);
|
|
8234
|
+
if (selected.kind !== "newest") return;
|
|
8235
|
+
const { run } = selected.generation;
|
|
8236
|
+
if (run.status !== "completed" || run.head_sha !== sha) return;
|
|
8237
|
+
const payload = parsePrVerifyCheckPayload(run.output?.text) ?? parsePrVerifyCheckPayload(run.output?.summary);
|
|
8238
|
+
if (!payload || payload.headSha !== sha || payload.repository !== `${owner}/${repo}`) return;
|
|
8239
|
+
const producer = checkRunProducedByFactoryApp({
|
|
8240
|
+
app: run.app,
|
|
8241
|
+
expectedAppId: resolvedAppId
|
|
8242
|
+
});
|
|
8243
|
+
if (!producer) return;
|
|
8045
8244
|
return {
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
8245
|
+
completedAtMs: generationOrderMs(run.completed_at),
|
|
8246
|
+
conclusion: run.conclusion ?? "",
|
|
8247
|
+
payload,
|
|
8248
|
+
producer
|
|
8049
8249
|
};
|
|
8250
|
+
}
|
|
8251
|
+
//#endregion
|
|
8252
|
+
//#region src/repository-profile-path.ts
|
|
8253
|
+
/**
|
|
8254
|
+
* `git rev-parse --show-toplevel` answers with a real path, while a caller's
|
|
8255
|
+
* `cwd` may reach the same directory through a symlink (every macOS temporary
|
|
8256
|
+
* directory does). Both sides are resolved before they are compared, so a
|
|
8257
|
+
* symlinked checkout is not mistaken for a profile outside the repository.
|
|
8258
|
+
*/
|
|
8259
|
+
const realPath = (value) => {
|
|
8260
|
+
try {
|
|
8261
|
+
return realpathSync(value);
|
|
8262
|
+
} catch {
|
|
8263
|
+
return value;
|
|
8264
|
+
}
|
|
8050
8265
|
};
|
|
8051
8266
|
/**
|
|
8052
|
-
* The
|
|
8053
|
-
*
|
|
8054
|
-
*
|
|
8055
|
-
* Four rules:
|
|
8056
|
-
* 1. A withheld command names one the resolved mode actually selected.
|
|
8057
|
-
* 2. A command is never recorded as both executed and not-required.
|
|
8058
|
-
* 3. Withheld commands are distinct — a duplicated disposition would let one
|
|
8059
|
-
* name carry two different bases.
|
|
8060
|
-
* 4. On a PASSED proof, every selected command has a disposition: silence is
|
|
8061
|
-
* not a disposition, and "absent from both lists" is exactly the silent
|
|
8062
|
-
* skip this epic forbids.
|
|
8063
|
-
*
|
|
8064
|
-
* Rule 4 is scoped to `outcome: "passed"` because an ABORTED proof legitimately
|
|
8065
|
-
* records partial execution — the run stopped at the failing command.
|
|
8066
|
-
*
|
|
8067
|
-
* The check is one-directional on purpose. Every SELECTED command must be
|
|
8068
|
-
* accounted for, but `executedCommands` may legitimately carry MORE than the
|
|
8069
|
-
* profile selected — full mode appends the `workspace:install-resolves` probe,
|
|
8070
|
-
* which is real work no profile declares.
|
|
8267
|
+
* The repository top level for `cwd`, or `cwd` itself when it is not a git
|
|
8268
|
+
* checkout. Never throws: a caller rooting a path has a usable answer either
|
|
8269
|
+
* way, and the checkout-less case keeps the previous cwd-rooted behavior.
|
|
8071
8270
|
*/
|
|
8072
|
-
const
|
|
8073
|
-
|
|
8074
|
-
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
const seen = /* @__PURE__ */ new Set();
|
|
8078
|
-
for (const [index, entry] of notRequired.entries()) {
|
|
8079
|
-
if (!selectedSet.has(entry.name)) context.addIssue({
|
|
8080
|
-
code: "custom",
|
|
8081
|
-
message: `notRequiredCommands entry "${entry.name}" is not one of this proof's verificationCommands.`,
|
|
8082
|
-
path: [
|
|
8083
|
-
"notRequiredCommands",
|
|
8084
|
-
index,
|
|
8085
|
-
"name"
|
|
8086
|
-
]
|
|
8087
|
-
});
|
|
8088
|
-
if (executed.has(entry.name)) context.addIssue({
|
|
8089
|
-
code: "custom",
|
|
8090
|
-
message: `Command "${entry.name}" is recorded both as executed and as not-required.`,
|
|
8091
|
-
path: [
|
|
8092
|
-
"notRequiredCommands",
|
|
8093
|
-
index,
|
|
8094
|
-
"name"
|
|
8095
|
-
]
|
|
8096
|
-
});
|
|
8097
|
-
if (seen.has(entry.name)) context.addIssue({
|
|
8098
|
-
code: "custom",
|
|
8099
|
-
message: `notRequiredCommands records "${entry.name}" more than once.`,
|
|
8100
|
-
path: [
|
|
8101
|
-
"notRequiredCommands",
|
|
8102
|
-
index,
|
|
8103
|
-
"name"
|
|
8104
|
-
]
|
|
8105
|
-
});
|
|
8106
|
-
seen.add(entry.name);
|
|
8271
|
+
const repositoryRootOrCwd = ({ cwd, readRepositoryRoot = repositoryRoot }) => {
|
|
8272
|
+
try {
|
|
8273
|
+
return readRepositoryRoot(cwd);
|
|
8274
|
+
} catch {
|
|
8275
|
+
return cwd;
|
|
8107
8276
|
}
|
|
8108
|
-
if (proof.outcome !== "passed") return;
|
|
8109
|
-
const disposed = new Set([...executed, ...seen]);
|
|
8110
|
-
const undisposed = selected.filter((name) => !disposed.has(name));
|
|
8111
|
-
if (undisposed.length > 0) context.addIssue({
|
|
8112
|
-
code: "custom",
|
|
8113
|
-
message: `A passed pr:verify proof must give every selected verification command a disposition; [${undisposed.join(", ")}] appear in verificationCommands but in neither executedCommands nor notRequiredCommands.`,
|
|
8114
|
-
path: ["executedCommands"]
|
|
8115
|
-
});
|
|
8116
8277
|
};
|
|
8117
8278
|
/**
|
|
8118
|
-
* The
|
|
8119
|
-
* was NOT REQUIRED if its own recorded stamp says so.
|
|
8120
|
-
*
|
|
8121
|
-
* `assertBatteryCompleteness` proves the two lists partition the selected
|
|
8122
|
-
* commands — that no command is silently absent. It does not prove the
|
|
8123
|
-
* withholding was EARNED. Without this rule a proof could name every expensive
|
|
8124
|
-
* command in `notRequiredCommands`, carry no stamp at all (or a conservative
|
|
8125
|
-
* one), and still read as full verification: the omission would be recorded,
|
|
8126
|
-
* accounted for, and completely unauthorized.
|
|
8127
|
-
*
|
|
8128
|
-
* Two things have to hold, and the first is what keeps the second honest:
|
|
8129
|
-
*
|
|
8130
|
-
* 1. COHERENCE. The entry's `impactTarget` must equal the `impactTarget` the
|
|
8131
|
-
* proof records for that same command in `verificationCommands`. The entry
|
|
8132
|
-
* does not get to nominate its own target; the proof's command→target
|
|
8133
|
-
* mapping (written by `pr:verify` from the loaded profile) does. Without
|
|
8134
|
-
* this, authorization validates a self-reported field and a crafted proof
|
|
8135
|
-
* can withhold an affected command while pointing at an unrelated released
|
|
8136
|
-
* target.
|
|
8137
|
-
* 2. AUTHORIZATION. That recorded target must be one the proof's own stamp
|
|
8138
|
-
* provably released.
|
|
8279
|
+
* The repository-relative, POSIX-separated path of `profilePath`.
|
|
8139
8280
|
*
|
|
8140
|
-
*
|
|
8141
|
-
*
|
|
8142
|
-
*
|
|
8281
|
+
* A cwd that is not a git checkout, and a profile the repository top level
|
|
8282
|
+
* does not contain, both fall back to the cwd-relative path: this function
|
|
8283
|
+
* makes a path more portable, and it must never invent one. Callers that
|
|
8284
|
+
* require the profile to be inside the trusted checkout keep enforcing that
|
|
8285
|
+
* themselves.
|
|
8286
|
+
*/
|
|
8287
|
+
const repositoryRelativeProfilePath = ({ cwd, profilePath, readRepositoryRoot = repositoryRoot }) => {
|
|
8288
|
+
const cwdRelative = path.relative(cwd, profilePath).split(path.sep).join("/");
|
|
8289
|
+
const root = repositoryRootOrCwd({
|
|
8290
|
+
cwd,
|
|
8291
|
+
readRepositoryRoot
|
|
8292
|
+
});
|
|
8293
|
+
const rootRelative = path.relative(realPath(root), realPath(profilePath)).split(path.sep).join("/");
|
|
8294
|
+
return rootRelative.length > 0 && !rootRelative.startsWith("../") ? rootRelative : cwdRelative;
|
|
8295
|
+
};
|
|
8296
|
+
/** A path that names no file inside the root it was measured from. */
|
|
8297
|
+
const escapesRoot = (relativePath) => relativePath.length === 0 || relativePath === ".." || relativePath.startsWith("../") || path.isAbsolute(relativePath);
|
|
8298
|
+
/**
|
|
8299
|
+
* The same path, for a caller that must REFUSE a profile the repository does
|
|
8300
|
+
* not contain.
|
|
8143
8301
|
*
|
|
8144
|
-
*
|
|
8145
|
-
*
|
|
8146
|
-
*
|
|
8147
|
-
*
|
|
8148
|
-
*
|
|
8149
|
-
* have released that target's command in the first place (target-scoped
|
|
8150
|
-
* basis, this build's stamp version, exactly one row for the target,
|
|
8151
|
-
* `not-affected`). A conservative stamp, an unknown version, a
|
|
8152
|
-
* self-contradictory stamp, an unclassified name, or an `affected` verdict all
|
|
8153
|
-
* fail the same way scoping itself would.
|
|
8302
|
+
* Containment is measured from the repository top level, not from `cwd`.
|
|
8303
|
+
* Measuring it from `cwd` refuses the repository-root profile whenever the
|
|
8304
|
+
* command runs in a package subdirectory — a profile that is plainly inside
|
|
8305
|
+
* the trusted checkout — and it refused before the conversion above could run
|
|
8306
|
+
* (cycle-1 review finding on PR #1004).
|
|
8154
8307
|
*
|
|
8155
|
-
*
|
|
8156
|
-
*
|
|
8157
|
-
*
|
|
8308
|
+
* Fail-closed is unchanged. A profile genuinely outside the repository root
|
|
8309
|
+
* falls back to the cwd-relative path, which still escapes, and throws here.
|
|
8310
|
+
* A cwd that is not a git checkout has no root to measure from and keeps the
|
|
8311
|
+
* previous cwd-relative rule exactly.
|
|
8158
8312
|
*/
|
|
8159
|
-
const
|
|
8160
|
-
const
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
path: ["impactStamp"]
|
|
8168
|
-
});
|
|
8169
|
-
return;
|
|
8170
|
-
}
|
|
8171
|
-
const recordedTargets = new Map(proof.verificationCommands.map((command) => [command.name, command.impactTarget]));
|
|
8172
|
-
for (const [index, entry] of notRequired.entries()) {
|
|
8173
|
-
const recorded = recordedTargets.get(entry.name);
|
|
8174
|
-
if (recorded !== entry.impactTarget) {
|
|
8175
|
-
context.addIssue({
|
|
8176
|
-
code: "custom",
|
|
8177
|
-
message: recorded === void 0 ? `notRequiredCommands entry "${entry.name}" claims impactTarget "${entry.impactTarget}", but this proof records no impactTarget for that command; only a command the profile scopes can be withheld.` : `notRequiredCommands entry "${entry.name}" claims impactTarget "${entry.impactTarget}", but this proof records impactTarget "${recorded}" for that command.`,
|
|
8178
|
-
path: [
|
|
8179
|
-
"notRequiredCommands",
|
|
8180
|
-
index,
|
|
8181
|
-
"impactTarget"
|
|
8182
|
-
]
|
|
8183
|
-
});
|
|
8184
|
-
continue;
|
|
8185
|
-
}
|
|
8186
|
-
const decision = impactStampScopeDecision({
|
|
8187
|
-
stamp: impactStamp,
|
|
8188
|
-
surface: "verification-battery",
|
|
8189
|
-
targetName: entry.impactTarget
|
|
8190
|
-
});
|
|
8191
|
-
if (!decision.scoped) context.addIssue({
|
|
8192
|
-
code: "custom",
|
|
8193
|
-
message: `notRequiredCommands entry "${entry.name}" is not authorized by this proof's impactStamp: ${decision.reason}.`,
|
|
8194
|
-
path: [
|
|
8195
|
-
"notRequiredCommands",
|
|
8196
|
-
index,
|
|
8197
|
-
"impactTarget"
|
|
8198
|
-
]
|
|
8199
|
-
});
|
|
8200
|
-
}
|
|
8201
|
-
};
|
|
8202
|
-
/** Console lines naming every withheld command and why. Never silent. */
|
|
8203
|
-
const batteryScopeSummaryLines = (plan) => {
|
|
8204
|
-
if (plan.notRequired.length === 0) return [];
|
|
8205
|
-
return ["Verification battery scoped by the impact stamp (not-required):", ...plan.notRequired.map((entry) => `- ${entry.name} (${entry.impactTarget}): ${entry.basis}`)];
|
|
8313
|
+
const repositoryContainedProfilePath = ({ cwd, profilePath, readRepositoryRoot = repositoryRoot }) => {
|
|
8314
|
+
const relativePath = repositoryRelativeProfilePath({
|
|
8315
|
+
cwd,
|
|
8316
|
+
profilePath,
|
|
8317
|
+
readRepositoryRoot
|
|
8318
|
+
});
|
|
8319
|
+
if (escapesRoot(relativePath)) throw new Error("The readiness profile must be inside the trusted checkout.");
|
|
8320
|
+
return relativePath;
|
|
8206
8321
|
};
|
|
8207
8322
|
//#endregion
|
|
8208
|
-
//#region src/pr-
|
|
8209
|
-
const
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8323
|
+
//#region src/pr-classification.ts
|
|
8324
|
+
const MISSING_STAMP_REASON = "impact stamp is missing; fail closed to non-trivial";
|
|
8325
|
+
function ownershipFromStamp(stamp, file) {
|
|
8326
|
+
if (stamp.inertPaths.includes(file)) return { kind: "inert" };
|
|
8327
|
+
if (stamp.unsubscribedPaths.includes(file)) return { kind: "unowned" };
|
|
8328
|
+
const named = stamp.targets.find((target) => target.impact === "affected" && target.subscribedPaths.includes(file));
|
|
8329
|
+
if (named) return {
|
|
8330
|
+
kind: "target",
|
|
8331
|
+
target: named.name
|
|
8332
|
+
};
|
|
8333
|
+
const affected = stamp.targets.find((target) => target.impact === "affected");
|
|
8334
|
+
if (affected) return {
|
|
8335
|
+
kind: "target",
|
|
8336
|
+
target: affected.name
|
|
8337
|
+
};
|
|
8338
|
+
return { kind: "unowned" };
|
|
8339
|
+
}
|
|
8340
|
+
function reasonForOwnership(file, ownership) {
|
|
8341
|
+
switch (ownership.kind) {
|
|
8342
|
+
case "inert": return `${file}: repository-inert ownership (stamp.inertPaths)`;
|
|
8343
|
+
case "target": return `${file}: affects product target "${ownership.target}"`;
|
|
8344
|
+
default: return `${file}: no declared owner; fail closed to non-trivial`;
|
|
8345
|
+
}
|
|
8346
|
+
}
|
|
8347
|
+
function relativeProfilePath(options) {
|
|
8348
|
+
return repositoryRelativeProfilePath({
|
|
8349
|
+
cwd: options.cwd,
|
|
8350
|
+
profilePath: options.profilePath
|
|
8214
8351
|
});
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
|
|
8352
|
+
}
|
|
8353
|
+
function applyProfileEditGuard(files, relativeProfile, profilePath, base) {
|
|
8354
|
+
if (!files.some((file) => file === relativeProfile || file === profilePath)) return base;
|
|
8355
|
+
return {
|
|
8356
|
+
classification: "non-trivial",
|
|
8357
|
+
reasons: [...base.reasons, `${relativeProfile}: edits the ownership declarations; forced non-trivial`]
|
|
8358
|
+
};
|
|
8359
|
+
}
|
|
8218
8360
|
/**
|
|
8219
|
-
*
|
|
8220
|
-
*
|
|
8221
|
-
*
|
|
8222
|
-
* stored v1–v3 proof can outlive the release that drops it. Pre-1.0 posture
|
|
8223
|
-
* applies: one current contract, no compat reader. A consumer on an older
|
|
8224
|
-
* factory must adopt this release before HQ ingests its pr:verify proofs.
|
|
8361
|
+
* Path ownership as the stamp recorded it. A missing stamp fails closed.
|
|
8362
|
+
* Does not glob, and does not refuse the docs rung on a conservative basis —
|
|
8363
|
+
* that gate belongs to {@link classifyDiffForRunWithStamp}.
|
|
8225
8364
|
*/
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
}
|
|
8236
|
-
const
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
}).
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
base
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
8271
|
-
|
|
8272
|
-
|
|
8273
|
-
|
|
8274
|
-
|
|
8275
|
-
|
|
8276
|
-
|
|
8277
|
-
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
"trivial",
|
|
8292
|
-
"full"
|
|
8293
|
-
]),
|
|
8294
|
-
notDemandedRecords: z.array(notDemandedRecordSchema).min(1).optional(),
|
|
8295
|
-
notRequiredCommands: z.array(notRequiredCommandSchema).min(1).optional(),
|
|
8296
|
-
outcome: z.enum(["aborted", "passed"]).optional(),
|
|
8297
|
-
patchId: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
8298
|
-
policyResolution: policyResolutionSchema.optional(),
|
|
8299
|
-
profilePath: z.string().min(1),
|
|
8300
|
-
projectKey: z.string().min(1),
|
|
8301
|
-
repository: z.string().min(1),
|
|
8302
|
-
schemaVersion: prVerifySchemaVersionSchema,
|
|
8303
|
-
startedAt: z.iso.datetime(),
|
|
8304
|
-
verificationCommands: z.array(z.object({
|
|
8305
|
-
command: z.string().min(1),
|
|
8306
|
-
description: z.string().min(1),
|
|
8307
|
-
impactTarget: z.string().min(1).optional(),
|
|
8308
|
-
name: z.string().min(1),
|
|
8309
|
-
scope: z.enum([
|
|
8310
|
-
"docs-only",
|
|
8311
|
-
"trivial",
|
|
8312
|
-
"full"
|
|
8313
|
-
])
|
|
8314
|
-
}))
|
|
8315
|
-
}).superRefine(assertPrVerifyProofRules);
|
|
8316
|
-
function validatePrVerifyProof(value) {
|
|
8317
|
-
return prVerifyProofSchema.parse(value);
|
|
8365
|
+
function classifyDiff(files, stamp) {
|
|
8366
|
+
if (stamp === void 0) return {
|
|
8367
|
+
classification: "non-trivial",
|
|
8368
|
+
reasons: [MISSING_STAMP_REASON]
|
|
8369
|
+
};
|
|
8370
|
+
const sortedFiles = [...files].toSorted();
|
|
8371
|
+
if (sortedFiles.length === 0) return {
|
|
8372
|
+
classification: "non-trivial",
|
|
8373
|
+
reasons: ["No changed files detected; defaulting to non-trivial verification."]
|
|
8374
|
+
};
|
|
8375
|
+
const ownerships = sortedFiles.map((file) => ({
|
|
8376
|
+
file,
|
|
8377
|
+
ownership: ownershipFromStamp(stamp, file)
|
|
8378
|
+
}));
|
|
8379
|
+
const reasons = ownerships.map(({ file, ownership }) => reasonForOwnership(file, ownership));
|
|
8380
|
+
if (ownerships.every(({ ownership }) => ownership.kind === "inert")) return {
|
|
8381
|
+
classification: "docs/process-only",
|
|
8382
|
+
reasons
|
|
8383
|
+
};
|
|
8384
|
+
return {
|
|
8385
|
+
classification: "non-trivial",
|
|
8386
|
+
reasons
|
|
8387
|
+
};
|
|
8388
|
+
}
|
|
8389
|
+
/**
|
|
8390
|
+
* The candidate classification `pr:verify` records: path ownership gated by
|
|
8391
|
+
* the impact stamp's positive evidence. The docs/process-only rung requires a
|
|
8392
|
+
* `target-scoped` stamp with zero affected targets ON TOP of full inert
|
|
8393
|
+
* ownership — a conservative stamp (unreadable or unsupported lockfile sides,
|
|
8394
|
+
* no declared targets, computation doubt) refuses the reduced rung, so a
|
|
8395
|
+
* fail-closed stamp can never coexist with a reduced verification battery
|
|
8396
|
+
* (#742 review cycle 3).
|
|
8397
|
+
*
|
|
8398
|
+
* Ownership and reasons come from `stamp.targets`, `stamp.inertPaths`, and
|
|
8399
|
+
* `stamp.unsubscribedPaths`. A missing stamp fails closed; this function
|
|
8400
|
+
* never evaluates profile globs.
|
|
8401
|
+
*/
|
|
8402
|
+
function classifyDiffForRunWithStamp(profile, files, stamp, options) {
|
|
8403
|
+
const base = applyProfileEditGuard(files, relativeProfilePath(options), options.profilePath, classifyDiff(files, stamp));
|
|
8404
|
+
if (stamp === void 0 || base.classification === "non-trivial") return base;
|
|
8405
|
+
if (stamp.basis !== "target-scoped") return {
|
|
8406
|
+
classification: "non-trivial",
|
|
8407
|
+
reasons: [...base.reasons, "impact stamp is conservative; the docs/process-only rung needs a target-scoped stamp with zero affected targets"]
|
|
8408
|
+
};
|
|
8409
|
+
const affected = stamp.targets.filter((target) => target.impact === "affected");
|
|
8410
|
+
if (affected.length > 0) return {
|
|
8411
|
+
classification: "non-trivial",
|
|
8412
|
+
reasons: [...base.reasons, `impact stamp records ${affected.length} affected target(s); forced non-trivial`]
|
|
8413
|
+
};
|
|
8414
|
+
return base;
|
|
8415
|
+
}
|
|
8416
|
+
/**
|
|
8417
|
+
* Path predicate for consumers that do not already hold a stamp (pr-ready's
|
|
8418
|
+
* docs-only delta). Computes one stamp through {@link computeImpactStamp} —
|
|
8419
|
+
* the only glob-evaluation site — then reads ownership from it.
|
|
8420
|
+
*/
|
|
8421
|
+
function classifyDiffForRun(profile, files, options) {
|
|
8422
|
+
const relativeProfile = relativeProfilePath(options);
|
|
8423
|
+
const stamp = computeImpactStamp({
|
|
8424
|
+
changedFiles: files,
|
|
8425
|
+
profile,
|
|
8426
|
+
profilePath: relativeProfile,
|
|
8427
|
+
readLockfile: () => void 0
|
|
8428
|
+
});
|
|
8429
|
+
return applyProfileEditGuard(files, relativeProfile, options.profilePath, classifyDiff(files, stamp));
|
|
8318
8430
|
}
|
|
8319
8431
|
//#endregion
|
|
8320
8432
|
//#region src/pr-readiness/verification-proof.ts
|
|
@@ -9089,7 +9201,6 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
9089
9201
|
}) : void 0;
|
|
9090
9202
|
const selectedProfileCommands = profile.verification.commands.filter((command) => commandAppliesToMode(command, resolvedMode));
|
|
9091
9203
|
const commands = commandsForMode(profile, resolvedMode);
|
|
9092
|
-
if (args.requireKnownAuthoringSession && authoringSession === "unknown") throw new Error("A known authoring session is required before verification can run.");
|
|
9093
9204
|
if (commands.length === 0) throw new Error(`No verification commands configured for ${resolvedMode}.`);
|
|
9094
9205
|
console.log(`pr:verify mode: ${resolvedMode}${args.mode === "auto" ? " (auto)" : ""}`);
|
|
9095
9206
|
console.log(`classification: ${classification.classification}`);
|
|
@@ -9114,6 +9225,34 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
9114
9225
|
const scopedOutNames = new Set(notRequiredCommands.map(({ name }) => name));
|
|
9115
9226
|
for (const line of batteryScopeSummaryLines(battery)) console.log(line);
|
|
9116
9227
|
const repository = `${profile.repository.owner}/${profile.repository.name}`;
|
|
9228
|
+
if (args.reuseAccepted) {
|
|
9229
|
+
assertCleanWorktreeForProof({
|
|
9230
|
+
cwd,
|
|
9231
|
+
dirtyMessage: "Accepted verification requires a clean committed checkout.",
|
|
9232
|
+
statusPorcelain: git.statusPorcelain
|
|
9233
|
+
});
|
|
9234
|
+
const binding = (dependencies.fetchVerifyBinding ?? fetchPrVerifyProofBinding)({
|
|
9235
|
+
expectedAppId: FACTORY_PROOF_GATE_APP_ID,
|
|
9236
|
+
owner: profile.repository.owner,
|
|
9237
|
+
repo: profile.repository.name,
|
|
9238
|
+
sha: headSha
|
|
9239
|
+
});
|
|
9240
|
+
const accepted = binding?.payload.verificationProof;
|
|
9241
|
+
if (binding?.conclusion === "success" && binding.payload.outcome === "passed" && accepted && accepted.outcome === "passed" && accepted.authoringSession !== "unknown" && commands.every((command) => scopedOutNames.has(command.name) || accepted.executedCommands?.some((executed) => executed.name === command.name && executed.command === command.command && executed.scope === command.scope && executed.exitCode === 0)) && accepted.executedCommands?.every((executed) => executed.exitCode === 0) && accepted.headSha === headSha && accepted.patchId === patchId && accepted.repository === repository && accepted.base === base.ref && accepted.baseSha === base.sha && accepted.mergeBaseSha === proofMergeBaseSha && accepted.mode === resolvedMode && accepted.classification === classification.classification && vetoedTargets.length === 0 && isDeepStrictEqual(accepted.changedFiles.toSorted(), files.toSorted()) && isDeepStrictEqual(accepted.verificationCommands, commands) && isDeepStrictEqual(accepted.impactStamp, impactStamp) && isDeepStrictEqual(accepted.notRequiredCommands ?? [], notRequiredCommands) && isDeepStrictEqual(accepted.policyResolution, policyResolution)) {
|
|
9242
|
+
assertCleanWorktreeForProof({
|
|
9243
|
+
cwd,
|
|
9244
|
+
dirtyMessage: "Checkout changed while reading accepted verification.",
|
|
9245
|
+
statusPorcelain: git.statusPorcelain
|
|
9246
|
+
});
|
|
9247
|
+
if (git.currentHeadSha(cwd) !== headSha) throw new Error("Candidate HEAD changed while reading accepted verification.");
|
|
9248
|
+
const paths = resolveProofOutputPaths(cwd, DEFAULT_PR_VERIFY_PROOF_PATH, args.output);
|
|
9249
|
+
writeProofToPaths((target) => writeProofJson(accepted, target), paths.canonical, paths.additional);
|
|
9250
|
+
console.log(`pr:verify reused accepted exact-candidate proof; original provenance preserved at ${paths.canonical}`);
|
|
9251
|
+
return accepted;
|
|
9252
|
+
}
|
|
9253
|
+
console.log("pr:verify accepted coverage unavailable or mismatched; running verification.");
|
|
9254
|
+
}
|
|
9255
|
+
if (args.requireKnownAuthoringSession && authoringSession === "unknown") throw new Error("A known authoring session is required before verification can run.");
|
|
9117
9256
|
let gateState = "failure";
|
|
9118
9257
|
const { env: verificationEnv, cleanup } = context.buildEnv();
|
|
9119
9258
|
const { canonical: output, additional: additionalOutput } = resolveProofOutputPaths(cwd, DEFAULT_PR_VERIFY_PROOF_PATH, args.output);
|
|
@@ -15699,7 +15838,7 @@ const renderPrBodySections = ({ reviewProof, verifyProof }) => `${renderPrBodySe
|
|
|
15699
15838
|
//#endregion
|
|
15700
15839
|
//#region src/pr-readiness/status-check-rollup.ts
|
|
15701
15840
|
var status_check_rollup_exports = /* @__PURE__ */ __exportAll({
|
|
15702
|
-
HOSTED_VERIFY_CHECK_NAME: () => HOSTED_VERIFY_CHECK_NAME
|
|
15841
|
+
HOSTED_VERIFY_CHECK_NAME: () => HOSTED_VERIFY_CHECK_NAME,
|
|
15703
15842
|
HOSTED_VERIFY_DRAFT_CHECK_NAME: () => HOSTED_VERIFY_DRAFT_CHECK_NAME,
|
|
15704
15843
|
hostedVerifyCheckState: () => hostedVerifyCheckState,
|
|
15705
15844
|
isDraftHostedVerifyCheck: () => isDraftHostedVerifyCheck,
|
|
@@ -15735,7 +15874,7 @@ const isFactoryReadyCheck = (check) => check.context?.startsWith("patronage-fact
|
|
|
15735
15874
|
* name across the fleet because one generator emits the workflow that posts
|
|
15736
15875
|
* it (ADR 0016).
|
|
15737
15876
|
*/
|
|
15738
|
-
const HOSTED_VERIFY_CHECK_NAME
|
|
15877
|
+
const HOSTED_VERIFY_CHECK_NAME = "verify";
|
|
15739
15878
|
/**
|
|
15740
15879
|
* Is this rollup entry the hosted `verify` gate, from the producer the
|
|
15741
15880
|
* ruleset pins it to?
|
|
@@ -15756,7 +15895,7 @@ const isHostedVerifyCheck = (check) => check.name === "verify" && Boolean(check.
|
|
|
15756
15895
|
* the literal; this is the reader's copy of it, and
|
|
15757
15896
|
* `software-factory-hq/alchemy/verify-workflow.test.ts` pins the two equal.
|
|
15758
15897
|
*/
|
|
15759
|
-
const HOSTED_VERIFY_DRAFT_CHECK_NAME = `${HOSTED_VERIFY_CHECK_NAME
|
|
15898
|
+
const HOSTED_VERIFY_DRAFT_CHECK_NAME = `${HOSTED_VERIFY_CHECK_NAME} (draft)`;
|
|
15760
15899
|
/**
|
|
15761
15900
|
* Is this the draft-head summary run's residue?
|
|
15762
15901
|
*
|
|
@@ -15982,12 +16121,11 @@ function armAutoMerge(input, dependencies = {}) {
|
|
|
15982
16121
|
//#region src/merge-freeze.ts
|
|
15983
16122
|
const MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
|
|
15984
16123
|
const MERGE_FREEZE_APP_SLUG = "patronage-factory";
|
|
15985
|
-
const HOSTED_VERIFY_CHECK_NAME = "verify";
|
|
15986
|
-
const GITHUB_ACTIONS_APP_ID$1 = 15368;
|
|
15987
|
-
const GITHUB_ACTIONS_APP_SLUG = "github-actions";
|
|
15988
16124
|
const MERGE_FREEZE_SCHEMA_VERSION = 1;
|
|
15989
16125
|
const CHECK_RUNS_PER_PAGE$1 = 100;
|
|
15990
16126
|
const CHECK_RUN_PAGE_LIMIT$1 = 10;
|
|
16127
|
+
const WORKFLOW_RUNS_PER_PAGE = 100;
|
|
16128
|
+
const WORKFLOW_RUN_PAGE_LIMIT = 10;
|
|
15991
16129
|
const shaSchema$2 = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
15992
16130
|
const verificationSchema = z.object({
|
|
15993
16131
|
jobs: z.record(z.string(), z.enum([
|
|
@@ -16058,19 +16196,23 @@ const completedMergeFreezeCheckRunSchema = mergeFreezeCheckRunListItemSchema.ext
|
|
|
16058
16196
|
output: z.object({ text: z.string().min(1) }),
|
|
16059
16197
|
status: z.literal("completed")
|
|
16060
16198
|
});
|
|
16061
|
-
const
|
|
16062
|
-
app: z.object({
|
|
16063
|
-
id: z.literal(GITHUB_ACTIONS_APP_ID$1),
|
|
16064
|
-
slug: z.literal(GITHUB_ACTIONS_APP_SLUG)
|
|
16065
|
-
}),
|
|
16066
|
-
head_sha: shaSchema$2,
|
|
16199
|
+
const workflowRunIdentitySchema = z.object({
|
|
16067
16200
|
id: z.number().int().positive(),
|
|
16068
|
-
|
|
16069
|
-
|
|
16201
|
+
workflow_id: z.number().int().positive()
|
|
16202
|
+
});
|
|
16203
|
+
const workflowRunListItemSchema = workflowRunIdentitySchema.extend({
|
|
16204
|
+
conclusion: z.string().nullable().optional(),
|
|
16205
|
+
created_at: z.iso.datetime(),
|
|
16206
|
+
event: z.string(),
|
|
16207
|
+
head_sha: shaSchema$2,
|
|
16208
|
+
run_attempt: z.number().int().positive(),
|
|
16070
16209
|
status: z.enum([
|
|
16071
16210
|
"completed",
|
|
16072
16211
|
"in_progress",
|
|
16073
|
-
"
|
|
16212
|
+
"pending",
|
|
16213
|
+
"queued",
|
|
16214
|
+
"requested",
|
|
16215
|
+
"waiting"
|
|
16074
16216
|
])
|
|
16075
16217
|
});
|
|
16076
16218
|
const commitParentsSchema = z.object({
|
|
@@ -16133,40 +16275,48 @@ function selectMissingMergeFreezeGenerationUnchecked(api, input) {
|
|
|
16133
16275
|
name: MERGE_FREEZE_CHECK_NAME
|
|
16134
16276
|
}), parentSha);
|
|
16135
16277
|
if (parentGeneration.kind !== "settled") return unconfiguredMergeFreeze(input, `the immediate parent ${parentSha} has no settled App-owned freeze generation (${parentGeneration.kind}).`);
|
|
16136
|
-
|
|
16278
|
+
if (!(api.workflowRun && api.workflowRuns)) return unconfiguredMergeFreeze(input, "the authority cannot read GitHub Actions workflow runs.");
|
|
16279
|
+
const { verification } = parentGeneration.state;
|
|
16280
|
+
if (!verification) return unconfiguredMergeFreeze(input, `the immediate parent ${parentSha}'s settled generation records no producing workflow run; regenerate the merge-target push Verify workflow so the writer records its run.`);
|
|
16281
|
+
const parentRun = workflowRunIdentitySchema.parse(api.workflowRun({
|
|
16137
16282
|
...input,
|
|
16138
|
-
|
|
16139
|
-
}))
|
|
16140
|
-
|
|
16141
|
-
|
|
16142
|
-
const
|
|
16143
|
-
|
|
16144
|
-
|
|
16145
|
-
|
|
16146
|
-
|
|
16147
|
-
if (pendingVerifyRuns.length > 1 && orderedVerifyRuns.length !== pendingVerifyRuns.length) return unconfiguredMergeFreeze(input, `multiple pending source-pinned ${HOSTED_VERIFY_CHECK_NAME} runs cannot be ordered because at least one has no start time.`);
|
|
16148
|
-
const selectedVerify = pendingVerifyRuns.length === 1 ? {
|
|
16149
|
-
generation: { run: pendingVerifyRuns[0] },
|
|
16150
|
-
kind: "selected"
|
|
16151
|
-
} : selectNewestGeneration(orderedVerifyRuns);
|
|
16152
|
-
if (selectedVerify.kind === "ambiguous") return unconfiguredMergeFreeze(input, `the newest source-pinned ${HOSTED_VERIFY_CHECK_NAME} generation is ambiguous.`);
|
|
16153
|
-
if (selectedVerify.kind === "none") return unconfiguredMergeFreeze(input, `the current tip has no source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run.`);
|
|
16154
|
-
const verify = selectedVerify.generation.run;
|
|
16155
|
-
if (verify.head_sha !== input.headSha) return unconfiguredMergeFreeze(input, `the newest source-pinned ${HOSTED_VERIFY_CHECK_NAME} run targets ${verify.head_sha}.`);
|
|
16156
|
-
const refreshed = selectListedMergeFreezeGeneration(api.list({
|
|
16283
|
+
runId: verification.runId
|
|
16284
|
+
}));
|
|
16285
|
+
if (parentRun.id !== verification.runId) return unconfiguredMergeFreeze(input, `the parent generation's producing run ${verification.runId} could not be read (GitHub returned run ${parentRun.id}).`);
|
|
16286
|
+
const workflowId = parentRun.workflow_id;
|
|
16287
|
+
const tipRuns = z.object({ workflow_runs: z.array(z.unknown()) }).parse(api.workflowRuns({
|
|
16288
|
+
...input,
|
|
16289
|
+
workflowId
|
|
16290
|
+
})).workflow_runs.map((run) => workflowRunListItemSchema.parse(run)).filter((run) => run.workflow_id === workflowId && run.head_sha === input.headSha && run.event === "push");
|
|
16291
|
+
const rereadTipGeneration = () => selectListedMergeFreezeGeneration(api.list({
|
|
16157
16292
|
...input,
|
|
16158
16293
|
name: MERGE_FREEZE_CHECK_NAME
|
|
16159
16294
|
}), input.headSha);
|
|
16295
|
+
const completedRun = tipRuns.find((run) => run.status === "completed");
|
|
16296
|
+
if (completedRun) {
|
|
16297
|
+
const refreshedAfterCompletion = rereadTipGeneration();
|
|
16298
|
+
if (refreshedAfterCompletion.kind !== "missing") return refreshedAfterCompletion;
|
|
16299
|
+
return unconfiguredMergeFreeze(input, `the current tip's push run ${completedRun.id} of the freeze-writing Verify workflow already completed without producing a freeze generation.`);
|
|
16300
|
+
}
|
|
16301
|
+
const selectedRun = selectNewestGeneration(tipRuns.filter((run) => run.status !== "completed").map((run) => ({
|
|
16302
|
+
orderMs: generationOrderMs(run.created_at),
|
|
16303
|
+
run
|
|
16304
|
+
})));
|
|
16305
|
+
if (selectedRun.kind === "ambiguous") return unconfiguredMergeFreeze(input, "the newest pending push run of the freeze-writing Verify workflow is ambiguous.");
|
|
16306
|
+
if (selectedRun.kind === "none") return unconfiguredMergeFreeze(input, `the current tip has no queued or running push run of the freeze-writing Verify workflow (workflow ${workflowId}).`);
|
|
16307
|
+
const { run } = selectedRun.generation;
|
|
16308
|
+
const refreshed = rereadTipGeneration();
|
|
16160
16309
|
if (refreshed.kind !== "missing") return refreshed;
|
|
16161
16310
|
return {
|
|
16162
16311
|
kind: "settling",
|
|
16163
16312
|
phase: "awaiting-generation",
|
|
16164
|
-
reason: `No ${MERGE_FREEZE_CHECK_NAME} generation exists yet for ${input.headSha}; the immediate parent has a settled generation
|
|
16313
|
+
reason: `No ${MERGE_FREEZE_CHECK_NAME} generation exists yet for ${input.headSha}; the immediate parent ${parentSha} has a settled generation written by workflow ${workflowId}, and that workflow's push run ${run.id} on the tip is ${run.status}.`,
|
|
16165
16314
|
witness: {
|
|
16166
16315
|
headSha: input.headSha,
|
|
16167
16316
|
parentSha,
|
|
16168
|
-
|
|
16169
|
-
|
|
16317
|
+
workflowId,
|
|
16318
|
+
workflowRunId: run.id,
|
|
16319
|
+
workflowRunStatus: run.status
|
|
16170
16320
|
}
|
|
16171
16321
|
};
|
|
16172
16322
|
}
|
|
@@ -16174,7 +16324,7 @@ function selectMissingMergeFreezeGeneration(api, input) {
|
|
|
16174
16324
|
try {
|
|
16175
16325
|
return selectMissingMergeFreezeGenerationUnchecked(api, input);
|
|
16176
16326
|
} catch (error) {
|
|
16177
|
-
return unconfiguredMergeFreeze(input, `the required GitHub history or
|
|
16327
|
+
return unconfiguredMergeFreeze(input, `the required GitHub history, check-run, or workflow-run evidence is unreadable: ${error instanceof Error ? error.message : String(error)}.`);
|
|
16178
16328
|
}
|
|
16179
16329
|
}
|
|
16180
16330
|
function selectMergeFreezeGeneration(api, input) {
|
|
@@ -16219,6 +16369,19 @@ function createGitHubCheckRunMergeFreezeApi(dependencies = {}) {
|
|
|
16219
16369
|
},
|
|
16220
16370
|
parent({ cwd, headSha, repository }) {
|
|
16221
16371
|
return (dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/commits/${headSha}`], cwd);
|
|
16372
|
+
},
|
|
16373
|
+
workflowRun({ cwd, repository, runId }) {
|
|
16374
|
+
return (dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/actions/runs/${runId}`], cwd);
|
|
16375
|
+
},
|
|
16376
|
+
workflowRuns({ cwd, headSha, repository, workflowId }) {
|
|
16377
|
+
const workflowRuns = [];
|
|
16378
|
+
for (let page = 1; page <= WORKFLOW_RUN_PAGE_LIMIT; page += 1) {
|
|
16379
|
+
const response = z.object({ workflow_runs: z.array(z.unknown()) }).parse((dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/actions/workflows/${workflowId}/runs?head_sha=${headSha}&event=push&per_page=${WORKFLOW_RUNS_PER_PAGE}&page=${page}`], cwd));
|
|
16380
|
+
workflowRuns.push(...response.workflow_runs);
|
|
16381
|
+
if (response.workflow_runs.length < WORKFLOW_RUNS_PER_PAGE) break;
|
|
16382
|
+
if (page === WORKFLOW_RUN_PAGE_LIMIT) throw new Error(`workflow ${workflowId} run pagination limit was exhausted before GitHub returned a final page.`);
|
|
16383
|
+
}
|
|
16384
|
+
return { workflow_runs: workflowRuns };
|
|
16222
16385
|
}
|
|
16223
16386
|
};
|
|
16224
16387
|
}
|
|
@@ -16262,12 +16425,12 @@ const SETTLING_MERGE_FREEZE_TAIL = "readiness proceeds (ADR 0016). If this wave
|
|
|
16262
16425
|
*
|
|
16263
16426
|
* The two phases permit readiness for different reasons, so they say different
|
|
16264
16427
|
* things. A running generation is the writer already at work on this tip. An
|
|
16265
|
-
* awaited one is the writer proven to run — a settled parent — plus
|
|
16266
|
-
*
|
|
16428
|
+
* awaited one is the writer proven to run — a settled parent — plus that
|
|
16429
|
+
* writer's workflow's push run on the tip, which owes the generation.
|
|
16267
16430
|
*/
|
|
16268
16431
|
const settlingMergeFreezeNotice = (headSha, generation) => {
|
|
16269
16432
|
if (generation.phase === "running-generation") return `Merge freeze generation ${generation.witness.runId} for base ${headSha} is still settling (${generation.reason}); that running generation writes the verdict for this tip, so ${SETTLING_MERGE_FREEZE_TAIL}`;
|
|
16270
|
-
return `Merge freeze for base ${headSha} is still settling with no generation yet (${generation.reason}); settled parent ${generation.witness.parentSha} and ${
|
|
16433
|
+
return `Merge freeze for base ${headSha} is still settling with no generation yet (${generation.reason}); settled parent ${generation.witness.parentSha} and push run ${generation.witness.workflowRunId} of its freeze-writing workflow ${generation.witness.workflowId}, which is ${generation.witness.workflowRunStatus}, prove one is owed, so ${SETTLING_MERGE_FREEZE_TAIL}`;
|
|
16271
16434
|
};
|
|
16272
16435
|
/**
|
|
16273
16436
|
* The freeze read during readiness, before any authorized arming (#477,
|
|
@@ -16279,8 +16442,9 @@ const settlingMergeFreezeNotice = (headSha, generation) => {
|
|
|
16279
16442
|
* **settling** generation permits readiness and any authorized arming with a
|
|
16280
16443
|
* notice only when the writer is observable, and each phase proves that by its
|
|
16281
16444
|
* own rule (#890): `running-generation` shows the generation itself in flight,
|
|
16282
|
-
* while `awaiting-generation` shows a settled parent tip plus a pending
|
|
16283
|
-
*
|
|
16445
|
+
* while `awaiting-generation` shows a settled parent tip plus a pending push
|
|
16446
|
+
* run, on this tip, of the workflow that wrote the parent's generation (#521,
|
|
16447
|
+
* #1276). Epic #473 decision 5
|
|
16284
16448
|
* deliberately trades the settle-window wait against the measured rarity of a
|
|
16285
16449
|
* red merge target. A writerless or unprovable repository fails closed.
|
|
16286
16450
|
*/
|
|
@@ -16842,7 +17006,7 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
16842
17006
|
const awaitPostUndraftChecks = awaitPostUndraftChecksRepair(input);
|
|
16843
17007
|
if ((input.hostedVerifyCheck ?? "none") === "none") blockers.push({
|
|
16844
17008
|
demand: DEMAND_KEYS.githubChecks,
|
|
16845
|
-
reason: `GitHub has no ${HOSTED_VERIFY_CHECK_NAME
|
|
17009
|
+
reason: `GitHub has no ${HOSTED_VERIFY_CHECK_NAME} check run from its workflow producer on this head; the branch ruleset pins it as a required check and the status rollup does not report the shortfall.`,
|
|
16846
17010
|
...awaitPostUndraftChecks
|
|
16847
17011
|
});
|
|
16848
17012
|
if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({
|
|
@@ -20310,7 +20474,7 @@ async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (me
|
|
|
20310
20474
|
status: "timed-out",
|
|
20311
20475
|
verifyState
|
|
20312
20476
|
};
|
|
20313
|
-
report(`pr:publish is waiting for hosted check runs on ${expectedHeadSha} to settle (${HOSTED_VERIFY_CHECK_NAME
|
|
20477
|
+
report(`pr:publish is waiting for hosted check runs on ${expectedHeadSha} to settle (${HOSTED_VERIFY_CHECK_NAME}: ${verifyState}; still running: ${pending.length > 0 ? pending.join(", ") : "none reported yet"}); attempt ${attempts + 1} of 40.\n`);
|
|
20314
20478
|
await sleep(PUBLISH_HOSTED_CHECKS_WAIT_MS);
|
|
20315
20479
|
}
|
|
20316
20480
|
}
|
|
@@ -20767,7 +20931,7 @@ function createPrReviewCommand(output, action) {
|
|
|
20767
20931
|
//#endregion
|
|
20768
20932
|
//#region src/commands/pr-verify.ts
|
|
20769
20933
|
function createPrVerifyCommand(output, action = runPrVerify) {
|
|
20770
|
-
return markCwdOptionDefault(new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base ref to assert. With a pull request open for this branch its live base is the base and a disagreeing value is refused; without one, this ref (default origin/main) is fetched fresh and its SHA recorded").option("--cwd <path>", "working directory to verify", collectCwdOption).option("--docs-only", "run the docs/process verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
|
|
20934
|
+
return markCwdOptionDefault(new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base ref to assert. With a pull request open for this branch its live base is the base and a disagreeing value is refused; without one, this ref (default origin/main) is fetched fresh and its SHA recorded").option("--cwd <path>", "working directory to verify", collectCwdOption).option("--docs-only", "run the docs/process verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--reuse-accepted", "reuse an exact-candidate App proof with matching current verification coverage; otherwise verify normally").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
|
|
20771
20935
|
gate: "pr:verify",
|
|
20772
20936
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
20773
20937
|
stderr: output.stderr
|
|
@@ -20782,7 +20946,8 @@ function createPrVerifyCommand(output, action = runPrVerify) {
|
|
|
20782
20946
|
mode: modeFor(options),
|
|
20783
20947
|
output: options.output,
|
|
20784
20948
|
profilePath: options.profile,
|
|
20785
|
-
requireKnownAuthoringSession: true
|
|
20949
|
+
requireKnownAuthoringSession: true,
|
|
20950
|
+
reuseAccepted: options.reuseAccepted
|
|
20786
20951
|
}, dependencies);
|
|
20787
20952
|
if (options.json) output.stdout.write(`${JSON.stringify(proof, null, 2)}\n`);
|
|
20788
20953
|
})));
|
|
@@ -20794,6 +20959,7 @@ function modeFor(options) {
|
|
|
20794
20959
|
}
|
|
20795
20960
|
/** The GitHub deployment `task` that names receipts this tool wrote. */
|
|
20796
20961
|
const DEPLOYMENT_RECEIPT_TASK = "patronage-factory/deployment";
|
|
20962
|
+
const PREVIEW_DEPLOYMENT_RECEIPT_TASK = "patronage-factory/preview-deployment";
|
|
20797
20963
|
/** The `performed_via_github_app.id` of GitHub Actions itself. */
|
|
20798
20964
|
const GITHUB_ACTIONS_APP_ID = 15368;
|
|
20799
20965
|
const RECEIPT_PAGE_SIZE = 100;
|
|
@@ -20841,7 +21007,12 @@ const workflowRunIdentityFromEnv = (env) => {
|
|
|
20841
21007
|
} };
|
|
20842
21008
|
};
|
|
20843
21009
|
/** The payload this tool writes into every deployment it creates. */
|
|
21010
|
+
const previewScopeSchema = z.object({
|
|
21011
|
+
headRef: z.string().min(1),
|
|
21012
|
+
pr: z.number().int().positive()
|
|
21013
|
+
});
|
|
20844
21014
|
const receiptPayloadSchema = z.object({
|
|
21015
|
+
preview: previewScopeSchema.optional(),
|
|
20845
21016
|
repository: z.string().regex(REPOSITORY_PATTERN),
|
|
20846
21017
|
schemaVersion: z.literal(1),
|
|
20847
21018
|
sourceSha: z.string().regex(COMMIT_SHA_PATTERN$1),
|
|
@@ -20866,6 +21037,7 @@ const deploymentRecordSchema = z.object({
|
|
|
20866
21037
|
original_environment: z.string(),
|
|
20867
21038
|
payload: z.unknown(),
|
|
20868
21039
|
performed_via_github_app: z.object({ id: z.number().int() }).nullable().optional(),
|
|
21040
|
+
production_environment: z.boolean().optional(),
|
|
20869
21041
|
sha: z.string(),
|
|
20870
21042
|
task: z.string()
|
|
20871
21043
|
});
|
|
@@ -20882,15 +21054,23 @@ const workflowRunJobSchema = z.object({
|
|
|
20882
21054
|
})).optional()
|
|
20883
21055
|
});
|
|
20884
21056
|
const workflowRunRecordSchema = z.object({
|
|
21057
|
+
event: z.string().optional(),
|
|
20885
21058
|
head_branch: z.string().nullable(),
|
|
20886
21059
|
head_sha: z.string(),
|
|
20887
21060
|
id: z.number().int(),
|
|
20888
21061
|
path: z.string(),
|
|
21062
|
+
pull_requests: z.array(z.object({
|
|
21063
|
+
head: z.object({
|
|
21064
|
+
ref: z.string(),
|
|
21065
|
+
sha: z.string()
|
|
21066
|
+
}),
|
|
21067
|
+
number: z.number().int()
|
|
21068
|
+
})).optional(),
|
|
20889
21069
|
repository: z.object({ full_name: z.string() }),
|
|
20890
21070
|
run_attempt: z.number().int().positive()
|
|
20891
21071
|
});
|
|
20892
21072
|
/** The deployment environment one target's receipts live in. */
|
|
20893
|
-
const deploymentReceiptEnvironment = (target) => target;
|
|
21073
|
+
const deploymentReceiptEnvironment = (target, preview) => preview ? `${target}:pr-${preview.pr}` : target;
|
|
20894
21074
|
const runUrl = (identity, runId) => `${identity.serverUrl}/${identity.repository}/actions/runs/${runId}`;
|
|
20895
21075
|
/** The `$GITHUB_OUTPUT` key a success publication writes the new deployment id under. */
|
|
20896
21076
|
const RECEIPT_DEPLOYMENT_ID_OUTPUT = "deployment_id";
|
|
@@ -20909,18 +21089,24 @@ const boundReceiptStepName = (deploymentId) => `Bind deployment receipt ${deploy
|
|
|
20909
21089
|
* GitHub Actions, written by this tool for this repository, target, stage,
|
|
20910
21090
|
* source, workflow, and ref.
|
|
20911
21091
|
*/
|
|
21092
|
+
const previewPayloadRefusal = (args, deployment, receipt) => {
|
|
21093
|
+
if (!args.preview) return receipt.preview === void 0 ? void 0 : "is a preview receipt, not production";
|
|
21094
|
+
if (deployment.production_environment !== false || receipt.preview?.pr !== args.preview.pr || receipt.preview.headRef !== args.preview.headRef || receipt.stage !== `pr-${args.preview.pr}` || receipt.sourceSha !== args.preview.candidateSha || receipt.workflow.ref !== `refs/pull/${args.preview.pr}/merge` || !args.preview.publishers.some((p) => p.workflowPath === receipt.workflow.path)) return "is not the expected hosted preview candidate and publisher";
|
|
21095
|
+
};
|
|
20912
21096
|
const bindReceiptPayload = (args, deployment) => {
|
|
20913
21097
|
const label = `deployment ${deployment.id}`;
|
|
20914
21098
|
if (deployment.performed_via_github_app?.id !== GITHUB_ACTIONS_APP_ID) return { reason: `${label} was not performed by GitHub Actions` };
|
|
20915
|
-
if (deployment.task !== "patronage-factory/deployment") return { reason: `${label} has a foreign task, not a factory receipt` };
|
|
21099
|
+
if (deployment.task !== (args.preview ? PREVIEW_DEPLOYMENT_RECEIPT_TASK : "patronage-factory/deployment")) return { reason: `${label} has a foreign task, not a factory receipt` };
|
|
20916
21100
|
const payload = receiptPayloadSchema.safeParse(deployment.payload);
|
|
20917
21101
|
if (!payload.success) return { reason: `${label} payload is not a schema-version-1 factory receipt` };
|
|
20918
21102
|
const receipt = payload.data;
|
|
21103
|
+
const previewReason = previewPayloadRefusal(args, deployment, receipt);
|
|
21104
|
+
if (previewReason) return { reason: `${label} ${previewReason}` };
|
|
20919
21105
|
if (receipt.repository !== args.identity.repository) return { reason: `${label} names another repository` };
|
|
20920
21106
|
if (receipt.target !== args.target || receipt.stage !== args.stage) return { reason: `${label} names another target or stage` };
|
|
20921
21107
|
if (receipt.sourceSha !== deployment.sha.toLowerCase()) return { reason: `${label} payload source ${receipt.sourceSha} differs from its deployment sha` };
|
|
20922
|
-
if (receipt.workflow.path !== args.identity.workflowPath) return { reason: `${label} was written by another workflow file, not ${args.identity.workflowPath}` };
|
|
20923
|
-
if (receipt.workflow.ref !== args.identity.ref) return { reason: `${label} was written for another ref, not ${args.identity.ref}` };
|
|
21108
|
+
if (!args.preview && receipt.workflow.path !== args.identity.workflowPath) return { reason: `${label} was written by another workflow file, not ${args.identity.workflowPath}` };
|
|
21109
|
+
if (!args.preview && receipt.workflow.ref !== args.identity.ref) return { reason: `${label} was written for another ref, not ${args.identity.ref}` };
|
|
20924
21110
|
return { receipt };
|
|
20925
21111
|
};
|
|
20926
21112
|
/**
|
|
@@ -20929,11 +21115,52 @@ const bindReceiptPayload = (args, deployment) => {
|
|
|
20929
21115
|
* must have bound this record's id. The first failure names the receipt
|
|
20930
21116
|
* untrusted. Statuses are not read: they are caller-written.
|
|
20931
21117
|
*/
|
|
21118
|
+
const previewRunMatches = (preview, run, receipt) => !preview || run.event === "pull_request" && run.pull_requests?.some((pr) => pr.number === preview.pr && pr.head.sha === receipt.sourceSha && pr.head.ref === preview.headRef) === true;
|
|
21119
|
+
const previewStatusPassed = (transport, deploymentId) => {
|
|
21120
|
+
try {
|
|
21121
|
+
return z.array(z.object({
|
|
21122
|
+
id: z.number().int(),
|
|
21123
|
+
state: z.string()
|
|
21124
|
+
})).parse(transport.listDeploymentStatuses(deploymentId)).toSorted((a, b) => b.id - a.id)[0]?.state === "success";
|
|
21125
|
+
} catch {
|
|
21126
|
+
return false;
|
|
21127
|
+
}
|
|
21128
|
+
};
|
|
21129
|
+
const isReceiptWriter = (job, receipt, stepName, preview) => job.conclusion === "success" && job.run_id === Number(receipt.workflow.runId) && job.run_attempt === receipt.workflow.runAttempt && job.head_sha.toLowerCase() === receipt.sourceSha && job.steps?.some((step) => step.name === stepName && step.conclusion === "success") === true && (!preview || preview.publishers.some((p) => p.workflowPath === receipt.workflow.path && p.jobName === job.name && p.requiredSteps.length > 0 && p.requiredSteps.every((name) => job.steps?.some((step) => step.name === name && step.conclusion === "success"))));
|
|
21130
|
+
const runMatchesReceipt = (args, run, receipt) => run.id === Number(receipt.workflow.runId) && run.run_attempt === receipt.workflow.runAttempt && run.path === receipt.workflow.path && run.repository.full_name === args.identity.repository && run.head_sha.toLowerCase() === receipt.sourceSha && run.head_branch === (args.preview ? args.preview.headRef : args.identity.ref.replace(/^refs\/heads\//u, ""));
|
|
21131
|
+
const sameRunAttempt = (current, original) => current.id === original.id && current.run_attempt === original.run_attempt && current.path === original.path && current.repository.full_name === original.repository.full_name && current.head_sha === original.head_sha && current.head_branch === original.head_branch;
|
|
21132
|
+
const previewRunIsNewest = (args, receipt) => {
|
|
21133
|
+
if (!args.preview) return true;
|
|
21134
|
+
try {
|
|
21135
|
+
const [newest] = args.preview.publishers.flatMap((publisher) => z.array(z.object({
|
|
21136
|
+
conclusion: z.string().nullable(),
|
|
21137
|
+
head_sha: z.literal(receipt.sourceSha),
|
|
21138
|
+
id: z.number().int().positive(),
|
|
21139
|
+
run_attempt: z.number().int().positive(),
|
|
21140
|
+
run_started_at: z.string().datetime(),
|
|
21141
|
+
status: z.string()
|
|
21142
|
+
})).parse(args.transport.listPreviewWorkflowRuns?.(publisher.workflowPath, receipt.sourceSha))).toSorted((a, b) => Date.parse(b.run_started_at) - Date.parse(a.run_started_at) || b.id - a.id);
|
|
21143
|
+
return newest?.id === Number(receipt.workflow.runId) && newest.run_attempt === receipt.workflow.runAttempt && newest.status === "completed" && newest.conclusion === "success";
|
|
21144
|
+
} catch {
|
|
21145
|
+
return false;
|
|
21146
|
+
}
|
|
21147
|
+
};
|
|
21148
|
+
const previewWorkflowMatches = (args, receipt) => {
|
|
21149
|
+
if (!args.preview) return true;
|
|
21150
|
+
try {
|
|
21151
|
+
const file = z.object({ sha: z.string().regex(/^[a-f0-9]{40}$/u) }).parse(args.transport.getWorkflowFile?.(receipt.workflow.path, receipt.sourceSha));
|
|
21152
|
+
return args.preview.publishers.some((p) => p.workflowPath === receipt.workflow.path && p.workflowBlobSha === file.sha);
|
|
21153
|
+
} catch {
|
|
21154
|
+
return false;
|
|
21155
|
+
}
|
|
21156
|
+
};
|
|
20932
21157
|
const trustReceipt = (args, deployment) => {
|
|
20933
21158
|
const label = `deployment ${deployment.id}`;
|
|
20934
21159
|
const bound = bindReceiptPayload(args, deployment);
|
|
20935
21160
|
if ("reason" in bound) return bound;
|
|
20936
21161
|
const { receipt } = bound;
|
|
21162
|
+
if (!previewRunIsNewest(args, receipt)) return { reason: `${label} is not the newest successful preview producer run` };
|
|
21163
|
+
if (!previewWorkflowMatches(args, receipt)) return { reason: `${label} producer workflow differs from trusted default-branch authority` };
|
|
20937
21164
|
let runRecord;
|
|
20938
21165
|
let jobRecords;
|
|
20939
21166
|
let latestRunRecord;
|
|
@@ -20946,17 +21173,18 @@ const trustReceipt = (args, deployment) => {
|
|
|
20946
21173
|
}
|
|
20947
21174
|
const run = workflowRunRecordSchema.safeParse(runRecord);
|
|
20948
21175
|
if (!run.success) return { reason: `${label} names run ${receipt.workflow.runId}, which GitHub does not record` };
|
|
20949
|
-
|
|
20950
|
-
if (
|
|
21176
|
+
if (!runMatchesReceipt(args, run.data, receipt)) return { reason: `${label} run identity disagrees with the receipt` };
|
|
21177
|
+
if (!previewRunMatches(args.preview, run.data, receipt)) return { reason: `${label} run does not identify this pull request candidate` };
|
|
20951
21178
|
const latestRun = workflowRunRecordSchema.safeParse(latestRunRecord);
|
|
20952
|
-
if (!latestRun.success || latestRun.data
|
|
21179
|
+
if (!latestRun.success || !sameRunAttempt(latestRun.data, run.data)) return { reason: `${label} producing attempt is no longer the current matching run attempt; reconciliation is required` };
|
|
20953
21180
|
const jobs = z.object({
|
|
20954
21181
|
jobs: z.array(workflowRunJobSchema),
|
|
20955
21182
|
total_count: z.number().int().nonnegative()
|
|
20956
21183
|
}).safeParse(jobRecords);
|
|
20957
21184
|
if (!jobs.success || jobs.data.total_count !== jobs.data.jobs.length) return { reason: `${label} names run ${receipt.workflow.runId}, whose job records are malformed or incomplete` };
|
|
20958
21185
|
const stepName = boundReceiptStepName(deployment.id);
|
|
20959
|
-
if (jobs.data.jobs.find((job) =>
|
|
21186
|
+
if (jobs.data.jobs.find((job) => isReceiptWriter(job, receipt, stepName, args.preview)) === void 0) return { reason: `${label} names run ${receipt.workflow.runId}, but no succeeded job of that run at ${receipt.sourceSha} bound deployment ${deployment.id}` };
|
|
21187
|
+
if (args.preview && !previewStatusPassed(args.transport, deployment.id)) return { reason: `${label} preview status is not a readable success` };
|
|
20960
21188
|
return {
|
|
20961
21189
|
baseline: {
|
|
20962
21190
|
deploymentId: deployment.id,
|
|
@@ -20979,7 +21207,7 @@ const trustReceipt = (args, deployment) => {
|
|
|
20979
21207
|
* record GitHub creates for it is unbound too.
|
|
20980
21208
|
*/
|
|
20981
21209
|
const resolveDeploymentBaseline = (args) => {
|
|
20982
|
-
const environment = deploymentReceiptEnvironment(args.target);
|
|
21210
|
+
const environment = deploymentReceiptEnvironment(args.target, args.preview);
|
|
20983
21211
|
let deployments;
|
|
20984
21212
|
try {
|
|
20985
21213
|
const parsed = z.array(deploymentRecordSchema).safeParse(args.transport.listDeployments());
|
|
@@ -21017,9 +21245,20 @@ const createdDeploymentSchema = z.object({ id: z.number().int() });
|
|
|
21017
21245
|
* earlier successful deployments of the environment inactive, so the newest
|
|
21018
21246
|
* success is always what production runs.
|
|
21019
21247
|
*/
|
|
21248
|
+
const assertPublicationScope = (args) => {
|
|
21249
|
+
if (args.preview && (args.stage !== `pr-${args.preview.pr}` || args.state !== "inactive" && args.identity.ref !== `refs/pull/${args.preview.pr}/merge`)) throw new Error("Preview receipt stage and workflow ref must identify the pull request.");
|
|
21250
|
+
if (!args.preview && (args.deploymentId !== void 0 || args.state === "pending" || args.state === "inactive")) throw new Error("Pending, inactive and same-record completion are preview-only.");
|
|
21251
|
+
if (args.preview && args.state === "success" && args.deploymentId === void 0) throw new Error("Preview success must finish its pre-mutation deployment intent.");
|
|
21252
|
+
};
|
|
21253
|
+
const assertOwnPreviewIntent = (args, payload, environment, deploymentId) => {
|
|
21254
|
+
const own = z.array(deploymentRecordSchema).parse(args.transport.listDeployments()).find((record) => record.id === deploymentId);
|
|
21255
|
+
if (!own || own.original_environment !== environment || own.sha !== args.sourceSha || own.production_environment !== false || own.task !== PREVIEW_DEPLOYMENT_RECEIPT_TASK || own.performed_via_github_app?.id !== GITHUB_ACTIONS_APP_ID || JSON.stringify(receiptPayloadSchema.parse(own.payload)) !== JSON.stringify(receiptPayloadSchema.parse(payload))) throw new Error("Preview completion must name this run's own deployment intent.");
|
|
21256
|
+
};
|
|
21020
21257
|
const publishDeploymentReceipt = (args) => {
|
|
21021
|
-
const environment = deploymentReceiptEnvironment(args.target);
|
|
21258
|
+
const environment = deploymentReceiptEnvironment(args.target, args.preview);
|
|
21259
|
+
assertPublicationScope(args);
|
|
21022
21260
|
const payload = {
|
|
21261
|
+
...args.preview ? { preview: args.preview } : {},
|
|
21023
21262
|
repository: args.identity.repository,
|
|
21024
21263
|
schemaVersion: 1,
|
|
21025
21264
|
sourceSha: args.sourceSha,
|
|
@@ -21036,24 +21275,26 @@ const publishDeploymentReceipt = (args) => {
|
|
|
21036
21275
|
runId: args.identity.runId
|
|
21037
21276
|
}
|
|
21038
21277
|
};
|
|
21039
|
-
|
|
21278
|
+
let { deploymentId } = args;
|
|
21279
|
+
if (deploymentId === void 0) deploymentId = createdDeploymentSchema.parse(args.transport.createDeployment({
|
|
21040
21280
|
auto_merge: false,
|
|
21041
21281
|
description: `${DEPLOYMENT_RECEIPT_TASK} ${args.target} at ${args.sourceSha}`,
|
|
21042
21282
|
environment,
|
|
21043
21283
|
payload,
|
|
21044
|
-
production_environment:
|
|
21284
|
+
production_environment: !args.preview,
|
|
21045
21285
|
ref: args.sourceSha,
|
|
21046
21286
|
required_contexts: [],
|
|
21047
|
-
task: DEPLOYMENT_RECEIPT_TASK
|
|
21048
|
-
}));
|
|
21049
|
-
args
|
|
21287
|
+
task: args.preview ? PREVIEW_DEPLOYMENT_RECEIPT_TASK : DEPLOYMENT_RECEIPT_TASK
|
|
21288
|
+
})).id;
|
|
21289
|
+
else assertOwnPreviewIntent(args, payload, environment, deploymentId);
|
|
21290
|
+
args.transport.createDeploymentStatus(deploymentId, {
|
|
21050
21291
|
auto_inactive: args.state === "success",
|
|
21051
21292
|
description: `${args.state}: ${args.target} at ${args.sourceSha} by run ${args.identity.runId} attempt ${args.identity.runAttempt}`,
|
|
21052
21293
|
log_url: `${runUrl(args.identity, args.identity.runId)}/attempts/${args.identity.runAttempt}`,
|
|
21053
21294
|
state: args.state
|
|
21054
21295
|
});
|
|
21055
21296
|
return {
|
|
21056
|
-
deploymentId
|
|
21297
|
+
deploymentId,
|
|
21057
21298
|
environment,
|
|
21058
21299
|
payload,
|
|
21059
21300
|
state: args.state
|
|
@@ -21120,10 +21361,20 @@ const ghDeploymentReceiptTransport = (cwd, repository, run = defaultGhRunner) =>
|
|
|
21120
21361
|
return {
|
|
21121
21362
|
createDeployment: (body) => post(`${base}/deployments`, body),
|
|
21122
21363
|
createDeploymentStatus: (deploymentId, body) => post(`${base}/deployments/${deploymentId}/statuses`, body),
|
|
21364
|
+
getWorkflowFile: (workflowPath, sourceSha) => api(`${base}/contents/${workflowPath}?ref=${sourceSha}`),
|
|
21123
21365
|
getWorkflowRun: (runId) => api(`${base}/actions/runs/${runId}`),
|
|
21124
21366
|
getWorkflowRunAttempt: (runId, attempt) => api(`${base}/actions/runs/${runId}/attempts/${attempt}`),
|
|
21125
21367
|
listDeploymentStatuses: (deploymentId) => api(`${base}/deployments/${deploymentId}/statuses?per_page=${RECEIPT_PAGE_SIZE}`),
|
|
21126
21368
|
listDeployments: () => api(`${base}/deployments?per_page=${RECEIPT_PAGE_SIZE}`),
|
|
21369
|
+
listPreviewWorkflowRuns: (workflowPath, sourceSha) => {
|
|
21370
|
+
const pages = ghJson(run, [
|
|
21371
|
+
"api",
|
|
21372
|
+
"--paginate",
|
|
21373
|
+
"--slurp",
|
|
21374
|
+
`${base}/actions/workflows/${encodeURIComponent(path.basename(workflowPath))}/runs?head_sha=${sourceSha}&event=pull_request&per_page=${RECEIPT_PAGE_SIZE}`
|
|
21375
|
+
], cwd);
|
|
21376
|
+
return z.array(z.object({ workflow_runs: z.array(z.unknown()) })).parse(pages).flatMap((page) => page.workflow_runs);
|
|
21377
|
+
},
|
|
21127
21378
|
listWorkflowRunJobs: (runId, attempt) => api(`${base}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=${RECEIPT_PAGE_SIZE}`)
|
|
21128
21379
|
};
|
|
21129
21380
|
};
|
|
@@ -22166,6 +22417,138 @@ function createPreviewReapCommand(output, deps = {}) {
|
|
|
22166
22417
|
});
|
|
22167
22418
|
}
|
|
22168
22419
|
//#endregion
|
|
22420
|
+
//#region src/preview-receipt-command.ts
|
|
22421
|
+
const policySchema = z.object({
|
|
22422
|
+
cleanupWorkflowPaths: z.array(z.string().regex(/^\.github\/workflows\/[^/]+\.ya?ml$/u)),
|
|
22423
|
+
publishers: z.array(z.object({
|
|
22424
|
+
jobId: z.string().min(1),
|
|
22425
|
+
jobName: z.string().min(1),
|
|
22426
|
+
requiredSteps: z.array(z.string().min(1)).min(1),
|
|
22427
|
+
workflowPath: z.string().regex(/^\.github\/workflows\/[^/]+\.ya?ml$/u)
|
|
22428
|
+
})).min(1),
|
|
22429
|
+
targets: z.array(z.string().regex(/^preview:[a-z][a-z0-9-]*$/u)).min(1)
|
|
22430
|
+
});
|
|
22431
|
+
const safeCapture = (capture) => (args, cwd) => {
|
|
22432
|
+
try {
|
|
22433
|
+
const text = capture(args, cwd);
|
|
22434
|
+
JSON.parse(text);
|
|
22435
|
+
return text;
|
|
22436
|
+
} catch {
|
|
22437
|
+
throw new Error("Preview receipt authority could not be read.");
|
|
22438
|
+
}
|
|
22439
|
+
};
|
|
22440
|
+
/** Authority is the GitHub default-branch profile at a resolved immutable SHA. */
|
|
22441
|
+
const readPreviewReceiptPolicy = (repository, cwd, rawCapture) => {
|
|
22442
|
+
const capture = safeCapture(rawCapture);
|
|
22443
|
+
const metadata = z.object({ default_branch: z.string().min(1) }).parse(JSON.parse(capture(["api", `repos/${repository}`], cwd)));
|
|
22444
|
+
const ref = z.object({ object: z.object({ sha: z.string().regex(/^[a-f0-9]{40}$/u) }) }).parse(JSON.parse(capture(["api", `repos/${repository}/git/ref/heads/${encodeURIComponent(metadata.default_branch)}`], cwd)));
|
|
22445
|
+
const file = z.object({
|
|
22446
|
+
content: z.string(),
|
|
22447
|
+
encoding: z.literal("base64")
|
|
22448
|
+
}).parse(JSON.parse(capture(["api", `repos/${repository}/contents/software-factory.profile.json?ref=${ref.object.sha}`], cwd)));
|
|
22449
|
+
const profile = JSON.parse(Buffer.from(file.content, "base64").toString("utf-8"));
|
|
22450
|
+
const policy = policySchema.parse(profile.extensions?.hostedPreviewReceipts);
|
|
22451
|
+
return {
|
|
22452
|
+
...policy,
|
|
22453
|
+
publishers: policy.publishers.map((publisher) => ({
|
|
22454
|
+
...publisher,
|
|
22455
|
+
workflowBlobSha: z.object({ sha: z.string().regex(/^[a-f0-9]{40}$/u) }).parse(JSON.parse(capture(["api", `repos/${repository}/contents/${publisher.workflowPath}?ref=${ref.object.sha}`], cwd))).sha
|
|
22456
|
+
}))
|
|
22457
|
+
};
|
|
22458
|
+
};
|
|
22459
|
+
const assertCurrentCheckout = (candidate, cwd) => {
|
|
22460
|
+
if (candidate.state !== "open" || candidate.draft || runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim() !== candidate.head.sha || runCapture("git", [
|
|
22461
|
+
"status",
|
|
22462
|
+
"--porcelain",
|
|
22463
|
+
"--untracked-files=no"
|
|
22464
|
+
], cwd).stdout.trim() !== "") throw new Error("Preview publication requires the clean current candidate checkout.");
|
|
22465
|
+
};
|
|
22466
|
+
const runPreviewReceipt = (options, env = process.env, rawCapture = (args, cwd) => runCapture("gh", args, cwd).stdout) => {
|
|
22467
|
+
const capture = safeCapture(rawCapture);
|
|
22468
|
+
const cwd = resolveCwdOption(options.cwd);
|
|
22469
|
+
const repository = z.object({ nameWithOwner: z.string().regex(/^[^/\s]+\/[^/\s]+$/u) }).parse(JSON.parse(capture([
|
|
22470
|
+
"repo",
|
|
22471
|
+
"view",
|
|
22472
|
+
"--json",
|
|
22473
|
+
"nameWithOwner"
|
|
22474
|
+
], cwd))).nameWithOwner;
|
|
22475
|
+
const identityResult = workflowRunIdentityFromEnv(env);
|
|
22476
|
+
if (options.action !== "read" && "invalid" in identityResult) throw new Error("Preview receipts require a complete GitHub Actions identity.");
|
|
22477
|
+
const pr = z.coerce.number().int().positive().parse(options.pr);
|
|
22478
|
+
const policy = readPreviewReceiptPolicy(repository, cwd, capture);
|
|
22479
|
+
const candidate = z.object({
|
|
22480
|
+
draft: z.boolean(),
|
|
22481
|
+
head: z.object({
|
|
22482
|
+
ref: z.string().min(1),
|
|
22483
|
+
repo: z.object({ full_name: z.literal(repository) }),
|
|
22484
|
+
sha: z.string().regex(/^[a-f0-9]{40}$/u)
|
|
22485
|
+
}),
|
|
22486
|
+
state: z.enum(["open", "closed"])
|
|
22487
|
+
}).parse(JSON.parse(capture(["api", `repos/${repository}/pulls/${pr}`], cwd)));
|
|
22488
|
+
const preview = {
|
|
22489
|
+
headRef: candidate.head.ref,
|
|
22490
|
+
pr
|
|
22491
|
+
};
|
|
22492
|
+
const stage = `pr-${pr}`;
|
|
22493
|
+
const transport = ghDeploymentReceiptTransport(cwd, repository, capture);
|
|
22494
|
+
if (options.action === "read") {
|
|
22495
|
+
if (candidate.state !== "open" || candidate.draft) throw new Error("Preview reuse requires an open, ready candidate.");
|
|
22496
|
+
const targets = policy.targets.map((target) => ({
|
|
22497
|
+
receipt: resolveDeploymentBaseline({
|
|
22498
|
+
identity: { repository },
|
|
22499
|
+
preview: {
|
|
22500
|
+
...preview,
|
|
22501
|
+
candidateSha: candidate.head.sha,
|
|
22502
|
+
publishers: policy.publishers
|
|
22503
|
+
},
|
|
22504
|
+
stage,
|
|
22505
|
+
target,
|
|
22506
|
+
transport
|
|
22507
|
+
}),
|
|
22508
|
+
target
|
|
22509
|
+
}));
|
|
22510
|
+
return {
|
|
22511
|
+
headSha: candidate.head.sha,
|
|
22512
|
+
physicalFreshness: "unchecked",
|
|
22513
|
+
pr,
|
|
22514
|
+
stage,
|
|
22515
|
+
targets
|
|
22516
|
+
};
|
|
22517
|
+
}
|
|
22518
|
+
if ("invalid" in identityResult || identityResult.identity.repository !== repository) throw new Error("Preview publisher identity disagrees with the current repository.");
|
|
22519
|
+
const { identity } = identityResult;
|
|
22520
|
+
if (!options.target || !policy.targets.includes(options.target)) throw new Error("Preview target is not authorized by the default-branch profile.");
|
|
22521
|
+
const cleanup = options.action === "invalidate";
|
|
22522
|
+
if (cleanup ? !policy.cleanupWorkflowPaths.includes(identity.workflowPath) : !policy.publishers.some((publisher) => publisher.workflowPath === identity.workflowPath && publisher.jobId === env.GITHUB_JOB)) throw new Error("This workflow job is not an authorized preview receipt publisher.");
|
|
22523
|
+
if (!cleanup) assertCurrentCheckout(candidate, cwd);
|
|
22524
|
+
const deploymentId = options.deploymentId === void 0 ? void 0 : z.coerce.number().int().positive().parse(options.deploymentId);
|
|
22525
|
+
if (options.action === "complete" !== (deploymentId !== void 0)) throw new Error("Only completion requires a deployment id.");
|
|
22526
|
+
let state = "inactive";
|
|
22527
|
+
if (options.action === "begin") state = "pending";
|
|
22528
|
+
if (options.action === "complete") state = z.enum(["success", "failure"]).parse(options.outcome);
|
|
22529
|
+
const receipt = publishDeploymentReceipt({
|
|
22530
|
+
deploymentId,
|
|
22531
|
+
identity,
|
|
22532
|
+
preview,
|
|
22533
|
+
sourceSha: candidate.head.sha,
|
|
22534
|
+
stage,
|
|
22535
|
+
state,
|
|
22536
|
+
target: options.target,
|
|
22537
|
+
transport
|
|
22538
|
+
});
|
|
22539
|
+
if (options.githubOutput) appendFileSync(options.githubOutput, `deployment_id=${receipt.deploymentId}\n`);
|
|
22540
|
+
return receipt;
|
|
22541
|
+
};
|
|
22542
|
+
const createPreviewReceiptCommand = (output) => markCwdOptionDefault(new Command("preview:receipt").description("Record hosted preview intent/results or read bound target receipts; physical freshness remains consumer-owned").requiredOption("--action <action>", "begin, complete, invalidate, or read").requiredOption("--pr <number>", "pull request owning the hosted pr-N stage").option("--cwd <path>", "repository working directory", collectCwdOption).option("--target <name>", "preview target from the trusted default-branch profile").option("--deployment-id <id>", "same intent id returned by begin").option("--outcome <outcome>", "success or failure for complete").option("--github-output <path>", "append deployment_id to the Actions output file").action((options) => {
|
|
22543
|
+
z.enum([
|
|
22544
|
+
"begin",
|
|
22545
|
+
"complete",
|
|
22546
|
+
"invalidate",
|
|
22547
|
+
"read"
|
|
22548
|
+
]).parse(options.action);
|
|
22549
|
+
output.stdout.write(`${JSON.stringify(runPreviewReceipt(options), null, 2)}\n`);
|
|
22550
|
+
}));
|
|
22551
|
+
//#endregion
|
|
22169
22552
|
//#region src/worktree-scratch-files.ts
|
|
22170
22553
|
var worktree_scratch_files_exports = /* @__PURE__ */ __exportAll({
|
|
22171
22554
|
checkWorktreeScratchFiles: () => checkWorktreeScratchFiles,
|
|
@@ -22238,6 +22621,7 @@ function createProgram(options = {}) {
|
|
|
22238
22621
|
program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
|
|
22239
22622
|
program.addCommand(createPrVerifyCommand(output));
|
|
22240
22623
|
program.addCommand(createProductionImpactCommand(output));
|
|
22624
|
+
program.addCommand(createPreviewReceiptCommand(output));
|
|
22241
22625
|
program.addCommand(createPreviewReapCommand(output));
|
|
22242
22626
|
program.addCommand(createCandidateImpactCommand(output));
|
|
22243
22627
|
program.addCommand(createCloseoutCommand(output));
|