@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.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.33";
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", ["status", "--porcelain"], cwd).stdout.trim();
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
- const normalizeStatusPath = (filePath) => filePath.replaceAll("\\", "/").replace(/^\.\//u, "");
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
- const changedFileSet = new Set(changedFiles.map(normalizeStatusPath));
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/pr-verify-check-payload.ts
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
- * Machine-readable binding carried on the `patronage-factory/pr-verify` check
7102
- * run (#247), alongside the human-readable prose summary.
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
- * It records *what was verified*, for the head SHA it was written for. It
7105
- * grants nothing and gates nothing on its own: a consumer still has to verify
7106
- * the producing App, the conclusion, and its own freshness rule. The payload
7107
- * is a fenced JSON document in `output.text`.
7108
- */
7109
- const PR_VERIFY_CHECK_PAYLOAD_KIND = "pr-verify-proof-binding";
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 outcomeFor$1 = (value) => isOutcome(value.outcome) ? value.outcome : "aborted";
7119
- const asClassification = (value) => DIFF_CLASSIFICATIONS.includes(value) ? value : "unknown";
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
- * Project a local pr:verify proof onto the durable check-run payload. Accepts
7122
- * `unknown` because the publisher is handed an opaque proof value; anything
7123
- * unrecognizable degrades to `undefined` so the check run still publishes its
7124
- * prose.
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 prVerifyCheckPayloadFor = (proof) => {
7127
- const value = proof;
7128
- if (!value || typeof value.headSha !== "string" || value.headSha.length === 0 || !isResolvedPrVerifyMode(value.mode)) return;
7129
- const carriesReleases = value.notRequiredCommands !== void 0;
7130
- return {
7131
- classification: asClassification(value.classification),
7132
- executedCommands: (value.executedCommands ?? []).map(({ name }) => name).filter((name) => typeof name === "string"),
7133
- headSha: value.headSha,
7134
- ...carriesReleases && value.impactStamp !== void 0 ? { impactStamp: value.impactStamp } : {},
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/github-check-runs.ts
7149
- const FACTORY_CHECK_NAMES = {
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
- * Fire-and-forget publication for a check run that is a *mirror* of a local
7156
- * verdict — swallowing the failure cannot change what the gate decided.
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
- * Not every branded check is a mirror any more (#477). `patronage-factory/
7159
- * pr-ready` is a source-pinned required check in the branch ruleset: a
7160
- * swallowed failure there leaves a candidate armed, unmergeable, and — as epic
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 isRetryableCheckRunFailure = (error) => {
7208
- if (!(error instanceof GitHubApiError)) return true;
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
- * The pre-push state of the detached `pr:verify` publisher: GitHub 422s a check
7213
- * run whose `head_sha` it has never seen.
7165
+ * Plan one verification battery against the candidate's impact stamp.
7214
7166
  *
7215
- * Scoped to `pr:verify` because that is the gate whose proof `pr:publish`
7216
- * rebinds after the push (`republishVerifyProofCheckRun`); the other gates run
7217
- * against a head GitHub already has.
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
- * Scoped to a *successful* verification because `pr:verify` also publishes this
7220
- * gate from its aborted path (`writePartialProof`, `pr-verify.ts`), which 422s
7221
- * pre-push in exactly the same way. That proof is incomplete — applicability
7222
- * classifies it `typed-aborted` and readiness answers "re-run pr:verify" — so
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 isPrePushVerifyBinding = (input, error) => input.gate === "pr-verify" && input.conclusion === "success" && error instanceof GitHubApiError && error.status === 422;
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
- * What to tell an operator when the head SHA is not on GitHub yet.
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
- * Reported as a bare "unable to post check run", this reads as a defect to
7232
- * chase rather than the ordinary pre-push state, and on PR #314 it prompted a
7233
- * second full verification after the push — every command re-run for nothing.
7234
- * The proof is already written and `pr:publish` binds it to this same commit
7235
- * with no re-execution, so say that instead (#316).
7236
- */
7237
- 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.
7238
- 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.
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
- * Retry budget is the caller's. The fire-and-forget publisher passes one
7251
- * attempt because it already has a commit-status fallback (#938). The
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
- * This is recording made visible, not a control. The attended merge is the
7296
- * control.
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 readyIdentityBlock = (value) => {
7299
- const runs = value.ledger?.reviewRuns ?? [];
7300
- if (runs.length === 0 && value.authoringSession === void 0) return "";
7301
- return `\n\nRecorded review identity:\n\n${[...runs.map((run) => {
7302
- const rung = run.rung === void 0 ? "" : ` at rung \`${run.rung}\``;
7303
- return `- ${run.kind ?? "review"}${rung}: session ${readyIdentityField(run.sessionId)} · model ${readyIdentityField(run.model)} · producer ${readyIdentityField(run.producer)}`;
7304
- }), `- authoring session: ${readyIdentityField(value.authoringSession)}`].join("\n")}`;
7305
- };
7306
- function readyOutput(proof) {
7307
- const value = proof;
7308
- const reasons = readyReasonLines(value);
7309
- return {
7310
- 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)}`,
7311
- title: `pr:ready ${value.status ?? "unknown"}`
7312
- };
7313
- }
7314
- function factoryCheckOutput(gate, proof) {
7315
- return {
7316
- "pr-ready": readyOutput,
7317
- "pr-verify": verifyOutput
7318
- }[gate](proof);
7319
- }
7320
- async function runGhDetails(args, cwd) {
7321
- const { execFile } = await import("node:child_process");
7322
- const { stdout } = await promisify(execFile)("gh", args, {
7323
- cwd,
7324
- encoding: "utf-8",
7325
- timeout: GITHUB_PUBLISH_TIMEOUT_MS
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
- * POST the check run, retrying a bounded number of times.
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
- * The retry exists for one specific failure: GitHub rejects a check run whose
7362
- * `head_sha` it has not seen. `pr:verify` routinely runs before the branch is
7363
- * pushed, and even after a push the commit can take a moment to be visible.
7364
- * Without a retry the POST 422s, falls through to a commit status that 422s
7365
- * too, and the run reads as "verification did not happen" (#247).
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
- * Retries are bounded three ways: a maximum attempt count, an optional
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
- * Throws the last error when every attempt fails; callers decide whether to
7372
- * fall back or to swallow. Returns the created check run, whose `id` is the
7373
- * only handle on *which* run this call produced — what the read-back below
7374
- * requires the served run to be.
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
- async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUrl, input, remainingAttempts }) {
7377
- const { attempts, budgetMs, delayMs } = dependencies.retry ?? DEFAULT_CHECK_RUN_RETRY;
7378
- const clock = dependencies.now ?? Date.now;
7379
- const remaining = remainingAttempts ?? Math.max(1, attempts);
7380
- const deadline = deadlineMs ?? (budgetMs === void 0 ? void 0 : clock() + budgetMs);
7381
- const request = dependencies.fetch ?? fetch;
7382
- const timeoutMs = dependencies.timeoutMs ?? 5e3;
7383
- try {
7384
- const token = dependencies.token ?? await installationToken(input, config, request, (dependencies.now ?? Date.now)(), {
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 await requestGitHubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/check-runs`, {
7389
- body: JSON.stringify(checkRunRequestBody(input, detailsUrl)),
7390
- headers: {
7391
- Authorization: `Bearer ${token}`,
7392
- "Content-Type": "application/json"
7393
- },
7394
- method: "POST"
7395
- }, timeoutMs);
7396
- } catch (error) {
7397
- if (remaining <= 1 || !isRetryableCheckRunFailure(error) || deadline !== void 0 && clock() + delayMs >= deadline) throw error;
7398
- await (dependencies.sleep ?? defaultSleep)(delayMs);
7399
- return await postFactoryCheckRun({
7400
- config,
7401
- deadlineMs: deadline,
7402
- dependencies,
7403
- detailsUrl,
7404
- input,
7405
- remainingAttempts: remaining - 1
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
- * Whether the Details-URL lookup failed only because the branch has no pull
7411
- * request yet. `gh pr view` exits non-zero in that case, which is the ordinary
7412
- * state of every `pr:verify` run made before the push (#323).
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 isNoPullRequestYet = (message) => /no pull requests found for branch/iu.test(message);
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
- * What to tell an operator when there is no pull request to link to yet.
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
- * Reported as "unable to resolve Details URL", this reads as a defect to chase
7419
- * on the exact path where that register has already cost a full redundant
7420
- * verification once (PR #314, #316). Nothing is wrong: the commit link is the
7421
- * correct Details target until a pull request exists, and `pr:publish` binds
7422
- * the check to the pull request afterwards.
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
- 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.
7425
- `;
7426
- async function resolveDetailsUrlSafely(input, dependencies, onDiagnostic) {
7603
+ function publishFactoryCheckSafely(publisher, input) {
7427
7604
  try {
7428
- return await (dependencies.resolveDetailsUrl ?? resolveFactoryDetailsUrl)(input);
7605
+ publisher?.(input);
7429
7606
  } catch (error) {
7430
- const message = error instanceof Error ? error.message : String(error);
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
- * Read back every run GitHub serves for this check name on this head SHA.
7437
- *
7438
- * `filter=all`, not `filter=latest` (#524). `latest` was chosen here on the
7439
- * rationale that it is the view the required-check rollup reads; that rationale
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 publishedRunIsServed = (input, createdId, served) => {
7544
- if (createdId === void 0 || served.id !== createdId) return false;
7545
- const status = input.status ?? "completed";
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
- * Whether the required check this publication targets is *satisfied* on the
7551
- * head: the newest run of the name is this call's own, and no run of the name
7552
- * survives unfinished.
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 servedRunConfirmsPublication = (input, createdId, selection) => publishedRunIsServed(input, createdId, selection.run) && unfinishedRuns(selection.runs).length === 0;
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
- * Which of the two refusals happened, reported in the order an operator can act
7566
- * on: this call's own publication first, then any *other* run holding the check
7567
- * open. Once the first clause holds, this call's run is terminal, so everything
7568
- * the second clause names belongs to something else.
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 unconfirmedDetail = (input, createdId, selection) => {
7571
- const served = selection.run;
7572
- 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`;
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
- * Confirm the publication against what GitHub serves, and say plainly which of
7577
- * the two events failed when it cannot.
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
- * Only the read is wrapped. A token, timeout, or 5xx failure here is not a
7580
- * failure to publish — the POST already succeeded and the check is probably
7581
- * present — so it must not be reported through the "unable to republish"
7582
- * register, which drives `pr:ready`'s "this run's verdict never reached the
7583
- * required check" notice.
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
- async function confirmPublishedCheckRun({ config, createdId, dependencies, input, onDiagnostic }) {
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
- * Publish a factory check run and *wait* for it, so a caller that has just made
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
- * "Landed" means GitHub serves it, not that the POST was accepted (#520). On
7621
- * PR #519 the POST was accepted, `pr:ready` reported ready and armed, and the
7622
- * source-pinned required check read `in_progress` across three runs — so GitHub
7623
- * never scheduled the merge and emitted no rollup row saying why. Arming
7624
- * already refuses to infer its outcome from the invocation and reads the pull
7625
- * request back (`arm-auto-merge.ts`); publication now does the same.
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
- * "Landed" is judged against every run of the name from the pinned App, not
7628
- * against the one GitHub collapses to (#524): the newest must be *this* run, in
7629
- * the status and conclusion that were published, and no run of the name may
7630
- * still be unfinished. A surviving `in_progress` run blocks the required check
7631
- * on its own, so confirming past it would report success on a pull request
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
- * Confirmation is a point-in-time read, deliberately: a later publication for
7637
- * the same name changes the answer — a completed one by becoming the newest, an
7638
- * unfinished one by holding the check open beside this verdict rather than
7639
- * replacing it — and every remaining writer publishes once, as the last thing
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
- async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
7643
- const { onDiagnostic } = dependencies;
7644
- const checkName = FACTORY_CHECK_NAMES[input.gate];
7645
- const access = await resolveFactoryAppAccess(input, dependencies);
7646
- if (access.kind !== "ok") {
7647
- onDiagnostic?.(`${checkName}: ${access.message}`);
7648
- if (access.failClosed) throw new FactoryAppUnevaluableError(`${checkName}: ${access.message.trim()}`);
7649
- return false;
7650
- }
7651
- try {
7652
- const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp ?? {
7653
- appId: access.appId,
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
- * `git rev-parse --show-toplevel` answers with a real path, while a caller's
7751
- * `cwd` may reach the same directory through a symlink (every macOS temporary
7752
- * directory does). Both sides are resolved before they are compared, so a
7753
- * symlinked checkout is not mistaken for a profile outside the repository.
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 realPath = (value) => {
7756
- try {
7757
- return realpathSync(value);
7758
- } catch {
7759
- return value;
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
- * The repository top level for `cwd`, or `cwd` itself when it is not a git
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
- * The repository-relative, POSIX-separated path of `profilePath`.
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
- * A cwd that is not a git checkout, and a profile the repository top level
7778
- * does not contain, both fall back to the cwd-relative path: this function
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 repositoryRelativeProfilePath = ({ cwd, profilePath, readRepositoryRoot = repositoryRoot }) => {
7784
- const cwdRelative = path.relative(cwd, profilePath).split(path.sep).join("/");
7785
- const root = repositoryRootOrCwd({
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
- readRepositoryRoot
7758
+ encoding: "utf-8",
7759
+ timeout: GITHUB_PUBLISH_TIMEOUT_MS
7788
7760
  });
7789
- const rootRelative = path.relative(realPath(root), realPath(profilePath)).split(path.sep).join("/");
7790
- return rootRelative.length > 0 && !rootRelative.startsWith("../") ? rootRelative : cwdRelative;
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
- * The same path, for a caller that must REFUSE a profile the repository does
7796
- * not contain.
7793
+ * POST the check run, retrying a bounded number of times.
7797
7794
  *
7798
- * Containment is measured from the repository top level, not from `cwd`.
7799
- * Measuring it from `cwd` refuses the repository-root profile whenever the
7800
- * command runs in a package subdirectory — a profile that is plainly inside
7801
- * the trusted checkout — and it refused before the conversion above could run
7802
- * (cycle-1 review finding on PR #1004).
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
- * Fail-closed is unchanged. A profile genuinely outside the repository root
7805
- * falls back to the cwd-relative path, which still escapes, and throws here.
7806
- * A cwd that is not a git checkout has no root to measure from and keeps the
7807
- * previous cwd-relative rule exactly.
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
- const repositoryContainedProfilePath = ({ cwd, profilePath, readRepositoryRoot = repositoryRoot }) => {
7810
- const relativePath = repositoryRelativeProfilePath({
7811
- cwd,
7812
- profilePath,
7813
- readRepositoryRoot
7814
- });
7815
- if (escapesRoot(relativePath)) throw new Error("The readiness profile must be inside the trusted checkout.");
7816
- return relativePath;
7817
- };
7818
- //#endregion
7819
- //#region src/pr-classification.ts
7820
- const MISSING_STAMP_REASON = "impact stamp is missing; fail closed to non-trivial";
7821
- function ownershipFromStamp(stamp, file) {
7822
- if (stamp.inertPaths.includes(file)) return { kind: "inert" };
7823
- if (stamp.unsubscribedPaths.includes(file)) return { kind: "unowned" };
7824
- const named = stamp.targets.find((target) => target.impact === "affected" && target.subscribedPaths.includes(file));
7825
- if (named) return {
7826
- kind: "target",
7827
- target: named.name
7828
- };
7829
- const affected = stamp.targets.find((target) => target.impact === "affected");
7830
- if (affected) return {
7831
- kind: "target",
7832
- target: affected.name
7833
- };
7834
- return { kind: "unowned" };
7835
- }
7836
- function reasonForOwnership(file, ownership) {
7837
- switch (ownership.kind) {
7838
- case "inert": return `${file}: repository-inert ownership (stamp.inertPaths)`;
7839
- case "target": return `${file}: affects product target "${ownership.target}"`;
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
- function relativeProfilePath(options) {
7844
- return repositoryRelativeProfilePath({
7845
- cwd: options.cwd,
7846
- profilePath: options.profilePath
7847
- });
7848
- }
7849
- function applyProfileEditGuard(files, relativeProfile, profilePath, base) {
7850
- if (!files.some((file) => file === relativeProfile || file === profilePath)) return base;
7851
- return {
7852
- classification: "non-trivial",
7853
- reasons: [...base.reasons, `${relativeProfile}: edits the ownership declarations; forced non-trivial`]
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
- * Path ownership as the stamp recorded it. A missing stamp fails closed.
7858
- * Does not glob, and does not refuse the docs rung on a conservative basis —
7859
- * that gate belongs to {@link classifyDiffForRunWithStamp}.
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 classifyDiff(files, stamp) {
7862
- if (stamp === void 0) return {
7863
- classification: "non-trivial",
7864
- reasons: [MISSING_STAMP_REASON]
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
- const sortedFiles = [...files].toSorted();
7867
- if (sortedFiles.length === 0) return {
7868
- classification: "non-trivial",
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
- const ownerships = sortedFiles.map((file) => ({
7872
- file,
7873
- ownership: ownershipFromStamp(stamp, file)
7874
- }));
7875
- const reasons = ownerships.map(({ file, ownership }) => reasonForOwnership(file, ownership));
7876
- if (ownerships.every(({ ownership }) => ownership.kind === "inert")) return {
7877
- classification: "docs/process-only",
7878
- reasons
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
- classification: "non-trivial",
7882
- reasons
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
- * The candidate classification `pr:verify` records: path ownership gated by
7887
- * the impact stamp's positive evidence. The docs/process-only rung requires a
7888
- * `target-scoped` stamp with zero affected targets ON TOP of full inert
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
- * Ownership and reasons come from `stamp.targets`, `stamp.inertPaths`, and
7895
- * `stamp.unsubscribedPaths`. A missing stamp fails closed; this function
7896
- * never evaluates profile globs.
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
- function classifyDiffForRunWithStamp(profile, files, stamp, options) {
7899
- const base = applyProfileEditGuard(files, relativeProfilePath(options), options.profilePath, classifyDiff(files, stamp));
7900
- if (stamp === void 0 || base.classification === "non-trivial") return base;
7901
- if (stamp.basis !== "target-scoped") return {
7902
- classification: "non-trivial",
7903
- reasons: [...base.reasons, "impact stamp is conservative; the docs/process-only rung needs a target-scoped stamp with zero affected targets"]
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
- * Path predicate for consumers that do not already hold a stamp (pr-ready's
7914
- * docs-only delta). Computes one stamp through {@link computeImpactStamp} —
7915
- * the only glob-evaluation site — then reads ownership from it.
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
- function classifyDiffForRun(profile, files, options) {
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
- * The authority could not be read, and `reason` says why — required, because an
7975
- * unresolved record with no reason is an unactionable refusal. `pr:ready`
7976
- * refuses before it writes a proof, so a Slice A ready proof never carries one;
7977
- * this member is the recorded form of that refusal for the stages that carry it
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 unresolvedPolicyResolutionSchema = z.object({
7981
- /** The base ref whose policy could not be read. */
7982
- baseRefName: z.string().min(1),
7983
- /** Why the authority is unresolved. */
7984
- reason: z.string().min(1),
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
- * The `vetoedTargets` contract, enforced at RUNTIME as well as at the type
7994
- * level. TypeScript makes the parameter required for callers it compiles;
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
- * A malformed CALL is API misuse, not data doubt: it fails loudly (ADR 0024)
8001
- * rather than degrading to the conservative floor, because a producer that
8002
- * never resolved the veto set has a bug its author must see.
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
- const assertVetoedTargets = (vetoedTargets, caller) => {
8005
- 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.`);
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
- * Plan one verification battery against the candidate's impact stamp.
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
- * Pure and total: it makes a decision, it never reads a proof, a profile file,
8011
- * or the filesystem, and no INPUT can make it throw — every doubt path is a
8012
- * decision, not an exception. A malformed CALL is the one exception to that,
8013
- * and deliberately so: omitting `vetoedTargets` is API misuse rather than data
8014
- * doubt, and it throws (see {@link assertVetoedTargets}).
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
- * `stamp` must already be trusted by
8017
- * the caller — inside `pr:verify` that is the stamp just computed for this
8018
- * candidate's own identity triple; anywhere else it is `trustedImpactStamp`'s
8019
- * output or `undefined`.
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
- const planVerificationBattery = ({ commands, stamp, vetoedTargets }) => {
8022
- assertVetoedTargets(vetoedTargets, "planVerificationBattery");
8023
- const vetoed = new Set(vetoedTargets);
8024
- const dispositions = [];
8025
- const execute = [];
8026
- const notRequired = [];
8027
- for (const command of commands) {
8028
- const { impactTarget, name } = command;
8029
- if (impactTarget === void 0) {
8030
- dispositions.push({
8031
- basis: UNCONDITIONAL_BASIS,
8032
- disposition: "executed",
8033
- name
8034
- });
8035
- execute.push(command);
8036
- continue;
8037
- }
8038
- const decision = impactStampScopeDecision({
8039
- stamp,
8040
- surface: "verification-battery",
8041
- targetName: impactTarget
8042
- });
8043
- const vetoedHere = decision.scoped && vetoed.has(impactTarget);
8044
- 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;
8045
- const scoped = decision.scoped && !vetoedHere;
8046
- dispositions.push({
8047
- basis,
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
- if (scoped) notRequired.push({
8053
- basis,
8054
- impactTarget,
8055
- name
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
- else execute.push(command);
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 completeness invariant as an enforceable control (ADR 0024: controls
8067
- * fail loudly), shared by both pr:verify proof schemas so they cannot drift.
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 assertBatteryCompleteness = (proof, context) => {
8087
- const selected = proof.verificationCommands.map((command) => command.name);
8088
- const selectedSet = new Set(selected);
8089
- const executed = new Set((proof.executedCommands ?? []).map((command) => command.name));
8090
- const notRequired = proof.notRequiredCommands ?? [];
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 stamp-authorization control: a passed proof may only claim a command
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
- * The proof is one artifact and a determined forger controls all of it; this
8155
- * is internal-coherence belt-and-braces in the same trust domain as the rest
8156
- * of the proof, and write-time truth stays `pr:verify`'s job.
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
- * The authorizing evidence is the proof's OWN `impactStamp` — the same stamp
8159
- * `pr:verify` computed for this candidate's identity triple and recorded here,
8160
- * so authorization is bound to the same identities the proof binds. The
8161
- * predicate is the shared {@link impactStampScopeDecision}, not a second
8162
- * reading of the stamp: an entry is authorized exactly when the stamp would
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
- * Scoped to `outcome: "passed"` for the same reason as the completeness rule:
8170
- * an aborted run records partial execution, so it makes no false completeness
8171
- * claim.
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 assertNotRequiredStampAuthorization = (proof, context) => {
8174
- const notRequired = proof.notRequiredCommands ?? [];
8175
- if (proof.outcome !== "passed" || notRequired.length === 0) return;
8176
- const { impactStamp } = proof;
8177
- if (impactStamp === void 0) {
8178
- context.addIssue({
8179
- code: "custom",
8180
- 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.`,
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-verify-proof-rules.ts
8223
- const assertPrVerifyProofRules = (proof, context) => {
8224
- if (proof.notDemandedRecords !== void 0 && proof.impactStamp === void 0) context.addIssue({
8225
- code: "custom",
8226
- message: "notDemandedRecords requires impactStamp; package-level not-demanded records are bound to the stamp identity.",
8227
- path: ["notDemandedRecords"]
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
- assertBatteryCompleteness(proof, context);
8230
- assertNotRequiredStampAuthorization(proof, context);
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 proof versions a reader accepts: the current one only (#920
8234
- * item 1). #917 narrowed the pr:ready reader the same way. A pr:verify proof
8235
- * is per-candidate and short-lived — it is rewritten on every head — so no
8236
- * stored v1–v3 proof can outlive the release that drops it. Pre-1.0 posture
8237
- * applies: one current contract, no compat reader. A consumer on an older
8238
- * factory must adopt this release before HQ ingests its pr:verify proofs.
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
- const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS = [4];
8241
- const prVerifyTestFailureSchema = z.object({
8242
- file: z.string().min(1),
8243
- kind: z.enum([
8244
- "assertion",
8245
- "error",
8246
- "timeout"
8247
- ]),
8248
- title: z.string().min(1)
8249
- });
8250
- const executedCommandSchema = z.object({
8251
- command: z.string().min(1),
8252
- counts: z.object({
8253
- testFiles: z.number().nonnegative().optional(),
8254
- tests: z.number().nonnegative().optional()
8255
- }).optional(),
8256
- durationMs: z.number().nonnegative(),
8257
- exitCode: z.number(),
8258
- failures: z.array(prVerifyTestFailureSchema).min(1).optional(),
8259
- name: z.string().min(1),
8260
- scope: z.enum([
8261
- "docs-only",
8262
- "trivial",
8263
- "full"
8264
- ])
8265
- });
8266
- const notRequiredCommandSchema = z.object({
8267
- basis: z.string().min(1),
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$1,
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$1 = "verify";
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$1} (draft)`;
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 hostedVerifyCheckRunSchema = z.object({
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
- name: z.literal(HOSTED_VERIFY_CHECK_NAME),
16066
- started_at: z.iso.datetime().nullable(),
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
- "queued"
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
- const verifyRuns = z.object({ check_runs: z.array(z.unknown()) }).parse(api.list({
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
- name: HOSTED_VERIFY_CHECK_NAME
16136
- })).check_runs.map((run) => hostedVerifyCheckRunSchema.parse(run));
16137
- const completedVerify = verifyRuns.find((run) => run.status === "completed");
16138
- if (completedVerify) return unconfiguredMergeFreeze(input, `the current tip's source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run ${completedVerify.id} already completed without producing a freeze generation.`);
16139
- const pendingVerifyRuns = verifyRuns.filter((run) => run.status !== "completed");
16140
- const orderedVerifyRuns = pendingVerifyRuns.flatMap((run) => run.started_at ? [{
16141
- orderMs: generationOrderMs(run.started_at),
16142
- run
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 and source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run ${verify.id} is ${verify.status}.`,
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
- verifyRunId: verify.id,
16166
- verifyStatus: verify.status
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 check-run evidence is unreadable: ${error instanceof Error ? error.message : String(error)}.`);
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 the hosted
16263
- * verify run that owes the generation.
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 ${HOSTED_VERIFY_CHECK_NAME} run ${generation.witness.verifyRunId}, which is ${generation.witness.verifyStatus}, prove one is owed, so ${SETTLING_MERGE_FREEZE_TAIL}`;
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
- * source-pinned hosted verify run that owes one (#521). Epic #473 decision 5
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$1} 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.`,
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$1}: ${verifyState}; still running: ${pending.length > 0 ? pending.join(", ") : "none reported yet"}); attempt ${attempts + 1} of 40.\n`);
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
- repository: z.object({ full_name: z.string() })
21062
+ pull_requests: z.array(z.object({
21063
+ head: z.object({
21064
+ ref: z.string(),
21065
+ sha: z.string()
21066
+ }),
21067
+ number: z.number().int()
21068
+ })).optional(),
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.getWorkflowRun(receipt.workflow.runId);
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
- const expectedBranch = args.identity.ref.replace(/^refs\/heads\//u, "");
20943
- if (run.data.path !== args.identity.workflowPath || run.data.repository.full_name !== args.identity.repository || run.data.head_sha.toLowerCase() !== receipt.sourceSha || run.data.head_branch !== expectedBranch) return { reason: `${label} names run ${receipt.workflow.runId}, whose recorded workflow, repository, head, or branch disagrees with the receipt` };
20944
- const jobs = z.object({ jobs: z.array(workflowRunJobSchema) }).safeParse(jobRecords);
20945
- if (!jobs.success) return { reason: `${label} names run ${receipt.workflow.runId}, whose job records are malformed` };
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.conclusion === "success" && job.head_sha.toLowerCase() === receipt.sourceSha && job.steps?.some((step) => step.name === stepName && step.conclusion === "success")) === void 0) return { reason: `${label} names run ${receipt.workflow.runId}, but no succeeded job of that run at ${receipt.sourceSha} bound deployment ${deployment.id}` };
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
- const created = createdDeploymentSchema.parse(args.transport.createDeployment({
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: true,
21284
+ production_environment: !args.preview,
21033
21285
  ref: args.sourceSha,
21034
21286
  required_contexts: [],
21035
- task: DEPLOYMENT_RECEIPT_TASK
21036
- }));
21037
- args.transport.createDeploymentStatus(created.id, {
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: created.id,
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
- listWorkflowRunJobs: (runId) => api(`${base}/actions/runs/${runId}/jobs?per_page=${RECEIPT_PAGE_SIZE}`)
21369
+ listPreviewWorkflowRuns: (workflowPath, sourceSha) => {
21370
+ const pages = ghJson(run, [
21371
+ "api",
21372
+ "--paginate",
21373
+ "--slurp",
21374
+ `${base}/actions/workflows/${encodeURIComponent(path.basename(workflowPath))}/runs?head_sha=${sourceSha}&event=pull_request&per_page=${RECEIPT_PAGE_SIZE}`
21375
+ ], cwd);
21376
+ return z.array(z.object({ workflow_runs: z.array(z.unknown()) })).parse(pages).flatMap((page) => page.workflow_runs);
21377
+ },
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));