@patronage/software-factory 1.0.0-alpha.33 → 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 +303 -165
- package/dist/index.js +1614 -1217
- 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
|
/**
|
|
@@ -1349,7 +1349,11 @@ function resolveDiffBase(cwd, base) {
|
|
|
1349
1349
|
}
|
|
1350
1350
|
}
|
|
1351
1351
|
function statusPorcelain(cwd) {
|
|
1352
|
-
return runCapture("git", [
|
|
1352
|
+
return runCapture("git", [
|
|
1353
|
+
"status",
|
|
1354
|
+
"--porcelain",
|
|
1355
|
+
"--untracked-files=all"
|
|
1356
|
+
], cwd).stdout.trim();
|
|
1353
1357
|
}
|
|
1354
1358
|
function filesFromNameStatus(value) {
|
|
1355
1359
|
return value.split("\n").filter(Boolean).flatMap((line) => {
|
|
@@ -2507,27 +2511,9 @@ function tryReadProof(read, filePath) {
|
|
|
2507
2511
|
function readOptionalProof(read, filePath) {
|
|
2508
2512
|
return tryReadProof(read, filePath).proof;
|
|
2509
2513
|
}
|
|
2510
|
-
|
|
2511
|
-
const decodeStatusPath = (filePath) => {
|
|
2512
|
-
const trimmed = filePath.trim();
|
|
2513
|
-
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) try {
|
|
2514
|
-
return JSON.parse(trimmed);
|
|
2515
|
-
} catch {}
|
|
2516
|
-
return trimmed;
|
|
2517
|
-
};
|
|
2518
|
-
const statusLinePaths = (line) => {
|
|
2519
|
-
const xyStatus = line.slice(0, 2);
|
|
2520
|
-
const porcelainPath = line.slice(3);
|
|
2521
|
-
return xyStatus[0] === "R" || xyStatus[0] === "C" || xyStatus[1] === "R" || xyStatus[1] === "C" ? porcelainPath.split(" -> ").map(decodeStatusPath) : [decodeStatusPath(porcelainPath)];
|
|
2522
|
-
};
|
|
2523
|
-
function assertCleanWorktreeForProof({ changedFiles, cwd, dirtyMessage, statusPorcelain }) {
|
|
2514
|
+
function assertCleanWorktreeForProof({ cwd, dirtyMessage, statusPorcelain }) {
|
|
2524
2515
|
const status = statusPorcelain(cwd);
|
|
2525
|
-
|
|
2526
|
-
const offendingLines = status.split("\n").filter((line) => {
|
|
2527
|
-
if (line.length < 3) return false;
|
|
2528
|
-
return statusLinePaths(line).some((filePath) => changedFileSet.has(normalizeStatusPath(filePath)));
|
|
2529
|
-
});
|
|
2530
|
-
if (offendingLines.length > 0) throw new Error(`${dirtyMessage}\n${offendingLines.join("\n")}`);
|
|
2516
|
+
if (status.trim().length > 0) throw new Error(`${dirtyMessage}\n${status}`);
|
|
2531
2517
|
}
|
|
2532
2518
|
//#endregion
|
|
2533
2519
|
//#region src/battery-shell-command.ts
|
|
@@ -7096,1239 +7082,1351 @@ const RESOLVED_PR_VERIFY_MODES = new Set([
|
|
|
7096
7082
|
]);
|
|
7097
7083
|
const isResolvedPrVerifyMode = (value) => RESOLVED_PR_VERIFY_MODES.has(value);
|
|
7098
7084
|
//#endregion
|
|
7099
|
-
//#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);
|
|
7100
7098
|
/**
|
|
7101
|
-
*
|
|
7102
|
-
*
|
|
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.
|
|
7103
7102
|
*
|
|
7104
|
-
*
|
|
7105
|
-
*
|
|
7106
|
-
*
|
|
7107
|
-
* is a
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
const PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION = 1;
|
|
7111
|
-
const OUTCOMES = ["aborted", "passed"];
|
|
7112
|
-
const isOutcome = (value) => OUTCOMES.includes(value);
|
|
7113
|
-
/**
|
|
7114
|
-
* The payload's outcome must agree with the check run's own conclusion, which
|
|
7115
|
-
* comes from `verifyProofPassed`: a proof with no recorded outcome is
|
|
7116
|
-
* 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.
|
|
7117
7109
|
*/
|
|
7118
|
-
const
|
|
7119
|
-
|
|
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();
|
|
7120
7130
|
/**
|
|
7121
|
-
*
|
|
7122
|
-
*
|
|
7123
|
-
*
|
|
7124
|
-
*
|
|
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.
|
|
7125
7136
|
*/
|
|
7126
|
-
const
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
|
|
7136
|
-
mode: value.mode,
|
|
7137
|
-
...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
|
|
7138
|
-
outcome: outcomeFor$1(value),
|
|
7139
|
-
proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
|
|
7140
|
-
schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
|
|
7141
|
-
...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
|
|
7142
|
-
...typeof value.repository === "string" ? { repository: value.repository } : {},
|
|
7143
|
-
...carriesReleases && value.verificationCommands !== void 0 ? { verificationCommands: value.verificationCommands } : {}
|
|
7144
|
-
};
|
|
7145
|
-
};
|
|
7146
|
-
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]);
|
|
7147
7146
|
//#endregion
|
|
7148
|
-
//#region src/
|
|
7149
|
-
const
|
|
7150
|
-
"pr-ready": "patronage-factory/pr-ready",
|
|
7151
|
-
"pr-verify": "patronage-factory/pr-verify"
|
|
7152
|
-
};
|
|
7153
|
-
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";
|
|
7154
7149
|
/**
|
|
7155
|
-
*
|
|
7156
|
-
*
|
|
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.
|
|
7157
7156
|
*
|
|
7158
|
-
*
|
|
7159
|
-
*
|
|
7160
|
-
*
|
|
7161
|
-
* #473 wave 2 measured — with no rollup row saying why. So no `pr-ready`
|
|
7162
|
-
* publication comes through here at all: `pr:ready` publishes once, completed,
|
|
7163
|
-
* through {@link ensureFactoryCheckRunPublished}, whose result the caller
|
|
7164
|
-
* reads. It used to publish an in-progress run here while hosted checks
|
|
7165
|
-
* settled, on the reasoning that a missing in-progress check cannot green
|
|
7166
|
-
* anything — true, and beside the point, because a *present* one cannot be
|
|
7167
|
-
* un-blocked and nothing ever completed it (#526).
|
|
7168
|
-
*/
|
|
7169
|
-
function publishFactoryCheckSafely(publisher, input) {
|
|
7170
|
-
try {
|
|
7171
|
-
publisher?.(input);
|
|
7172
|
-
} catch (error) {
|
|
7173
|
-
console.warn(`${FACTORY_CHECK_NAMES[input.gate]} proof mirror failed without affecting the gate: ${error instanceof Error ? error.message : String(error)}`);
|
|
7174
|
-
}
|
|
7175
|
-
}
|
|
7176
|
-
/**
|
|
7177
|
-
* The detached, fire-and-forget publisher does not retry: it runs on a tail the
|
|
7178
|
-
* process may never await, so sleeping there buys no durability. Reliability is
|
|
7179
|
-
* bought on the awaited path (`ensureFactoryCheckRunPublished`), which the
|
|
7180
|
-
* composed `pr:publish` flow runs once the head SHA is on GitHub.
|
|
7181
|
-
*/
|
|
7182
|
-
const DEFAULT_CHECK_RUN_RETRY = {
|
|
7183
|
-
attempts: 1,
|
|
7184
|
-
delayMs: 0
|
|
7185
|
-
};
|
|
7186
|
-
/**
|
|
7187
|
-
* The awaited path blocks `pr:publish`, so it is bounded twice: by attempts and
|
|
7188
|
-
* by a wall-clock budget. Publication can no longer fail the gate; it must not
|
|
7189
|
-
* be able to hang it either.
|
|
7190
|
-
*/
|
|
7191
|
-
const DURABLE_CHECK_RUN_RETRY = {
|
|
7192
|
-
attempts: 3,
|
|
7193
|
-
budgetMs: 2e4,
|
|
7194
|
-
delayMs: 750
|
|
7195
|
-
};
|
|
7196
|
-
const defaultSleep = async (ms) => {
|
|
7197
|
-
const { setTimeout: delay } = await import("node:timers/promises");
|
|
7198
|
-
await delay(ms);
|
|
7199
|
-
};
|
|
7200
|
-
/**
|
|
7201
|
-
* Worth another attempt: 422 is what GitHub answers for a head SHA it has not
|
|
7202
|
-
* seen yet (the whole reason the retry exists), 5xx and rate limiting are
|
|
7203
|
-
* transient, and a thrown non-HTTP error is a network/timeout failure. A 401 /
|
|
7204
|
-
* 403 / 404 means the credentials or installation are wrong, and retrying that
|
|
7205
|
-
* only burns the caller's time budget.
|
|
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.
|
|
7206
7160
|
*/
|
|
7207
|
-
const
|
|
7208
|
-
if (!(
|
|
7209
|
-
return error.status === 422 || error.status === 429 || error.status >= 500;
|
|
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.`);
|
|
7210
7163
|
};
|
|
7211
7164
|
/**
|
|
7212
|
-
*
|
|
7213
|
-
* run whose `head_sha` it has never seen.
|
|
7165
|
+
* Plan one verification battery against the candidate's impact stamp.
|
|
7214
7166
|
*
|
|
7215
|
-
*
|
|
7216
|
-
*
|
|
7217
|
-
*
|
|
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}).
|
|
7218
7172
|
*
|
|
7219
|
-
*
|
|
7220
|
-
*
|
|
7221
|
-
*
|
|
7222
|
-
*
|
|
7223
|
-
* telling the operator it is stored and needs no rerun would be false in every
|
|
7224
|
-
* clause, and false in the expensive direction. A failed verification keeps the
|
|
7225
|
-
* plain diagnostic (#316).
|
|
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`.
|
|
7226
7177
|
*/
|
|
7227
|
-
const
|
|
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
|
+
};
|
|
7221
|
+
};
|
|
7228
7222
|
/**
|
|
7229
|
-
*
|
|
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.
|
|
7230
7225
|
*
|
|
7231
|
-
*
|
|
7232
|
-
*
|
|
7233
|
-
*
|
|
7234
|
-
*
|
|
7235
|
-
*
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
Re-verify only if the tree changes: a new commit, an amend, or a rebase.
|
|
7240
|
-
`;
|
|
7241
|
-
/**
|
|
7242
|
-
* Mint an installation token for the Patronage Factory App.
|
|
7243
|
-
*
|
|
7244
|
-
* The mechanism — app JWT, installation lookup, token exchange — lives in
|
|
7245
|
-
* `@patronage/factory-ci` (#617), because paitronage's proof-comment publisher
|
|
7246
|
-
* had grown a second copy of it. What stays here is what is this repository's:
|
|
7247
|
-
* where the credentials come from (`user-config`), and the publish timeout the
|
|
7248
|
-
* rest of these calls are bound by. Nothing is cached, as before.
|
|
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.
|
|
7249
7234
|
*
|
|
7250
|
-
*
|
|
7251
|
-
*
|
|
7252
|
-
* confirmation read-back leaves the default, because it has no fallback and
|
|
7253
|
-
* a stale keep-alive is the class #923 covers.
|
|
7254
|
-
*/
|
|
7255
|
-
const installationToken = (repository, config, request, now, budget = {}) => mintFactoryInstallationToken(repository, config, request, now, {
|
|
7256
|
-
timeoutMs: budget.timeoutMs ?? 5e3,
|
|
7257
|
-
...budget.transportAttempts === void 0 ? {} : { transportAttempts: budget.transportAttempts }
|
|
7258
|
-
});
|
|
7259
|
-
function verifyOutput(proof) {
|
|
7260
|
-
const value = proof;
|
|
7261
|
-
const commands = value.executedCommands?.length ?? 0;
|
|
7262
|
-
const payload = prVerifyCheckPayloadFor(proof);
|
|
7263
|
-
return {
|
|
7264
|
-
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)}`,
|
|
7265
|
-
...payload ? { text: renderPrVerifyCheckPayloadText(payload) } : {},
|
|
7266
|
-
title: `pr:verify ${value.outcome ?? "unknown"}`
|
|
7267
|
-
};
|
|
7268
|
-
}
|
|
7269
|
-
/**
|
|
7270
|
-
* The refusals to name, preferring the attributed projection (#391) so each
|
|
7271
|
-
* line carries its demand code. `blockingReasons` is the fallback because the
|
|
7272
|
-
* throwing path in `pr:ready` publishes a proof that carries only that field.
|
|
7273
|
-
* The two projections hold the same refusals in the same order, so reading
|
|
7274
|
-
* either one tells the same story.
|
|
7275
|
-
*/
|
|
7276
|
-
const readyReasonLines = (value) => {
|
|
7277
|
-
const named = value.blockedReasons ?? [];
|
|
7278
|
-
if (named.length > 0) return named.map((reason) => ({
|
|
7279
|
-
...reason.code === void 0 ? {} : { code: reason.code },
|
|
7280
|
-
detail: reason.detail ?? "unrecorded"
|
|
7281
|
-
}));
|
|
7282
|
-
return (value.blockingReasons ?? []).map((reason) => ({ detail: typeof reason === "string" ? reason : "unrecorded" }));
|
|
7283
|
-
};
|
|
7284
|
-
const readyReasonBlock = (reasons) => {
|
|
7285
|
-
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")}`;
|
|
7286
|
-
};
|
|
7287
|
-
const readyIdentityField = (value) => value === void 0 || value.trim().length === 0 ? "unrecorded" : `\`${value}\``;
|
|
7288
|
-
/**
|
|
7289
|
-
* #1037: the review identity the proof already records, printed where the
|
|
7290
|
-
* merge decision is made. `pr:ready` recorded the review session, model, and
|
|
7291
|
-
* producer, and the authoring session it was checked against, but only the PR
|
|
7292
|
-
* body prose showed them — so a human merging from the checks page could not
|
|
7293
|
-
* see which model produced the review that admitted the candidate.
|
|
7235
|
+
* Rule 4 is scoped to `outcome: "passed"` because an ABORTED proof legitimately
|
|
7236
|
+
* records partial execution — the run stopped at the failing command.
|
|
7294
7237
|
*
|
|
7295
|
-
*
|
|
7296
|
-
*
|
|
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.
|
|
7297
7242
|
*/
|
|
7298
|
-
const
|
|
7299
|
-
const
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
}
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
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"]
|
|
7326
7286
|
});
|
|
7327
|
-
return stdout;
|
|
7328
|
-
}
|
|
7329
|
-
async function resolveFactoryDetailsUrl(input, run = runGhDetails) {
|
|
7330
|
-
const hqBase = input.hqLaneBaseUrl;
|
|
7331
|
-
if (hqBase !== void 0 && input.pr !== void 0) return hqLaneRefUrl(hqBase, input.repo, input.pr);
|
|
7332
|
-
const raw = await run([
|
|
7333
|
-
"pr",
|
|
7334
|
-
"view",
|
|
7335
|
-
...input.pr === void 0 ? [] : [
|
|
7336
|
-
String(input.pr),
|
|
7337
|
-
"--repo",
|
|
7338
|
-
`${input.owner}/${input.repo}`
|
|
7339
|
-
],
|
|
7340
|
-
"--json",
|
|
7341
|
-
"comments,number,url"
|
|
7342
|
-
], input.cwd);
|
|
7343
|
-
const pr = JSON.parse(raw);
|
|
7344
|
-
if (hqBase !== void 0 && typeof pr.number === "number") return hqLaneRefUrl(hqBase, input.repo, pr.number);
|
|
7345
|
-
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}`;
|
|
7346
|
-
}
|
|
7347
|
-
const checkRunRequestBody = (input, detailsUrl) => {
|
|
7348
|
-
const status = input.status ?? "completed";
|
|
7349
|
-
return {
|
|
7350
|
-
...status === "completed" ? { conclusion: input.conclusion } : {},
|
|
7351
|
-
details_url: detailsUrl,
|
|
7352
|
-
head_sha: input.sha,
|
|
7353
|
-
name: FACTORY_CHECK_NAMES[input.gate],
|
|
7354
|
-
output: factoryCheckOutput(input.gate, input.proof),
|
|
7355
|
-
status
|
|
7356
|
-
};
|
|
7357
7287
|
};
|
|
7358
7288
|
/**
|
|
7359
|
-
*
|
|
7289
|
+
* The stamp-authorization control: a passed proof may only claim a command
|
|
7290
|
+
* was NOT REQUIRED if its own recorded stamp says so.
|
|
7360
7291
|
*
|
|
7361
|
-
*
|
|
7362
|
-
*
|
|
7363
|
-
*
|
|
7364
|
-
*
|
|
7365
|
-
*
|
|
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.
|
|
7366
7298
|
*
|
|
7367
|
-
*
|
|
7368
|
-
* wall-clock budget, and retryability of the failure itself — a 401/403/404
|
|
7369
|
-
* means the credentials are wrong, and re-sending them cannot help.
|
|
7299
|
+
* Two things have to hold, and the first is what keeps the second honest:
|
|
7370
7300
|
*
|
|
7371
|
-
*
|
|
7372
|
-
*
|
|
7373
|
-
*
|
|
7374
|
-
*
|
|
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.
|
|
7375
7329
|
*/
|
|
7376
|
-
|
|
7377
|
-
const
|
|
7378
|
-
|
|
7379
|
-
const
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
timeoutMs,
|
|
7386
|
-
transportAttempts: 1
|
|
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"]
|
|
7387
7339
|
});
|
|
7388
|
-
return
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
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
|
+
]
|
|
7406
7370
|
});
|
|
7407
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);
|
|
7408
7489
|
}
|
|
7490
|
+
//#endregion
|
|
7491
|
+
//#region src/pr-verify-check-payload.ts
|
|
7409
7492
|
/**
|
|
7410
|
-
*
|
|
7411
|
-
*
|
|
7412
|
-
*
|
|
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`.
|
|
7413
7500
|
*/
|
|
7414
|
-
const
|
|
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);
|
|
7415
7505
|
/**
|
|
7416
|
-
*
|
|
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.
|
|
7517
|
+
*/
|
|
7518
|
+
const prVerifyCheckPayloadFor = (proof) => {
|
|
7519
|
+
const value = proof;
|
|
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 } : {}
|
|
7536
|
+
};
|
|
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;
|
|
7546
|
+
};
|
|
7547
|
+
const readTransportedVerifyProof = (value, text) => {
|
|
7548
|
+
if (value === void 0 || new TextEncoder().encode(text).length > 6e4) return {};
|
|
7549
|
+
return { verificationProof: validatePrVerifyProof(value) };
|
|
7550
|
+
};
|
|
7551
|
+
/**
|
|
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.
|
|
7555
|
+
*/
|
|
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 {}
|
|
7579
|
+
};
|
|
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"
|
|
7586
|
+
};
|
|
7587
|
+
const hqLaneRefUrl = (base, repo, number) => `${base}/${encodeURIComponent(repo)}/${number}`;
|
|
7588
|
+
/**
|
|
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.
|
|
7417
7591
|
*
|
|
7418
|
-
*
|
|
7419
|
-
*
|
|
7420
|
-
*
|
|
7421
|
-
*
|
|
7422
|
-
*
|
|
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).
|
|
7423
7602
|
*/
|
|
7424
|
-
|
|
7425
|
-
`;
|
|
7426
|
-
async function resolveDetailsUrlSafely(input, dependencies, onDiagnostic) {
|
|
7603
|
+
function publishFactoryCheckSafely(publisher, input) {
|
|
7427
7604
|
try {
|
|
7428
|
-
|
|
7605
|
+
publisher?.(input);
|
|
7429
7606
|
} catch (error) {
|
|
7430
|
-
|
|
7431
|
-
onDiagnostic?.(isNoPullRequestYet(message) ? noPullRequestDetailsUrlNotice(input.gate) : `${FACTORY_CHECK_NAMES[input.gate]}: unable to resolve Details URL; using commit: ${message}\n`);
|
|
7432
|
-
return `https://github.com/${input.owner}/${input.repo}/commit/${input.sha}`;
|
|
7607
|
+
console.warn(`${FACTORY_CHECK_NAMES[input.gate]} proof mirror failed without affecting the gate: ${error instanceof Error ? error.message : String(error)}`);
|
|
7433
7608
|
}
|
|
7434
7609
|
}
|
|
7435
7610
|
/**
|
|
7436
|
-
*
|
|
7437
|
-
*
|
|
7438
|
-
*
|
|
7439
|
-
*
|
|
7440
|
-
* is false, measured on PR #523's head `77cf425`. Two `patronage-factory/
|
|
7441
|
-
* pr-ready` runs existed there — `91323051988` (`in_progress`, stranded by
|
|
7442
|
-
* older code) and `91323113996` (`completed`/`success`). `latest` served the
|
|
7443
|
-
* completed one, while the pull request's rollup reported *both*
|
|
7444
|
-
* (`PENDING` and `SUCCESS`) and `mergeStateStatus` stayed `BLOCKED`. So `latest`
|
|
7445
|
-
* can report a publication confirmed while the gate still blocks on a different
|
|
7446
|
-
* run of the same name — false confidence in exactly the direction this
|
|
7447
|
-
* read-back exists to eliminate. The set the gate evaluates is every run of the
|
|
7448
|
-
* name, which is what this asks for.
|
|
7449
|
-
*
|
|
7450
|
-
* Three pins keep the answer usable as proof:
|
|
7451
|
-
*
|
|
7452
|
-
* - `app_id` — the required check is source-pinned to the factory App, so a
|
|
7453
|
-
* same-named run from another installed app (a GitHub Actions job named
|
|
7454
|
-
* `patronage-factory/pr-ready` materializes under integration 15368) is not
|
|
7455
|
-
* the pinned requirement. Left unpinned it could confirm a publication the
|
|
7456
|
-
* pinned check never got, or refuse every genuine one. This is the same
|
|
7457
|
-
* fail-closed rule `checkRunProducedByFactoryApp` applies to durable records
|
|
7458
|
-
* below. A run served without any `app` id is refused rather than filtered
|
|
7459
|
-
* out: dropping it fails closed for the newest-run clause but open for
|
|
7460
|
-
* terminality, and an unfinished run of unknown provenance still blocks the
|
|
7461
|
-
* gate.
|
|
7462
|
-
* - `per_page=100` with a required `total_count` that equals the page — a
|
|
7463
|
-
* truncated page holds neither the whole set nor a defensible newest run, and
|
|
7464
|
-
* `filter=all` returns strictly more runs than `latest` did, so this guard now
|
|
7465
|
-
* carries more weight. Truncation is unconfirmed, not paginated: a busy head
|
|
7466
|
-
* refuses rather than judging a publication against a page that silently
|
|
7467
|
-
* omits the run blocking it. An absent or malformed count cannot establish
|
|
7468
|
-
* completeness at all, so it refuses too.
|
|
7469
|
-
* - an unparsable or absent `started_at` — unorderable state is refused before
|
|
7470
|
-
* selection rather than sorting to an extreme and being chosen.
|
|
7471
|
-
*
|
|
7472
|
-
* Ordering among what survives goes through the sequencing owner rather than a
|
|
7473
|
-
* second rule here.
|
|
7474
|
-
*/
|
|
7475
|
-
async function servedFactoryCheckRun(input, config, dependencies) {
|
|
7476
|
-
const request = dependencies.fetch ?? fetch;
|
|
7477
|
-
const timeoutMs = dependencies.timeoutMs ?? 5e3;
|
|
7478
|
-
const token = dependencies.token ?? await installationToken(input, config, request, (dependencies.now ?? Date.now)(), { timeoutMs });
|
|
7479
|
-
const query = new URLSearchParams({
|
|
7480
|
-
app_id: String(config.appId),
|
|
7481
|
-
check_name: FACTORY_CHECK_NAMES[input.gate],
|
|
7482
|
-
filter: "all",
|
|
7483
|
-
per_page: "100"
|
|
7484
|
-
});
|
|
7485
|
-
const served = await requestGitHubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/commits/${input.sha}/check-runs?${query.toString()}`, {
|
|
7486
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
7487
|
-
method: "GET"
|
|
7488
|
-
}, timeoutMs);
|
|
7489
|
-
const page = Array.isArray(served.check_runs) ? served.check_runs : [];
|
|
7490
|
-
const totalCount = served.total_count;
|
|
7491
|
-
if (!(typeof totalCount === "number" && Number.isSafeInteger(totalCount))) return {
|
|
7492
|
-
kind: "unavailable",
|
|
7493
|
-
reason: "the served page reports no usable total_count"
|
|
7494
|
-
};
|
|
7495
|
-
if (totalCount !== page.length) return {
|
|
7496
|
-
kind: "unavailable",
|
|
7497
|
-
reason: `${page.length} runs on the page against a total_count of ${totalCount}`
|
|
7498
|
-
};
|
|
7499
|
-
if (page.some((run) => (run.app?.id ?? null) === null)) return {
|
|
7500
|
-
kind: "unavailable",
|
|
7501
|
-
reason: "a served run has no app identity"
|
|
7502
|
-
};
|
|
7503
|
-
const runs = page.filter((run) => String(run.app?.id ?? "") === String(config.appId));
|
|
7504
|
-
const orderable = runs.flatMap((run) => {
|
|
7505
|
-
const orderMs = generationOrderMs(run.started_at);
|
|
7506
|
-
return Number.isFinite(orderMs) ? [{
|
|
7507
|
-
orderMs,
|
|
7508
|
-
run
|
|
7509
|
-
}] : [];
|
|
7510
|
-
});
|
|
7511
|
-
if (orderable.length !== runs.length) return {
|
|
7512
|
-
kind: "unavailable",
|
|
7513
|
-
reason: "a served run has no usable started_at"
|
|
7514
|
-
};
|
|
7515
|
-
const newest = selectNewestGeneration(orderable);
|
|
7516
|
-
if (newest.kind === "ambiguous") return {
|
|
7517
|
-
kind: "unavailable",
|
|
7518
|
-
reason: `${newest.tied.length} served runs share the newest started_at`
|
|
7519
|
-
};
|
|
7520
|
-
if (newest.kind === "none") return {
|
|
7521
|
-
kind: "unavailable",
|
|
7522
|
-
reason: "no run is served for this name"
|
|
7523
|
-
};
|
|
7524
|
-
return {
|
|
7525
|
-
kind: "newest",
|
|
7526
|
-
run: newest.generation.run,
|
|
7527
|
-
runs
|
|
7528
|
-
};
|
|
7529
|
-
}
|
|
7530
|
-
/** Runs GitHub has not finished. A non-terminal run blocks its required check. */
|
|
7531
|
-
const unfinishedRuns = (runs) => runs.filter((run) => run.status !== "completed");
|
|
7532
|
-
/**
|
|
7533
|
-
* Whether the served run *is* the publication that was just posted: the same
|
|
7534
|
-
* run, in the status that was asked for, and — for a completed one — with the
|
|
7535
|
-
* conclusion that was asked for.
|
|
7536
|
-
*
|
|
7537
|
-
* Requiring the id is what separates "GitHub serves a run that looks like
|
|
7538
|
-
* mine" from "GitHub serves mine". An older identical success from a previous
|
|
7539
|
-
* invocation satisfies every field comparison while this POST is still
|
|
7540
|
-
* invisible, and confirming on it is exactly the assumed-vs-live trust this
|
|
7541
|
-
* read-back exists to end.
|
|
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.
|
|
7542
7615
|
*/
|
|
7543
|
-
const
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
if (served.status !== status) return false;
|
|
7547
|
-
return status !== "completed" || served.conclusion === input.conclusion;
|
|
7616
|
+
const DEFAULT_CHECK_RUN_RETRY = {
|
|
7617
|
+
attempts: 1,
|
|
7618
|
+
delayMs: 0
|
|
7548
7619
|
};
|
|
7549
7620
|
/**
|
|
7550
|
-
*
|
|
7551
|
-
*
|
|
7552
|
-
*
|
|
7553
|
-
*
|
|
7554
|
-
* The second clause is what makes the answer the *gate's* answer rather than
|
|
7555
|
-
* one collapsed view of it (#524). A stranded `in_progress` run of a
|
|
7556
|
-
* required-check name blocks its pull request permanently and is not cleared by
|
|
7557
|
-
* publishing a newer completed run, so a confirmation that ignores it is a lie
|
|
7558
|
-
* in the one direction that matters. A publication that is itself non-terminal
|
|
7559
|
-
* therefore never confirms — correctly, since it cannot satisfy the check
|
|
7560
|
-
* either. No factory gate makes one for this name any more: `pr:ready`'s
|
|
7561
|
-
* in-progress writer, the last of them, is deleted (#526).
|
|
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.
|
|
7562
7624
|
*/
|
|
7563
|
-
const
|
|
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
|
+
};
|
|
7564
7634
|
/**
|
|
7565
|
-
*
|
|
7566
|
-
*
|
|
7567
|
-
*
|
|
7568
|
-
* the
|
|
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.
|
|
7569
7640
|
*/
|
|
7570
|
-
const
|
|
7571
|
-
|
|
7572
|
-
|
|
7573
|
-
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`;
|
|
7641
|
+
const isRetryableCheckRunFailure = (error) => {
|
|
7642
|
+
if (!(error instanceof GitHubApiError)) return true;
|
|
7643
|
+
return error.status === 422 || error.status === 429 || error.status >= 500;
|
|
7574
7644
|
};
|
|
7575
7645
|
/**
|
|
7576
|
-
*
|
|
7577
|
-
*
|
|
7646
|
+
* The pre-push state of the detached `pr:verify` publisher: GitHub 422s a check
|
|
7647
|
+
* run whose `head_sha` it has never seen.
|
|
7578
7648
|
*
|
|
7579
|
-
*
|
|
7580
|
-
*
|
|
7581
|
-
*
|
|
7582
|
-
*
|
|
7583
|
-
*
|
|
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.
|
|
7652
|
+
*
|
|
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).
|
|
7584
7660
|
*/
|
|
7585
|
-
|
|
7586
|
-
const checkName = FACTORY_CHECK_NAMES[input.gate];
|
|
7587
|
-
let selection;
|
|
7588
|
-
try {
|
|
7589
|
-
selection = await servedFactoryCheckRun(input, config, dependencies);
|
|
7590
|
-
} catch (error) {
|
|
7591
|
-
selection = {
|
|
7592
|
-
kind: "unavailable",
|
|
7593
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
7594
|
-
};
|
|
7595
|
-
}
|
|
7596
|
-
if (selection.kind === "unavailable") {
|
|
7597
|
-
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`);
|
|
7598
|
-
return false;
|
|
7599
|
-
}
|
|
7600
|
-
if (!servedRunConfirmsPublication(input, createdId, selection)) {
|
|
7601
|
-
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`);
|
|
7602
|
-
return false;
|
|
7603
|
-
}
|
|
7604
|
-
return true;
|
|
7605
|
-
}
|
|
7661
|
+
const isPrePushVerifyBinding = (input, error) => input.gate === "pr-verify" && input.conclusion === "success" && error instanceof GitHubApiError && error.status === 422;
|
|
7606
7662
|
/**
|
|
7607
|
-
*
|
|
7608
|
-
* the head SHA visible on GitHub (pushed the branch, created the PR) can make
|
|
7609
|
-
* the proof reliably present for that SHA before it returns (#247).
|
|
7610
|
-
*
|
|
7611
|
-
* Never posts a commit-status fallback: the commit status is a human-readable
|
|
7612
|
-
* mirror, not a proof surface, so a caller that needs an App-verified check
|
|
7613
|
-
* run must be told plainly whether it got one. Returns `true` only when the
|
|
7614
|
-
* App-owned check run landed. Throws {@link FactoryAppUnevaluableError} when
|
|
7615
|
-
* Factory App access is fail-closed (missing mint or Cursor OIDC exchange,
|
|
7616
|
-
* #949): `pr:ready` must not notice that miss and exit 0, and must not publish
|
|
7617
|
-
* a red check that reads as candidate refusal (#1125). An optional-App skip
|
|
7618
|
-
* still returns `false`.
|
|
7663
|
+
* What to tell an operator when the head SHA is not on GitHub yet.
|
|
7619
7664
|
*
|
|
7620
|
-
* "
|
|
7621
|
-
*
|
|
7622
|
-
*
|
|
7623
|
-
*
|
|
7624
|
-
*
|
|
7625
|
-
|
|
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).
|
|
7670
|
+
*/
|
|
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
|
+
`;
|
|
7675
|
+
/**
|
|
7676
|
+
* Mint an installation token for the Patronage Factory App.
|
|
7626
7677
|
*
|
|
7627
|
-
*
|
|
7628
|
-
*
|
|
7629
|
-
*
|
|
7630
|
-
*
|
|
7631
|
-
*
|
|
7632
|
-
* GitHub will never merge. One read, no polling: an unconfirmed publication
|
|
7633
|
-
* returns `false`, which `pr:ready` already turns into a notice and an
|
|
7634
|
-
* idempotent re-dispatch.
|
|
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.
|
|
7635
7683
|
*
|
|
7636
|
-
*
|
|
7637
|
-
*
|
|
7638
|
-
*
|
|
7639
|
-
*
|
|
7640
|
-
* its invocation does. No retry, no poll, no re-confirm.
|
|
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.
|
|
7641
7688
|
*/
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
|
|
7652
|
-
|
|
7653
|
-
|
|
7654
|
-
privateKeyPath: "factory-app-broker"
|
|
7655
|
-
};
|
|
7656
|
-
const tokenDependencies = {
|
|
7657
|
-
...dependencies,
|
|
7658
|
-
token: access.token
|
|
7659
|
-
};
|
|
7660
|
-
const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, onDiagnostic);
|
|
7661
|
-
const created = await postFactoryCheckRun({
|
|
7662
|
-
config,
|
|
7663
|
-
dependencies: {
|
|
7664
|
-
retry: DURABLE_CHECK_RUN_RETRY,
|
|
7665
|
-
...tokenDependencies
|
|
7666
|
-
},
|
|
7667
|
-
detailsUrl,
|
|
7668
|
-
input
|
|
7669
|
-
});
|
|
7670
|
-
return await confirmPublishedCheckRun({
|
|
7671
|
-
config,
|
|
7672
|
-
createdId: typeof created.id === "number" ? created.id : void 0,
|
|
7673
|
-
dependencies: tokenDependencies,
|
|
7674
|
-
input,
|
|
7675
|
-
onDiagnostic
|
|
7676
|
-
});
|
|
7677
|
-
} catch (error) {
|
|
7678
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
7679
|
-
onDiagnostic?.(`${FACTORY_CHECK_NAMES[input.gate]}: unable to republish check run: ${message}\n`);
|
|
7680
|
-
return false;
|
|
7681
|
-
}
|
|
7682
|
-
}
|
|
7683
|
-
function createFactoryCheckPublisher(output, dependencies = {}) {
|
|
7684
|
-
const postStatus = dependencies.postCommitStatus ?? createGhCommitStatusPoster(output);
|
|
7685
|
-
let publishQueue = Promise.resolve();
|
|
7686
|
-
return (input) => {
|
|
7687
|
-
const publish = async () => {
|
|
7688
|
-
await Promise.resolve();
|
|
7689
|
-
const checkName = FACTORY_CHECK_NAMES[input.gate];
|
|
7690
|
-
const { conclusion } = input;
|
|
7691
|
-
const status = input.status ?? "completed";
|
|
7692
|
-
if (status === "completed" && conclusion === void 0) throw new Error("A completed factory check requires a conclusion.");
|
|
7693
|
-
const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, (m) => output.stderr.write(m));
|
|
7694
|
-
const postFallback = (suppressFailureDiagnostic = false) => {
|
|
7695
|
-
try {
|
|
7696
|
-
postStatus({
|
|
7697
|
-
context: checkName,
|
|
7698
|
-
cwd: input.cwd,
|
|
7699
|
-
description: status === "in_progress" ? `${input.gate} gate is running` : `${input.gate} gate ${conclusion === "success" ? "passed" : "failed"}`,
|
|
7700
|
-
owner: input.owner,
|
|
7701
|
-
repo: input.repo,
|
|
7702
|
-
sha: input.sha,
|
|
7703
|
-
state: status === "in_progress" ? "pending" : conclusion ?? "failure",
|
|
7704
|
-
suppressFailureDiagnostic,
|
|
7705
|
-
targetUrl: detailsUrl
|
|
7706
|
-
});
|
|
7707
|
-
} catch (error) {
|
|
7708
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
7709
|
-
output.stderr.write(`${checkName}: unable to post fallback status: ${message}\n`);
|
|
7710
|
-
}
|
|
7711
|
-
};
|
|
7712
|
-
const access = await resolveFactoryAppAccess(input, {
|
|
7713
|
-
...dependencies,
|
|
7714
|
-
transportAttempts: 1
|
|
7715
|
-
});
|
|
7716
|
-
if (access.kind !== "ok") {
|
|
7717
|
-
output.stderr.write(`${checkName}: ${access.message}`);
|
|
7718
|
-
if (!access.failClosed) postFallback();
|
|
7719
|
-
return;
|
|
7720
|
-
}
|
|
7721
|
-
const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp ?? {
|
|
7722
|
-
appId: access.appId,
|
|
7723
|
-
privateKeyPath: "factory-app-broker"
|
|
7724
|
-
};
|
|
7725
|
-
try {
|
|
7726
|
-
await postFactoryCheckRun({
|
|
7727
|
-
config,
|
|
7728
|
-
dependencies: {
|
|
7729
|
-
...dependencies,
|
|
7730
|
-
token: access.token
|
|
7731
|
-
},
|
|
7732
|
-
detailsUrl,
|
|
7733
|
-
input
|
|
7734
|
-
});
|
|
7735
|
-
output.stdout.write(`${checkName} check run posted\n`);
|
|
7736
|
-
} catch (error) {
|
|
7737
|
-
const prePush = isPrePushVerifyBinding(input, error);
|
|
7738
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
7739
|
-
output.stderr.write(prePush ? prePushVerifyBindingNotice(input.sha) : `${checkName}: unable to post check run: ${message}\n`);
|
|
7740
|
-
postFallback(prePush);
|
|
7741
|
-
}
|
|
7742
|
-
};
|
|
7743
|
-
publishQueue = publishQueue.then(publish, publish);
|
|
7744
|
-
runDetachedBestEffort(() => publishQueue);
|
|
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"}`
|
|
7745
7701
|
};
|
|
7746
7702
|
}
|
|
7747
|
-
//#endregion
|
|
7748
|
-
//#region src/repository-profile-path.ts
|
|
7749
7703
|
/**
|
|
7750
|
-
*
|
|
7751
|
-
*
|
|
7752
|
-
*
|
|
7753
|
-
*
|
|
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.
|
|
7754
7709
|
*/
|
|
7755
|
-
const
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
}
|
|
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" }));
|
|
7761
7717
|
};
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
* checkout. Never throws: a caller rooting a path has a usable answer either
|
|
7765
|
-
* way, and the checkout-less case keeps the previous cwd-rooted behavior.
|
|
7766
|
-
*/
|
|
7767
|
-
const repositoryRootOrCwd = ({ cwd, readRepositoryRoot = repositoryRoot }) => {
|
|
7768
|
-
try {
|
|
7769
|
-
return readRepositoryRoot(cwd);
|
|
7770
|
-
} catch {
|
|
7771
|
-
return cwd;
|
|
7772
|
-
}
|
|
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")}`;
|
|
7773
7720
|
};
|
|
7721
|
+
const readyIdentityField = (value) => value === void 0 || value.trim().length === 0 ? "unrecorded" : `\`${value}\``;
|
|
7774
7722
|
/**
|
|
7775
|
-
*
|
|
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.
|
|
7776
7728
|
*
|
|
7777
|
-
*
|
|
7778
|
-
*
|
|
7779
|
-
* makes a path more portable, and it must never invent one. Callers that
|
|
7780
|
-
* require the profile to be inside the trusted checkout keep enforcing that
|
|
7781
|
-
* themselves.
|
|
7729
|
+
* This is recording made visible, not a control. The attended merge is the
|
|
7730
|
+
* control.
|
|
7782
7731
|
*/
|
|
7783
|
-
const
|
|
7784
|
-
const
|
|
7785
|
-
|
|
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, {
|
|
7786
7757
|
cwd,
|
|
7787
|
-
|
|
7758
|
+
encoding: "utf-8",
|
|
7759
|
+
timeout: GITHUB_PUBLISH_TIMEOUT_MS
|
|
7788
7760
|
});
|
|
7789
|
-
|
|
7790
|
-
|
|
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}`;
|
|
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
7791
|
};
|
|
7792
|
-
/** A path that names no file inside the root it was measured from. */
|
|
7793
|
-
const escapesRoot = (relativePath) => relativePath.length === 0 || relativePath === ".." || relativePath.startsWith("../") || path.isAbsolute(relativePath);
|
|
7794
7792
|
/**
|
|
7795
|
-
*
|
|
7796
|
-
* not contain.
|
|
7793
|
+
* POST the check run, retrying a bounded number of times.
|
|
7797
7794
|
*
|
|
7798
|
-
*
|
|
7799
|
-
*
|
|
7800
|
-
*
|
|
7801
|
-
*
|
|
7802
|
-
*
|
|
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).
|
|
7803
7800
|
*
|
|
7804
|
-
*
|
|
7805
|
-
*
|
|
7806
|
-
*
|
|
7807
|
-
*
|
|
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.
|
|
7804
|
+
*
|
|
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.
|
|
7808
7809
|
*/
|
|
7809
|
-
|
|
7810
|
-
const
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
|
|
7816
|
-
|
|
7817
|
-
|
|
7818
|
-
|
|
7819
|
-
|
|
7820
|
-
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
default: return `${file}: no declared owner; fail closed to non-trivial`;
|
|
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;
|
|
7817
|
+
try {
|
|
7818
|
+
const token = dependencies.token ?? await installationToken(input, config, request, (dependencies.now ?? Date.now)(), {
|
|
7819
|
+
timeoutMs,
|
|
7820
|
+
transportAttempts: 1
|
|
7821
|
+
});
|
|
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({
|
|
7834
|
+
config,
|
|
7835
|
+
deadlineMs: deadline,
|
|
7836
|
+
dependencies,
|
|
7837
|
+
detailsUrl,
|
|
7838
|
+
input,
|
|
7839
|
+
remainingAttempts: remaining - 1
|
|
7840
|
+
});
|
|
7841
7841
|
}
|
|
7842
7842
|
}
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7843
|
+
/**
|
|
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).
|
|
7847
|
+
*/
|
|
7848
|
+
const isNoPullRequestYet = (message) => /no pull requests found for branch/iu.test(message);
|
|
7849
|
+
/**
|
|
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.
|
|
7857
|
+
*/
|
|
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) {
|
|
7861
|
+
try {
|
|
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}`;
|
|
7867
|
+
}
|
|
7855
7868
|
}
|
|
7856
7869
|
/**
|
|
7857
|
-
*
|
|
7858
|
-
*
|
|
7859
|
-
*
|
|
7870
|
+
* Read back every run GitHub serves for this check name on this head SHA.
|
|
7871
|
+
*
|
|
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:
|
|
7885
|
+
*
|
|
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.
|
|
7905
|
+
*
|
|
7906
|
+
* Ordering among what survives goes through the sequencing owner rather than a
|
|
7907
|
+
* second rule here.
|
|
7860
7908
|
*/
|
|
7861
|
-
function
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
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"
|
|
7918
|
+
});
|
|
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"
|
|
7865
7928
|
};
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
reasons: ["No changed files detected; defaulting to non-trivial verification."]
|
|
7929
|
+
if (totalCount !== page.length) return {
|
|
7930
|
+
kind: "unavailable",
|
|
7931
|
+
reason: `${page.length} runs on the page against a total_count of ${totalCount}`
|
|
7870
7932
|
};
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
}
|
|
7875
|
-
const
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7933
|
+
if (page.some((run) => (run.app?.id ?? null) === null)) return {
|
|
7934
|
+
kind: "unavailable",
|
|
7935
|
+
reason: "a served run has no app identity"
|
|
7936
|
+
};
|
|
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"
|
|
7948
|
+
};
|
|
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`
|
|
7953
|
+
};
|
|
7954
|
+
if (newest.kind === "none") return {
|
|
7955
|
+
kind: "unavailable",
|
|
7956
|
+
reason: "no run is served for this name"
|
|
7879
7957
|
};
|
|
7880
7958
|
return {
|
|
7881
|
-
|
|
7882
|
-
|
|
7959
|
+
kind: "newest",
|
|
7960
|
+
run: newest.generation.run,
|
|
7961
|
+
runs
|
|
7883
7962
|
};
|
|
7884
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");
|
|
7885
7966
|
/**
|
|
7886
|
-
*
|
|
7887
|
-
* the
|
|
7888
|
-
*
|
|
7889
|
-
* ownership — a conservative stamp (unreadable or unsupported lockfile sides,
|
|
7890
|
-
* no declared targets, computation doubt) refuses the reduced rung, so a
|
|
7891
|
-
* fail-closed stamp can never coexist with a reduced verification battery
|
|
7892
|
-
* (#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.
|
|
7893
7970
|
*
|
|
7894
|
-
*
|
|
7895
|
-
*
|
|
7896
|
-
*
|
|
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.
|
|
7897
7976
|
*/
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
if (
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
};
|
|
7905
|
-
const affected = stamp.targets.filter((target) => target.impact === "affected");
|
|
7906
|
-
if (affected.length > 0) return {
|
|
7907
|
-
classification: "non-trivial",
|
|
7908
|
-
reasons: [...base.reasons, `impact stamp records ${affected.length} affected target(s); forced non-trivial`]
|
|
7909
|
-
};
|
|
7910
|
-
return base;
|
|
7911
|
-
}
|
|
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
|
+
};
|
|
7912
7983
|
/**
|
|
7913
|
-
*
|
|
7914
|
-
*
|
|
7915
|
-
*
|
|
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.
|
|
7987
|
+
*
|
|
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).
|
|
7916
7996
|
*/
|
|
7917
|
-
|
|
7918
|
-
const relativeProfile = relativeProfilePath(options);
|
|
7919
|
-
const stamp = computeImpactStamp({
|
|
7920
|
-
changedFiles: files,
|
|
7921
|
-
profile,
|
|
7922
|
-
profilePath: relativeProfile,
|
|
7923
|
-
readLockfile: () => void 0
|
|
7924
|
-
});
|
|
7925
|
-
return applyProfileEditGuard(files, relativeProfile, options.profilePath, classifyDiff(files, stamp));
|
|
7926
|
-
}
|
|
7927
|
-
//#endregion
|
|
7928
|
-
//#region src/catch-up-recognition-record.ts
|
|
7929
|
-
const objectShaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
7930
|
-
const catchUpRecognitionSchema = z.object({
|
|
7931
|
-
baseRef: z.string().min(1),
|
|
7932
|
-
baseTipSha: objectShaSchema,
|
|
7933
|
-
mergedParentSha: objectShaSchema,
|
|
7934
|
-
mergedTreeSha: objectShaSchema,
|
|
7935
|
-
upstreamRef: z.string().min(1)
|
|
7936
|
-
}).strict();
|
|
7937
|
-
//#endregion
|
|
7938
|
-
//#region src/policy-resolution.ts
|
|
7939
|
-
const COMMIT_SHA_PATTERN$3 = /^[0-9a-f]{40}$/u;
|
|
7940
|
-
const digestSchema = z.string().regex(/^[0-9a-f]{64}$/u);
|
|
7941
|
-
/**
|
|
7942
|
-
* The authority resolved: the live tip of the PR's own base ref served a
|
|
7943
|
-
* parseable profile, and demand was resolved from the union of that policy and
|
|
7944
|
-
* the candidate's.
|
|
7945
|
-
*
|
|
7946
|
-
* Every identity field is REQUIRED. A cross-stage reader compares these digests
|
|
7947
|
-
* to decide whether two stages judged the same candidate under the same policy;
|
|
7948
|
-
* an `authoritative` record with a digest missing would read to that reader as
|
|
7949
|
-
* "nothing disagrees", which is a silent downgrade of exactly the demand this
|
|
7950
|
-
* record exists to protect. `reason` is forbidden here — a resolved authority
|
|
7951
|
-
* has nothing to explain.
|
|
7952
|
-
*/
|
|
7953
|
-
const authoritativePolicyResolutionSchema = z.object({
|
|
7954
|
-
/** sha256 of the canonical base `review` subtree. */
|
|
7955
|
-
basePolicyDigest: digestSchema,
|
|
7956
|
-
/** The PR's own base ref, whose live tip is the authority. */
|
|
7957
|
-
baseRefName: z.string().min(1),
|
|
7958
|
-
/** sha256 of the canonical candidate `review` subtree. */
|
|
7959
|
-
candidatePolicyDigest: digestSchema,
|
|
7960
|
-
/** sha256 of the merged (base ∪ candidate) protected-path set. */
|
|
7961
|
-
effectiveDigest: digestSchema,
|
|
7962
|
-
/** The base-ref tip the base policy was read at. */
|
|
7963
|
-
policyBaseSha: z.string().regex(COMMIT_SHA_PATTERN$3),
|
|
7964
|
-
/**
|
|
7965
|
-
* Whether the candidate's `review` subtree differs from the base's. The
|
|
7966
|
-
* digests prove which policies were read; this says whether they agreed,
|
|
7967
|
-
* which is the fact that explains a demand the candidate's own profile
|
|
7968
|
-
* would not have produced.
|
|
7969
|
-
*/
|
|
7970
|
-
reviewPolicyChanged: z.boolean(),
|
|
7971
|
-
status: z.literal("authoritative")
|
|
7972
|
-
}).strict();
|
|
7997
|
+
const servedRunConfirmsPublication = (input, createdId, selection) => publishedRunIsServed(input, createdId, selection.run) && unfinishedRuns(selection.runs).length === 0;
|
|
7973
7998
|
/**
|
|
7974
|
-
*
|
|
7975
|
-
*
|
|
7976
|
-
*
|
|
7977
|
-
*
|
|
7978
|
-
* 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.
|
|
7979
8003
|
*/
|
|
7980
|
-
const
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
status: z.literal("unresolved")
|
|
7986
|
-
}).strict();
|
|
7987
|
-
/** How a gate resolved the review policy it evaluated a candidate under. */
|
|
7988
|
-
const policyResolutionSchema = z.discriminatedUnion("status", [authoritativePolicyResolutionSchema, unresolvedPolicyResolutionSchema]);
|
|
7989
|
-
//#endregion
|
|
7990
|
-
//#region src/verification-battery.ts
|
|
7991
|
-
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
|
+
};
|
|
7992
8009
|
/**
|
|
7993
|
-
*
|
|
7994
|
-
*
|
|
7995
|
-
* `new Set(undefined)` is a perfectly good empty set, so an untyped JavaScript
|
|
7996
|
-
* caller that omits it would otherwise scope exactly as if it had inspected
|
|
7997
|
-
* the envelopes and found nothing — the silent state requiring the parameter
|
|
7998
|
-
* exists to eliminate.
|
|
8010
|
+
* Confirm the publication against what GitHub serves, and say plainly which of
|
|
8011
|
+
* the two events failed when it cannot.
|
|
7999
8012
|
*
|
|
8000
|
-
*
|
|
8001
|
-
*
|
|
8002
|
-
*
|
|
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.
|
|
8003
8018
|
*/
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
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
|
+
}
|
|
8007
8040
|
/**
|
|
8008
|
-
*
|
|
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).
|
|
8009
8044
|
*
|
|
8010
|
-
*
|
|
8011
|
-
*
|
|
8012
|
-
*
|
|
8013
|
-
*
|
|
8014
|
-
*
|
|
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`.
|
|
8015
8053
|
*
|
|
8016
|
-
*
|
|
8017
|
-
* the
|
|
8018
|
-
*
|
|
8019
|
-
*
|
|
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.
|
|
8020
8075
|
*/
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
const
|
|
8024
|
-
const
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
disposition: scoped ? "not-required" : "executed",
|
|
8049
|
-
impactTarget,
|
|
8050
|
-
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
|
|
8051
8103
|
});
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8104
|
+
return await confirmPublishedCheckRun({
|
|
8105
|
+
config,
|
|
8106
|
+
createdId: typeof created.id === "number" ? created.id : void 0,
|
|
8107
|
+
dependencies: tokenDependencies,
|
|
8108
|
+
input,
|
|
8109
|
+
onDiagnostic
|
|
8056
8110
|
});
|
|
8057
|
-
|
|
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;
|
|
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;
|
|
8244
|
+
return {
|
|
8245
|
+
completedAtMs: generationOrderMs(run.completed_at),
|
|
8246
|
+
conclusion: run.conclusion ?? "",
|
|
8247
|
+
payload,
|
|
8248
|
+
producer
|
|
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;
|
|
8058
8264
|
}
|
|
8059
|
-
return {
|
|
8060
|
-
dispositions,
|
|
8061
|
-
execute,
|
|
8062
|
-
notRequired
|
|
8063
|
-
};
|
|
8064
8265
|
};
|
|
8065
8266
|
/**
|
|
8066
|
-
* The
|
|
8067
|
-
*
|
|
8068
|
-
*
|
|
8069
|
-
* Four rules:
|
|
8070
|
-
* 1. A withheld command names one the resolved mode actually selected.
|
|
8071
|
-
* 2. A command is never recorded as both executed and not-required.
|
|
8072
|
-
* 3. Withheld commands are distinct — a duplicated disposition would let one
|
|
8073
|
-
* name carry two different bases.
|
|
8074
|
-
* 4. On a PASSED proof, every selected command has a disposition: silence is
|
|
8075
|
-
* not a disposition, and "absent from both lists" is exactly the silent
|
|
8076
|
-
* skip this epic forbids.
|
|
8077
|
-
*
|
|
8078
|
-
* Rule 4 is scoped to `outcome: "passed"` because an ABORTED proof legitimately
|
|
8079
|
-
* records partial execution — the run stopped at the failing command.
|
|
8080
|
-
*
|
|
8081
|
-
* The check is one-directional on purpose. Every SELECTED command must be
|
|
8082
|
-
* accounted for, but `executedCommands` may legitimately carry MORE than the
|
|
8083
|
-
* profile selected — full mode appends the `workspace:install-resolves` probe,
|
|
8084
|
-
* 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.
|
|
8085
8270
|
*/
|
|
8086
|
-
const
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
const seen = /* @__PURE__ */ new Set();
|
|
8092
|
-
for (const [index, entry] of notRequired.entries()) {
|
|
8093
|
-
if (!selectedSet.has(entry.name)) context.addIssue({
|
|
8094
|
-
code: "custom",
|
|
8095
|
-
message: `notRequiredCommands entry "${entry.name}" is not one of this proof's verificationCommands.`,
|
|
8096
|
-
path: [
|
|
8097
|
-
"notRequiredCommands",
|
|
8098
|
-
index,
|
|
8099
|
-
"name"
|
|
8100
|
-
]
|
|
8101
|
-
});
|
|
8102
|
-
if (executed.has(entry.name)) context.addIssue({
|
|
8103
|
-
code: "custom",
|
|
8104
|
-
message: `Command "${entry.name}" is recorded both as executed and as not-required.`,
|
|
8105
|
-
path: [
|
|
8106
|
-
"notRequiredCommands",
|
|
8107
|
-
index,
|
|
8108
|
-
"name"
|
|
8109
|
-
]
|
|
8110
|
-
});
|
|
8111
|
-
if (seen.has(entry.name)) context.addIssue({
|
|
8112
|
-
code: "custom",
|
|
8113
|
-
message: `notRequiredCommands records "${entry.name}" more than once.`,
|
|
8114
|
-
path: [
|
|
8115
|
-
"notRequiredCommands",
|
|
8116
|
-
index,
|
|
8117
|
-
"name"
|
|
8118
|
-
]
|
|
8119
|
-
});
|
|
8120
|
-
seen.add(entry.name);
|
|
8271
|
+
const repositoryRootOrCwd = ({ cwd, readRepositoryRoot = repositoryRoot }) => {
|
|
8272
|
+
try {
|
|
8273
|
+
return readRepositoryRoot(cwd);
|
|
8274
|
+
} catch {
|
|
8275
|
+
return cwd;
|
|
8121
8276
|
}
|
|
8122
|
-
if (proof.outcome !== "passed") return;
|
|
8123
|
-
const disposed = new Set([...executed, ...seen]);
|
|
8124
|
-
const undisposed = selected.filter((name) => !disposed.has(name));
|
|
8125
|
-
if (undisposed.length > 0) context.addIssue({
|
|
8126
|
-
code: "custom",
|
|
8127
|
-
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.`,
|
|
8128
|
-
path: ["executedCommands"]
|
|
8129
|
-
});
|
|
8130
8277
|
};
|
|
8131
8278
|
/**
|
|
8132
|
-
* The
|
|
8133
|
-
* was NOT REQUIRED if its own recorded stamp says so.
|
|
8134
|
-
*
|
|
8135
|
-
* `assertBatteryCompleteness` proves the two lists partition the selected
|
|
8136
|
-
* commands — that no command is silently absent. It does not prove the
|
|
8137
|
-
* withholding was EARNED. Without this rule a proof could name every expensive
|
|
8138
|
-
* command in `notRequiredCommands`, carry no stamp at all (or a conservative
|
|
8139
|
-
* one), and still read as full verification: the omission would be recorded,
|
|
8140
|
-
* accounted for, and completely unauthorized.
|
|
8141
|
-
*
|
|
8142
|
-
* Two things have to hold, and the first is what keeps the second honest:
|
|
8143
|
-
*
|
|
8144
|
-
* 1. COHERENCE. The entry's `impactTarget` must equal the `impactTarget` the
|
|
8145
|
-
* proof records for that same command in `verificationCommands`. The entry
|
|
8146
|
-
* does not get to nominate its own target; the proof's command→target
|
|
8147
|
-
* mapping (written by `pr:verify` from the loaded profile) does. Without
|
|
8148
|
-
* this, authorization validates a self-reported field and a crafted proof
|
|
8149
|
-
* can withhold an affected command while pointing at an unrelated released
|
|
8150
|
-
* target.
|
|
8151
|
-
* 2. AUTHORIZATION. That recorded target must be one the proof's own stamp
|
|
8152
|
-
* provably released.
|
|
8279
|
+
* The repository-relative, POSIX-separated path of `profilePath`.
|
|
8153
8280
|
*
|
|
8154
|
-
*
|
|
8155
|
-
*
|
|
8156
|
-
*
|
|
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.
|
|
8157
8301
|
*
|
|
8158
|
-
*
|
|
8159
|
-
*
|
|
8160
|
-
*
|
|
8161
|
-
*
|
|
8162
|
-
*
|
|
8163
|
-
* have released that target's command in the first place (target-scoped
|
|
8164
|
-
* basis, this build's stamp version, exactly one row for the target,
|
|
8165
|
-
* `not-affected`). A conservative stamp, an unknown version, a
|
|
8166
|
-
* self-contradictory stamp, an unclassified name, or an `affected` verdict all
|
|
8167
|
-
* 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).
|
|
8168
8307
|
*
|
|
8169
|
-
*
|
|
8170
|
-
*
|
|
8171
|
-
*
|
|
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.
|
|
8172
8312
|
*/
|
|
8173
|
-
const
|
|
8174
|
-
const
|
|
8175
|
-
|
|
8176
|
-
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
8181
|
-
path: ["impactStamp"]
|
|
8182
|
-
});
|
|
8183
|
-
return;
|
|
8184
|
-
}
|
|
8185
|
-
const recordedTargets = new Map(proof.verificationCommands.map((command) => [command.name, command.impactTarget]));
|
|
8186
|
-
for (const [index, entry] of notRequired.entries()) {
|
|
8187
|
-
const recorded = recordedTargets.get(entry.name);
|
|
8188
|
-
if (recorded !== entry.impactTarget) {
|
|
8189
|
-
context.addIssue({
|
|
8190
|
-
code: "custom",
|
|
8191
|
-
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.`,
|
|
8192
|
-
path: [
|
|
8193
|
-
"notRequiredCommands",
|
|
8194
|
-
index,
|
|
8195
|
-
"impactTarget"
|
|
8196
|
-
]
|
|
8197
|
-
});
|
|
8198
|
-
continue;
|
|
8199
|
-
}
|
|
8200
|
-
const decision = impactStampScopeDecision({
|
|
8201
|
-
stamp: impactStamp,
|
|
8202
|
-
surface: "verification-battery",
|
|
8203
|
-
targetName: entry.impactTarget
|
|
8204
|
-
});
|
|
8205
|
-
if (!decision.scoped) context.addIssue({
|
|
8206
|
-
code: "custom",
|
|
8207
|
-
message: `notRequiredCommands entry "${entry.name}" is not authorized by this proof's impactStamp: ${decision.reason}.`,
|
|
8208
|
-
path: [
|
|
8209
|
-
"notRequiredCommands",
|
|
8210
|
-
index,
|
|
8211
|
-
"impactTarget"
|
|
8212
|
-
]
|
|
8213
|
-
});
|
|
8214
|
-
}
|
|
8215
|
-
};
|
|
8216
|
-
/** Console lines naming every withheld command and why. Never silent. */
|
|
8217
|
-
const batteryScopeSummaryLines = (plan) => {
|
|
8218
|
-
if (plan.notRequired.length === 0) return [];
|
|
8219
|
-
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;
|
|
8220
8321
|
};
|
|
8221
8322
|
//#endregion
|
|
8222
|
-
//#region src/pr-
|
|
8223
|
-
const
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
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
|
|
8228
8351
|
});
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
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
|
+
}
|
|
8360
|
+
/**
|
|
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}.
|
|
8364
|
+
*/
|
|
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
|
+
}
|
|
8232
8389
|
/**
|
|
8233
|
-
* The pr:verify
|
|
8234
|
-
*
|
|
8235
|
-
*
|
|
8236
|
-
*
|
|
8237
|
-
*
|
|
8238
|
-
*
|
|
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.
|
|
8239
8401
|
*/
|
|
8240
|
-
|
|
8241
|
-
const
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
"
|
|
8245
|
-
"
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
|
|
8268
|
-
impactTarget: z.string().min(1),
|
|
8269
|
-
name: z.string().min(1)
|
|
8270
|
-
});
|
|
8271
|
-
const notDemandedRecordSchema = z.object({
|
|
8272
|
-
basis: z.string().min(1),
|
|
8273
|
-
name: z.string().min(1)
|
|
8274
|
-
});
|
|
8275
|
-
const prVerifySchemaVersionSchema = z.literal(4);
|
|
8276
|
-
const prVerifyProofSchema = z.object({
|
|
8277
|
-
authoringSession: z.string().trim().min(1),
|
|
8278
|
-
base: z.string().min(1),
|
|
8279
|
-
baseSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
8280
|
-
baselineFullProofs: z.array(z.object({
|
|
8281
|
-
base: z.string().min(1),
|
|
8282
|
-
changedFiles: z.array(z.string()),
|
|
8283
|
-
headSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
8284
|
-
profilePath: z.string().min(1),
|
|
8285
|
-
projectKey: z.string().min(1),
|
|
8286
|
-
repository: z.string().min(1)
|
|
8287
|
-
})).optional(),
|
|
8288
|
-
catchUpRecognition: catchUpRecognitionSchema.optional(),
|
|
8289
|
-
changedFiles: z.array(z.string()),
|
|
8290
|
-
classification: z.enum([
|
|
8291
|
-
"docs/process-only",
|
|
8292
|
-
"trivial",
|
|
8293
|
-
"non-trivial"
|
|
8294
|
-
]),
|
|
8295
|
-
classificationReasons: z.array(z.string()),
|
|
8296
|
-
command: z.literal("patronage-factory pr:verify"),
|
|
8297
|
-
durationMs: z.number().nonnegative(),
|
|
8298
|
-
endedAt: z.iso.datetime(),
|
|
8299
|
-
executedCommands: z.array(executedCommandSchema).optional(),
|
|
8300
|
-
headSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
8301
|
-
impactStamp: impactStampSchema.optional(),
|
|
8302
|
-
mergeBaseSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
8303
|
-
mode: z.enum([
|
|
8304
|
-
"docs-only",
|
|
8305
|
-
"trivial",
|
|
8306
|
-
"full"
|
|
8307
|
-
]),
|
|
8308
|
-
notDemandedRecords: z.array(notDemandedRecordSchema).min(1).optional(),
|
|
8309
|
-
notRequiredCommands: z.array(notRequiredCommandSchema).min(1).optional(),
|
|
8310
|
-
outcome: z.enum(["aborted", "passed"]).optional(),
|
|
8311
|
-
patchId: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
8312
|
-
policyResolution: policyResolutionSchema.optional(),
|
|
8313
|
-
profilePath: z.string().min(1),
|
|
8314
|
-
projectKey: z.string().min(1),
|
|
8315
|
-
repository: z.string().min(1),
|
|
8316
|
-
schemaVersion: prVerifySchemaVersionSchema,
|
|
8317
|
-
startedAt: z.iso.datetime(),
|
|
8318
|
-
verificationCommands: z.array(z.object({
|
|
8319
|
-
command: z.string().min(1),
|
|
8320
|
-
description: z.string().min(1),
|
|
8321
|
-
impactTarget: z.string().min(1).optional(),
|
|
8322
|
-
name: z.string().min(1),
|
|
8323
|
-
scope: z.enum([
|
|
8324
|
-
"docs-only",
|
|
8325
|
-
"trivial",
|
|
8326
|
-
"full"
|
|
8327
|
-
])
|
|
8328
|
-
}))
|
|
8329
|
-
}).superRefine(assertPrVerifyProofRules);
|
|
8330
|
-
function validatePrVerifyProof(value) {
|
|
8331
|
-
return prVerifyProofSchema.parse(value);
|
|
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));
|
|
8332
8430
|
}
|
|
8333
8431
|
//#endregion
|
|
8334
8432
|
//#region src/pr-readiness/verification-proof.ts
|
|
@@ -9103,7 +9201,6 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
9103
9201
|
}) : void 0;
|
|
9104
9202
|
const selectedProfileCommands = profile.verification.commands.filter((command) => commandAppliesToMode(command, resolvedMode));
|
|
9105
9203
|
const commands = commandsForMode(profile, resolvedMode);
|
|
9106
|
-
if (args.requireKnownAuthoringSession && authoringSession === "unknown") throw new Error("A known authoring session is required before verification can run.");
|
|
9107
9204
|
if (commands.length === 0) throw new Error(`No verification commands configured for ${resolvedMode}.`);
|
|
9108
9205
|
console.log(`pr:verify mode: ${resolvedMode}${args.mode === "auto" ? " (auto)" : ""}`);
|
|
9109
9206
|
console.log(`classification: ${classification.classification}`);
|
|
@@ -9128,6 +9225,34 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
9128
9225
|
const scopedOutNames = new Set(notRequiredCommands.map(({ name }) => name));
|
|
9129
9226
|
for (const line of batteryScopeSummaryLines(battery)) console.log(line);
|
|
9130
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.");
|
|
9131
9256
|
let gateState = "failure";
|
|
9132
9257
|
const { env: verificationEnv, cleanup } = context.buildEnv();
|
|
9133
9258
|
const { canonical: output, additional: additionalOutput } = resolveProofOutputPaths(cwd, DEFAULT_PR_VERIFY_PROOF_PATH, args.output);
|
|
@@ -9188,6 +9313,11 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
9188
9313
|
});
|
|
9189
9314
|
};
|
|
9190
9315
|
try {
|
|
9316
|
+
assertCleanWorktreeForProof({
|
|
9317
|
+
cwd,
|
|
9318
|
+
dirtyMessage: "pr:verify requires committed input; the worktree is dirty. Commit changes or use a clean isolated checkout before verification.",
|
|
9319
|
+
statusPorcelain: git.statusPorcelain
|
|
9320
|
+
});
|
|
9191
9321
|
for (const command of commands) {
|
|
9192
9322
|
if (scopedOutNames.has(command.name)) continue;
|
|
9193
9323
|
console.log(`\n> ${command.name}: ${command.command}`);
|
|
@@ -9236,11 +9366,11 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
9236
9366
|
}
|
|
9237
9367
|
}
|
|
9238
9368
|
assertCleanWorktreeForProof({
|
|
9239
|
-
changedFiles: files,
|
|
9240
9369
|
cwd,
|
|
9241
9370
|
dirtyMessage: "pr:verify passed, but the worktree is dirty. Commit or stash changes before writing typed proof.",
|
|
9242
9371
|
statusPorcelain: git.statusPorcelain
|
|
9243
9372
|
});
|
|
9373
|
+
if (git.currentHeadSha(cwd) !== headSha) throw new Error("pr:verify candidate HEAD changed during verification. Re-run verification on the final committed head.");
|
|
9244
9374
|
const previousProof = readPreviousPrVerifyProof(cwd, output);
|
|
9245
9375
|
const baselineFullProofs = resolvedMode === "docs-only" ? baselineFullProofsForDocsOnly({
|
|
9246
9376
|
previousProof,
|
|
@@ -15708,7 +15838,7 @@ const renderPrBodySections = ({ reviewProof, verifyProof }) => `${renderPrBodySe
|
|
|
15708
15838
|
//#endregion
|
|
15709
15839
|
//#region src/pr-readiness/status-check-rollup.ts
|
|
15710
15840
|
var status_check_rollup_exports = /* @__PURE__ */ __exportAll({
|
|
15711
|
-
HOSTED_VERIFY_CHECK_NAME: () => HOSTED_VERIFY_CHECK_NAME
|
|
15841
|
+
HOSTED_VERIFY_CHECK_NAME: () => HOSTED_VERIFY_CHECK_NAME,
|
|
15712
15842
|
HOSTED_VERIFY_DRAFT_CHECK_NAME: () => HOSTED_VERIFY_DRAFT_CHECK_NAME,
|
|
15713
15843
|
hostedVerifyCheckState: () => hostedVerifyCheckState,
|
|
15714
15844
|
isDraftHostedVerifyCheck: () => isDraftHostedVerifyCheck,
|
|
@@ -15744,7 +15874,7 @@ const isFactoryReadyCheck = (check) => check.context?.startsWith("patronage-fact
|
|
|
15744
15874
|
* name across the fleet because one generator emits the workflow that posts
|
|
15745
15875
|
* it (ADR 0016).
|
|
15746
15876
|
*/
|
|
15747
|
-
const HOSTED_VERIFY_CHECK_NAME
|
|
15877
|
+
const HOSTED_VERIFY_CHECK_NAME = "verify";
|
|
15748
15878
|
/**
|
|
15749
15879
|
* Is this rollup entry the hosted `verify` gate, from the producer the
|
|
15750
15880
|
* ruleset pins it to?
|
|
@@ -15765,7 +15895,7 @@ const isHostedVerifyCheck = (check) => check.name === "verify" && Boolean(check.
|
|
|
15765
15895
|
* the literal; this is the reader's copy of it, and
|
|
15766
15896
|
* `software-factory-hq/alchemy/verify-workflow.test.ts` pins the two equal.
|
|
15767
15897
|
*/
|
|
15768
|
-
const HOSTED_VERIFY_DRAFT_CHECK_NAME = `${HOSTED_VERIFY_CHECK_NAME
|
|
15898
|
+
const HOSTED_VERIFY_DRAFT_CHECK_NAME = `${HOSTED_VERIFY_CHECK_NAME} (draft)`;
|
|
15769
15899
|
/**
|
|
15770
15900
|
* Is this the draft-head summary run's residue?
|
|
15771
15901
|
*
|
|
@@ -15991,13 +16121,22 @@ function armAutoMerge(input, dependencies = {}) {
|
|
|
15991
16121
|
//#region src/merge-freeze.ts
|
|
15992
16122
|
const MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
|
|
15993
16123
|
const MERGE_FREEZE_APP_SLUG = "patronage-factory";
|
|
15994
|
-
const HOSTED_VERIFY_CHECK_NAME = "verify";
|
|
15995
|
-
const GITHUB_ACTIONS_APP_ID$1 = 15368;
|
|
15996
|
-
const GITHUB_ACTIONS_APP_SLUG = "github-actions";
|
|
15997
16124
|
const MERGE_FREEZE_SCHEMA_VERSION = 1;
|
|
15998
16125
|
const CHECK_RUNS_PER_PAGE$1 = 100;
|
|
15999
16126
|
const CHECK_RUN_PAGE_LIMIT$1 = 10;
|
|
16127
|
+
const WORKFLOW_RUNS_PER_PAGE = 100;
|
|
16128
|
+
const WORKFLOW_RUN_PAGE_LIMIT = 10;
|
|
16000
16129
|
const shaSchema$2 = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
16130
|
+
const verificationSchema = z.object({
|
|
16131
|
+
jobs: z.record(z.string(), z.enum([
|
|
16132
|
+
"success",
|
|
16133
|
+
"failure",
|
|
16134
|
+
"cancelled",
|
|
16135
|
+
"skipped"
|
|
16136
|
+
])),
|
|
16137
|
+
runAttempt: z.number().int().positive(),
|
|
16138
|
+
runId: z.number().int().positive()
|
|
16139
|
+
});
|
|
16001
16140
|
const activeMergeFreezeStateSchema = z.object({
|
|
16002
16141
|
active: z.literal(true),
|
|
16003
16142
|
generationId: z.number().int().positive(),
|
|
@@ -16005,7 +16144,8 @@ const activeMergeFreezeStateSchema = z.object({
|
|
|
16005
16144
|
outcome: z.enum(["active", "stale"]),
|
|
16006
16145
|
reason: z.string().min(1),
|
|
16007
16146
|
recordedAt: z.iso.datetime(),
|
|
16008
|
-
schemaVersion: z.literal(MERGE_FREEZE_SCHEMA_VERSION)
|
|
16147
|
+
schemaVersion: z.literal(MERGE_FREEZE_SCHEMA_VERSION),
|
|
16148
|
+
verification: verificationSchema.optional()
|
|
16009
16149
|
});
|
|
16010
16150
|
const inactiveMergeFreezeStateSchema = z.object({
|
|
16011
16151
|
active: z.literal(false),
|
|
@@ -16015,7 +16155,8 @@ const inactiveMergeFreezeStateSchema = z.object({
|
|
|
16015
16155
|
outcome: z.literal("inactive"),
|
|
16016
16156
|
reason: z.string().min(1),
|
|
16017
16157
|
recordedAt: z.iso.datetime(),
|
|
16018
|
-
schemaVersion: z.literal(MERGE_FREEZE_SCHEMA_VERSION)
|
|
16158
|
+
schemaVersion: z.literal(MERGE_FREEZE_SCHEMA_VERSION),
|
|
16159
|
+
verification: verificationSchema.optional()
|
|
16019
16160
|
});
|
|
16020
16161
|
const mergeFreezeStateSchema = z.discriminatedUnion("active", [activeMergeFreezeStateSchema, inactiveMergeFreezeStateSchema]);
|
|
16021
16162
|
/**
|
|
@@ -16055,19 +16196,23 @@ const completedMergeFreezeCheckRunSchema = mergeFreezeCheckRunListItemSchema.ext
|
|
|
16055
16196
|
output: z.object({ text: z.string().min(1) }),
|
|
16056
16197
|
status: z.literal("completed")
|
|
16057
16198
|
});
|
|
16058
|
-
const
|
|
16059
|
-
app: z.object({
|
|
16060
|
-
id: z.literal(GITHUB_ACTIONS_APP_ID$1),
|
|
16061
|
-
slug: z.literal(GITHUB_ACTIONS_APP_SLUG)
|
|
16062
|
-
}),
|
|
16063
|
-
head_sha: shaSchema$2,
|
|
16199
|
+
const workflowRunIdentitySchema = z.object({
|
|
16064
16200
|
id: z.number().int().positive(),
|
|
16065
|
-
|
|
16066
|
-
|
|
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(),
|
|
16067
16209
|
status: z.enum([
|
|
16068
16210
|
"completed",
|
|
16069
16211
|
"in_progress",
|
|
16070
|
-
"
|
|
16212
|
+
"pending",
|
|
16213
|
+
"queued",
|
|
16214
|
+
"requested",
|
|
16215
|
+
"waiting"
|
|
16071
16216
|
])
|
|
16072
16217
|
});
|
|
16073
16218
|
const commitParentsSchema = z.object({
|
|
@@ -16130,40 +16275,48 @@ function selectMissingMergeFreezeGenerationUnchecked(api, input) {
|
|
|
16130
16275
|
name: MERGE_FREEZE_CHECK_NAME
|
|
16131
16276
|
}), parentSha);
|
|
16132
16277
|
if (parentGeneration.kind !== "settled") return unconfiguredMergeFreeze(input, `the immediate parent ${parentSha} has no settled App-owned freeze generation (${parentGeneration.kind}).`);
|
|
16133
|
-
|
|
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({
|
|
16134
16282
|
...input,
|
|
16135
|
-
|
|
16136
|
-
}))
|
|
16137
|
-
|
|
16138
|
-
|
|
16139
|
-
const
|
|
16140
|
-
|
|
16141
|
-
|
|
16142
|
-
|
|
16143
|
-
|
|
16144
|
-
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.`);
|
|
16145
|
-
const selectedVerify = pendingVerifyRuns.length === 1 ? {
|
|
16146
|
-
generation: { run: pendingVerifyRuns[0] },
|
|
16147
|
-
kind: "selected"
|
|
16148
|
-
} : selectNewestGeneration(orderedVerifyRuns);
|
|
16149
|
-
if (selectedVerify.kind === "ambiguous") return unconfiguredMergeFreeze(input, `the newest source-pinned ${HOSTED_VERIFY_CHECK_NAME} generation is ambiguous.`);
|
|
16150
|
-
if (selectedVerify.kind === "none") return unconfiguredMergeFreeze(input, `the current tip has no source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run.`);
|
|
16151
|
-
const verify = selectedVerify.generation.run;
|
|
16152
|
-
if (verify.head_sha !== input.headSha) return unconfiguredMergeFreeze(input, `the newest source-pinned ${HOSTED_VERIFY_CHECK_NAME} run targets ${verify.head_sha}.`);
|
|
16153
|
-
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({
|
|
16154
16292
|
...input,
|
|
16155
16293
|
name: MERGE_FREEZE_CHECK_NAME
|
|
16156
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();
|
|
16157
16309
|
if (refreshed.kind !== "missing") return refreshed;
|
|
16158
16310
|
return {
|
|
16159
16311
|
kind: "settling",
|
|
16160
16312
|
phase: "awaiting-generation",
|
|
16161
|
-
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}.`,
|
|
16162
16314
|
witness: {
|
|
16163
16315
|
headSha: input.headSha,
|
|
16164
16316
|
parentSha,
|
|
16165
|
-
|
|
16166
|
-
|
|
16317
|
+
workflowId,
|
|
16318
|
+
workflowRunId: run.id,
|
|
16319
|
+
workflowRunStatus: run.status
|
|
16167
16320
|
}
|
|
16168
16321
|
};
|
|
16169
16322
|
}
|
|
@@ -16171,7 +16324,7 @@ function selectMissingMergeFreezeGeneration(api, input) {
|
|
|
16171
16324
|
try {
|
|
16172
16325
|
return selectMissingMergeFreezeGenerationUnchecked(api, input);
|
|
16173
16326
|
} catch (error) {
|
|
16174
|
-
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)}.`);
|
|
16175
16328
|
}
|
|
16176
16329
|
}
|
|
16177
16330
|
function selectMergeFreezeGeneration(api, input) {
|
|
@@ -16216,6 +16369,19 @@ function createGitHubCheckRunMergeFreezeApi(dependencies = {}) {
|
|
|
16216
16369
|
},
|
|
16217
16370
|
parent({ cwd, headSha, repository }) {
|
|
16218
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 };
|
|
16219
16385
|
}
|
|
16220
16386
|
};
|
|
16221
16387
|
}
|
|
@@ -16259,12 +16425,12 @@ const SETTLING_MERGE_FREEZE_TAIL = "readiness proceeds (ADR 0016). If this wave
|
|
|
16259
16425
|
*
|
|
16260
16426
|
* The two phases permit readiness for different reasons, so they say different
|
|
16261
16427
|
* things. A running generation is the writer already at work on this tip. An
|
|
16262
|
-
* awaited one is the writer proven to run — a settled parent — plus
|
|
16263
|
-
*
|
|
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.
|
|
16264
16430
|
*/
|
|
16265
16431
|
const settlingMergeFreezeNotice = (headSha, generation) => {
|
|
16266
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}`;
|
|
16267
|
-
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}`;
|
|
16268
16434
|
};
|
|
16269
16435
|
/**
|
|
16270
16436
|
* The freeze read during readiness, before any authorized arming (#477,
|
|
@@ -16276,8 +16442,9 @@ const settlingMergeFreezeNotice = (headSha, generation) => {
|
|
|
16276
16442
|
* **settling** generation permits readiness and any authorized arming with a
|
|
16277
16443
|
* notice only when the writer is observable, and each phase proves that by its
|
|
16278
16444
|
* own rule (#890): `running-generation` shows the generation itself in flight,
|
|
16279
|
-
* while `awaiting-generation` shows a settled parent tip plus a pending
|
|
16280
|
-
*
|
|
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
|
|
16281
16448
|
* deliberately trades the settle-window wait against the measured rarity of a
|
|
16282
16449
|
* red merge target. A writerless or unprovable repository fails closed.
|
|
16283
16450
|
*/
|
|
@@ -16839,7 +17006,7 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
16839
17006
|
const awaitPostUndraftChecks = awaitPostUndraftChecksRepair(input);
|
|
16840
17007
|
if ((input.hostedVerifyCheck ?? "none") === "none") blockers.push({
|
|
16841
17008
|
demand: DEMAND_KEYS.githubChecks,
|
|
16842
|
-
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.`,
|
|
16843
17010
|
...awaitPostUndraftChecks
|
|
16844
17011
|
});
|
|
16845
17012
|
if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({
|
|
@@ -19726,7 +19893,6 @@ async function runPrReview(args, dependencies = {}) {
|
|
|
19726
19893
|
});
|
|
19727
19894
|
const files = git.changedFiles(cwd, base.sha);
|
|
19728
19895
|
assertCleanWorktreeForProof({
|
|
19729
|
-
changedFiles: files,
|
|
19730
19896
|
cwd,
|
|
19731
19897
|
dirtyMessage: "pr:review requires a clean worktree so proof matches committed PR content.",
|
|
19732
19898
|
statusPorcelain: git.statusPorcelain
|
|
@@ -20308,7 +20474,7 @@ async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (me
|
|
|
20308
20474
|
status: "timed-out",
|
|
20309
20475
|
verifyState
|
|
20310
20476
|
};
|
|
20311
|
-
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`);
|
|
20312
20478
|
await sleep(PUBLISH_HOSTED_CHECKS_WAIT_MS);
|
|
20313
20479
|
}
|
|
20314
20480
|
}
|
|
@@ -20765,7 +20931,7 @@ function createPrReviewCommand(output, action) {
|
|
|
20765
20931
|
//#endregion
|
|
20766
20932
|
//#region src/commands/pr-verify.ts
|
|
20767
20933
|
function createPrVerifyCommand(output, action = runPrVerify) {
|
|
20768
|
-
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({
|
|
20769
20935
|
gate: "pr:verify",
|
|
20770
20936
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
20771
20937
|
stderr: output.stderr
|
|
@@ -20780,7 +20946,8 @@ function createPrVerifyCommand(output, action = runPrVerify) {
|
|
|
20780
20946
|
mode: modeFor(options),
|
|
20781
20947
|
output: options.output,
|
|
20782
20948
|
profilePath: options.profile,
|
|
20783
|
-
requireKnownAuthoringSession: true
|
|
20949
|
+
requireKnownAuthoringSession: true,
|
|
20950
|
+
reuseAccepted: options.reuseAccepted
|
|
20784
20951
|
}, dependencies);
|
|
20785
20952
|
if (options.json) output.stdout.write(`${JSON.stringify(proof, null, 2)}\n`);
|
|
20786
20953
|
})));
|
|
@@ -20792,6 +20959,7 @@ function modeFor(options) {
|
|
|
20792
20959
|
}
|
|
20793
20960
|
/** The GitHub deployment `task` that names receipts this tool wrote. */
|
|
20794
20961
|
const DEPLOYMENT_RECEIPT_TASK = "patronage-factory/deployment";
|
|
20962
|
+
const PREVIEW_DEPLOYMENT_RECEIPT_TASK = "patronage-factory/preview-deployment";
|
|
20795
20963
|
/** The `performed_via_github_app.id` of GitHub Actions itself. */
|
|
20796
20964
|
const GITHUB_ACTIONS_APP_ID = 15368;
|
|
20797
20965
|
const RECEIPT_PAGE_SIZE = 100;
|
|
@@ -20839,7 +21007,12 @@ const workflowRunIdentityFromEnv = (env) => {
|
|
|
20839
21007
|
} };
|
|
20840
21008
|
};
|
|
20841
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
|
+
});
|
|
20842
21014
|
const receiptPayloadSchema = z.object({
|
|
21015
|
+
preview: previewScopeSchema.optional(),
|
|
20843
21016
|
repository: z.string().regex(REPOSITORY_PATTERN),
|
|
20844
21017
|
schemaVersion: z.literal(1),
|
|
20845
21018
|
sourceSha: z.string().regex(COMMIT_SHA_PATTERN$1),
|
|
@@ -20864,6 +21037,7 @@ const deploymentRecordSchema = z.object({
|
|
|
20864
21037
|
original_environment: z.string(),
|
|
20865
21038
|
payload: z.unknown(),
|
|
20866
21039
|
performed_via_github_app: z.object({ id: z.number().int() }).nullable().optional(),
|
|
21040
|
+
production_environment: z.boolean().optional(),
|
|
20867
21041
|
sha: z.string(),
|
|
20868
21042
|
task: z.string()
|
|
20869
21043
|
});
|
|
@@ -20872,20 +21046,31 @@ const workflowRunJobSchema = z.object({
|
|
|
20872
21046
|
head_sha: z.string(),
|
|
20873
21047
|
id: z.number().int(),
|
|
20874
21048
|
name: z.string(),
|
|
21049
|
+
run_attempt: z.number().int().positive(),
|
|
21050
|
+
run_id: z.number().int().positive(),
|
|
20875
21051
|
steps: z.array(z.object({
|
|
20876
21052
|
conclusion: z.string().nullable(),
|
|
20877
21053
|
name: z.string()
|
|
20878
21054
|
})).optional()
|
|
20879
21055
|
});
|
|
20880
21056
|
const workflowRunRecordSchema = z.object({
|
|
21057
|
+
event: z.string().optional(),
|
|
20881
21058
|
head_branch: z.string().nullable(),
|
|
20882
21059
|
head_sha: z.string(),
|
|
20883
21060
|
id: z.number().int(),
|
|
20884
21061
|
path: z.string(),
|
|
20885
|
-
|
|
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(),
|
|
21069
|
+
repository: z.object({ full_name: z.string() }),
|
|
21070
|
+
run_attempt: z.number().int().positive()
|
|
20886
21071
|
});
|
|
20887
21072
|
/** The deployment environment one target's receipts live in. */
|
|
20888
|
-
const deploymentReceiptEnvironment = (target) => target;
|
|
21073
|
+
const deploymentReceiptEnvironment = (target, preview) => preview ? `${target}:pr-${preview.pr}` : target;
|
|
20889
21074
|
const runUrl = (identity, runId) => `${identity.serverUrl}/${identity.repository}/actions/runs/${runId}`;
|
|
20890
21075
|
/** The `$GITHUB_OUTPUT` key a success publication writes the new deployment id under. */
|
|
20891
21076
|
const RECEIPT_DEPLOYMENT_ID_OUTPUT = "deployment_id";
|
|
@@ -20904,18 +21089,24 @@ const boundReceiptStepName = (deploymentId) => `Bind deployment receipt ${deploy
|
|
|
20904
21089
|
* GitHub Actions, written by this tool for this repository, target, stage,
|
|
20905
21090
|
* source, workflow, and ref.
|
|
20906
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
|
+
};
|
|
20907
21096
|
const bindReceiptPayload = (args, deployment) => {
|
|
20908
21097
|
const label = `deployment ${deployment.id}`;
|
|
20909
21098
|
if (deployment.performed_via_github_app?.id !== GITHUB_ACTIONS_APP_ID) return { reason: `${label} was not performed by GitHub Actions` };
|
|
20910
|
-
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` };
|
|
20911
21100
|
const payload = receiptPayloadSchema.safeParse(deployment.payload);
|
|
20912
21101
|
if (!payload.success) return { reason: `${label} payload is not a schema-version-1 factory receipt` };
|
|
20913
21102
|
const receipt = payload.data;
|
|
21103
|
+
const previewReason = previewPayloadRefusal(args, deployment, receipt);
|
|
21104
|
+
if (previewReason) return { reason: `${label} ${previewReason}` };
|
|
20914
21105
|
if (receipt.repository !== args.identity.repository) return { reason: `${label} names another repository` };
|
|
20915
21106
|
if (receipt.target !== args.target || receipt.stage !== args.stage) return { reason: `${label} names another target or stage` };
|
|
20916
21107
|
if (receipt.sourceSha !== deployment.sha.toLowerCase()) return { reason: `${label} payload source ${receipt.sourceSha} differs from its deployment sha` };
|
|
20917
|
-
if (receipt.workflow.path !== args.identity.workflowPath) return { reason: `${label} was written by another workflow file, not ${args.identity.workflowPath}` };
|
|
20918
|
-
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}` };
|
|
20919
21110
|
return { receipt };
|
|
20920
21111
|
};
|
|
20921
21112
|
/**
|
|
@@ -20924,27 +21115,76 @@ const bindReceiptPayload = (args, deployment) => {
|
|
|
20924
21115
|
* must have bound this record's id. The first failure names the receipt
|
|
20925
21116
|
* untrusted. Statuses are not read: they are caller-written.
|
|
20926
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
|
+
};
|
|
20927
21157
|
const trustReceipt = (args, deployment) => {
|
|
20928
21158
|
const label = `deployment ${deployment.id}`;
|
|
20929
21159
|
const bound = bindReceiptPayload(args, deployment);
|
|
20930
21160
|
if ("reason" in bound) return bound;
|
|
20931
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` };
|
|
20932
21164
|
let runRecord;
|
|
20933
21165
|
let jobRecords;
|
|
21166
|
+
let latestRunRecord;
|
|
20934
21167
|
try {
|
|
20935
|
-
runRecord = args.transport.
|
|
20936
|
-
jobRecords = args.transport.listWorkflowRunJobs(receipt.workflow.runId);
|
|
21168
|
+
runRecord = args.transport.getWorkflowRunAttempt(receipt.workflow.runId, receipt.workflow.runAttempt);
|
|
21169
|
+
jobRecords = args.transport.listWorkflowRunJobs(receipt.workflow.runId, receipt.workflow.runAttempt);
|
|
21170
|
+
latestRunRecord = args.transport.getWorkflowRun(receipt.workflow.runId);
|
|
20937
21171
|
} catch (error) {
|
|
20938
21172
|
return { reason: `${label} names run ${receipt.workflow.runId}, which could not be read (transport error code: ${transportCode(error)})` };
|
|
20939
21173
|
}
|
|
20940
21174
|
const run = workflowRunRecordSchema.safeParse(runRecord);
|
|
20941
21175
|
if (!run.success) return { reason: `${label} names run ${receipt.workflow.runId}, which GitHub does not record` };
|
|
20942
|
-
|
|
20943
|
-
if (
|
|
20944
|
-
const
|
|
20945
|
-
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` };
|
|
21178
|
+
const latestRun = workflowRunRecordSchema.safeParse(latestRunRecord);
|
|
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` };
|
|
21180
|
+
const jobs = z.object({
|
|
21181
|
+
jobs: z.array(workflowRunJobSchema),
|
|
21182
|
+
total_count: z.number().int().nonnegative()
|
|
21183
|
+
}).safeParse(jobRecords);
|
|
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` };
|
|
20946
21185
|
const stepName = boundReceiptStepName(deployment.id);
|
|
20947
|
-
if (jobs.data.jobs.find((job) => 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` };
|
|
20948
21188
|
return {
|
|
20949
21189
|
baseline: {
|
|
20950
21190
|
deploymentId: deployment.id,
|
|
@@ -20967,7 +21207,7 @@ const trustReceipt = (args, deployment) => {
|
|
|
20967
21207
|
* record GitHub creates for it is unbound too.
|
|
20968
21208
|
*/
|
|
20969
21209
|
const resolveDeploymentBaseline = (args) => {
|
|
20970
|
-
const environment = deploymentReceiptEnvironment(args.target);
|
|
21210
|
+
const environment = deploymentReceiptEnvironment(args.target, args.preview);
|
|
20971
21211
|
let deployments;
|
|
20972
21212
|
try {
|
|
20973
21213
|
const parsed = z.array(deploymentRecordSchema).safeParse(args.transport.listDeployments());
|
|
@@ -21005,9 +21245,20 @@ const createdDeploymentSchema = z.object({ id: z.number().int() });
|
|
|
21005
21245
|
* earlier successful deployments of the environment inactive, so the newest
|
|
21006
21246
|
* success is always what production runs.
|
|
21007
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
|
+
};
|
|
21008
21257
|
const publishDeploymentReceipt = (args) => {
|
|
21009
|
-
const environment = deploymentReceiptEnvironment(args.target);
|
|
21258
|
+
const environment = deploymentReceiptEnvironment(args.target, args.preview);
|
|
21259
|
+
assertPublicationScope(args);
|
|
21010
21260
|
const payload = {
|
|
21261
|
+
...args.preview ? { preview: args.preview } : {},
|
|
21011
21262
|
repository: args.identity.repository,
|
|
21012
21263
|
schemaVersion: 1,
|
|
21013
21264
|
sourceSha: args.sourceSha,
|
|
@@ -21024,24 +21275,26 @@ const publishDeploymentReceipt = (args) => {
|
|
|
21024
21275
|
runId: args.identity.runId
|
|
21025
21276
|
}
|
|
21026
21277
|
};
|
|
21027
|
-
|
|
21278
|
+
let { deploymentId } = args;
|
|
21279
|
+
if (deploymentId === void 0) deploymentId = createdDeploymentSchema.parse(args.transport.createDeployment({
|
|
21028
21280
|
auto_merge: false,
|
|
21029
21281
|
description: `${DEPLOYMENT_RECEIPT_TASK} ${args.target} at ${args.sourceSha}`,
|
|
21030
21282
|
environment,
|
|
21031
21283
|
payload,
|
|
21032
|
-
production_environment:
|
|
21284
|
+
production_environment: !args.preview,
|
|
21033
21285
|
ref: args.sourceSha,
|
|
21034
21286
|
required_contexts: [],
|
|
21035
|
-
task: DEPLOYMENT_RECEIPT_TASK
|
|
21036
|
-
}));
|
|
21037
|
-
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, {
|
|
21038
21291
|
auto_inactive: args.state === "success",
|
|
21039
21292
|
description: `${args.state}: ${args.target} at ${args.sourceSha} by run ${args.identity.runId} attempt ${args.identity.runAttempt}`,
|
|
21040
21293
|
log_url: `${runUrl(args.identity, args.identity.runId)}/attempts/${args.identity.runAttempt}`,
|
|
21041
21294
|
state: args.state
|
|
21042
21295
|
});
|
|
21043
21296
|
return {
|
|
21044
|
-
deploymentId
|
|
21297
|
+
deploymentId,
|
|
21045
21298
|
environment,
|
|
21046
21299
|
payload,
|
|
21047
21300
|
state: args.state
|
|
@@ -21108,10 +21361,21 @@ const ghDeploymentReceiptTransport = (cwd, repository, run = defaultGhRunner) =>
|
|
|
21108
21361
|
return {
|
|
21109
21362
|
createDeployment: (body) => post(`${base}/deployments`, body),
|
|
21110
21363
|
createDeploymentStatus: (deploymentId, body) => post(`${base}/deployments/${deploymentId}/statuses`, body),
|
|
21364
|
+
getWorkflowFile: (workflowPath, sourceSha) => api(`${base}/contents/${workflowPath}?ref=${sourceSha}`),
|
|
21111
21365
|
getWorkflowRun: (runId) => api(`${base}/actions/runs/${runId}`),
|
|
21366
|
+
getWorkflowRunAttempt: (runId, attempt) => api(`${base}/actions/runs/${runId}/attempts/${attempt}`),
|
|
21112
21367
|
listDeploymentStatuses: (deploymentId) => api(`${base}/deployments/${deploymentId}/statuses?per_page=${RECEIPT_PAGE_SIZE}`),
|
|
21113
21368
|
listDeployments: () => api(`${base}/deployments?per_page=${RECEIPT_PAGE_SIZE}`),
|
|
21114
|
-
|
|
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
|
+
},
|
|
21378
|
+
listWorkflowRunJobs: (runId, attempt) => api(`${base}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=${RECEIPT_PAGE_SIZE}`)
|
|
21115
21379
|
};
|
|
21116
21380
|
};
|
|
21117
21381
|
//#endregion
|
|
@@ -22153,6 +22417,138 @@ function createPreviewReapCommand(output, deps = {}) {
|
|
|
22153
22417
|
});
|
|
22154
22418
|
}
|
|
22155
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
|
|
22156
22552
|
//#region src/worktree-scratch-files.ts
|
|
22157
22553
|
var worktree_scratch_files_exports = /* @__PURE__ */ __exportAll({
|
|
22158
22554
|
checkWorktreeScratchFiles: () => checkWorktreeScratchFiles,
|
|
@@ -22225,6 +22621,7 @@ function createProgram(options = {}) {
|
|
|
22225
22621
|
program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
|
|
22226
22622
|
program.addCommand(createPrVerifyCommand(output));
|
|
22227
22623
|
program.addCommand(createProductionImpactCommand(output));
|
|
22624
|
+
program.addCommand(createPreviewReceiptCommand(output));
|
|
22228
22625
|
program.addCommand(createPreviewReapCommand(output));
|
|
22229
22626
|
program.addCommand(createCandidateImpactCommand(output));
|
|
22230
22627
|
program.addCommand(createCloseoutCommand(output));
|