@patronage/software-factory 1.0.0-alpha.34 → 1.0.0-alpha.35

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