@patronage/software-factory 1.0.0-alpha.30 → 1.0.0-alpha.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.d.ts +10 -10
- package/dist/index.js +705 -138
- package/dist/schemas.js +3 -3
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -18,7 +18,8 @@ import { FACTORY_APP_BROKER_ORIGIN, FACTORY_APP_BROKER_PATH, FACTORY_CURSOR_OIDC
|
|
|
18
18
|
import http from "node:http";
|
|
19
19
|
import { pathToFileURL } from "node:url";
|
|
20
20
|
//#region package.json
|
|
21
|
-
var
|
|
21
|
+
var name = "@patronage/software-factory";
|
|
22
|
+
var version = "1.0.0-alpha.31";
|
|
22
23
|
//#endregion
|
|
23
24
|
//#region src/cli-entry.ts
|
|
24
25
|
/**
|
|
@@ -2277,7 +2278,7 @@ const migratedPolicyLines = (policy, root) => {
|
|
|
2277
2278
|
* still on `schemaVersion` 2 — or `undefined` when it is not.
|
|
2278
2279
|
*
|
|
2279
2280
|
* v3 is a hard break: there is no in-memory translation and no compatibility
|
|
2280
|
-
* path (ADR
|
|
2281
|
+
* path (ADR 0021, #318). What replaces it is a refusal that names each
|
|
2281
2282
|
* offending field and prints its replacement, so migrating is a transcription
|
|
2282
2283
|
* rather than a reading exercise. The message is a courtesy, never a control:
|
|
2283
2284
|
* every rule it describes is enforced by the schema below it.
|
|
@@ -2402,7 +2403,7 @@ function hqLaneRefBaseUrlFromProfile(profile) {
|
|
|
2402
2403
|
return hqLaneRefBaseUrl(profile.hq.endpoint);
|
|
2403
2404
|
}
|
|
2404
2405
|
function formatLegacyProfileError(profilePath, foundVersion, migration) {
|
|
2405
|
-
return [`Profile is invalid: ${profilePath}: schemaVersion ${foundVersion} is not supported; migrate to schemaVersion 6 (ADR
|
|
2406
|
+
return [`Profile is invalid: ${profilePath}: schemaVersion ${foundVersion} is not supported; migrate to schemaVersion 6 (ADR 0021).`, ...migration.map((line) => ` ${line}`)].join("\n");
|
|
2406
2407
|
}
|
|
2407
2408
|
function formatProfileError(profilePath, error) {
|
|
2408
2409
|
return `Profile is invalid: ${profilePath}: ${error.issues.map((issue) => {
|
|
@@ -5580,18 +5581,18 @@ const evidenceEnvelopeSchema$1 = z.object({
|
|
|
5580
5581
|
if (envelope.checkType === "review") {
|
|
5581
5582
|
if (envelope.rung === void 0) context.addIssue({
|
|
5582
5583
|
code: "custom",
|
|
5583
|
-
message: "review-type evidence envelope requires a rung (ADR 0014
|
|
5584
|
+
message: "review-type evidence envelope requires a rung (ADR 0014).",
|
|
5584
5585
|
path: ["rung"]
|
|
5585
5586
|
});
|
|
5586
5587
|
} else {
|
|
5587
5588
|
if (envelope.rung !== void 0) context.addIssue({
|
|
5588
5589
|
code: "custom",
|
|
5589
|
-
message: "verify-type evidence envelope must not carry rung (review-type-only field, ADR 0014
|
|
5590
|
+
message: "verify-type evidence envelope must not carry rung (review-type-only field, ADR 0014).",
|
|
5590
5591
|
path: ["rung"]
|
|
5591
5592
|
});
|
|
5592
5593
|
if (envelope.model !== void 0) context.addIssue({
|
|
5593
5594
|
code: "custom",
|
|
5594
|
-
message: "verify-type evidence envelope must not carry model (review-type-only field, ADR 0014
|
|
5595
|
+
message: "verify-type evidence envelope must not carry model (review-type-only field, ADR 0014).",
|
|
5595
5596
|
path: ["model"]
|
|
5596
5597
|
});
|
|
5597
5598
|
}
|
|
@@ -5700,7 +5701,7 @@ const evidenceEmitInvocation = (check) => check.checkType === "review" ? "review
|
|
|
5700
5701
|
* the reader has a thread to pull rather than a demand to interpret. It stays a
|
|
5701
5702
|
* courtesy: the schema and the envelope remain the control.
|
|
5702
5703
|
*/
|
|
5703
|
-
const absentEnvelopeReason = (check) => `Required check "${check.name}" has no evidence envelope; absence fails closed (ADR 0014
|
|
5704
|
+
const absentEnvelopeReason = (check) => `Required check "${check.name}" has no evidence envelope; absence fails closed (ADR 0014). This demand resolves ${check.checkType} envelope files under ${EVIDENCE_DIR}/ in this checkout — not GitHub check runs, so a green hosted job satisfies nothing on its own. Satisfy it with a passing ${check.checkType} envelope named "${check.name}", bound to this candidate's headSha, patchId, and mergeBaseSha: ${evidenceEmitInvocation(check)}`;
|
|
5704
5705
|
const freshnessRefusalReason = (name) => `Required check "${name}" evidence is stale: the envelope's patchId no longer matches the candidate delta.`;
|
|
5705
5706
|
const evaluateInScopeCheck = ({ authoringSessionIds, candidate, check, envelopes, parsed }) => {
|
|
5706
5707
|
const named = parsed.filter((loaded) => loaded.envelope.check === check.name);
|
|
@@ -5709,7 +5710,7 @@ const evaluateInScopeCheck = ({ authoringSessionIds, candidate, check, envelopes
|
|
|
5709
5710
|
if (malformedForCheck) return {
|
|
5710
5711
|
checkType: check.checkType,
|
|
5711
5712
|
name: check.name,
|
|
5712
|
-
reason: `Required check "${check.name}" envelope fails the three-way binding requirement (headSha+patchId+mergeBaseSha always required, ADR 0014
|
|
5713
|
+
reason: `Required check "${check.name}" envelope fails the three-way binding requirement (headSha+patchId+mergeBaseSha always required, ADR 0014): ${malformedForCheck.error}.`,
|
|
5713
5714
|
status: "unmet"
|
|
5714
5715
|
};
|
|
5715
5716
|
return {
|
|
@@ -5871,10 +5872,10 @@ const loadEvidenceEnvelopes = (cwd) => {
|
|
|
5871
5872
|
};
|
|
5872
5873
|
function independenceRefusalReason(name, independence) {
|
|
5873
5874
|
switch (independence.reason) {
|
|
5874
|
-
case "no-session": return `Required review check "${name}" carries no usable sessionId (absent or the reserved "unknown" sentinel), so different-session independence is unconfirmable; fail closed (ADR 0014
|
|
5875
|
-
case "unknown-authoring-identity": return `Required review check "${name}" independence is unconfirmable: the candidate authoring identity is unknown, so a different-session cross-check cannot run; fail closed (ADR 0014
|
|
5876
|
-
case "same-session": return `Required review check "${name}" ran in the same session as the authoring identity; same-session self-review never meets the rung (ADR 0014
|
|
5877
|
-
default: return `Required review check "${name}" independence could not be confirmed; fail closed (ADR 0014
|
|
5875
|
+
case "no-session": return `Required review check "${name}" carries no usable sessionId (absent or the reserved "unknown" sentinel), so different-session independence is unconfirmable; fail closed (ADR 0014).`;
|
|
5876
|
+
case "unknown-authoring-identity": return `Required review check "${name}" independence is unconfirmable: the candidate authoring identity is unknown, so a different-session cross-check cannot run; fail closed (ADR 0014).`;
|
|
5877
|
+
case "same-session": return `Required review check "${name}" ran in the same session as the authoring identity; same-session self-review never meets the rung (ADR 0014).`;
|
|
5878
|
+
default: return `Required review check "${name}" independence could not be confirmed; fail closed (ADR 0014).`;
|
|
5878
5879
|
}
|
|
5879
5880
|
}
|
|
5880
5881
|
//#endregion
|
|
@@ -7157,7 +7158,7 @@ const catchUpRecognitionSchema = z.object({
|
|
|
7157
7158
|
}).strict();
|
|
7158
7159
|
//#endregion
|
|
7159
7160
|
//#region src/policy-resolution.ts
|
|
7160
|
-
const COMMIT_SHA_PATTERN$
|
|
7161
|
+
const COMMIT_SHA_PATTERN$3 = /^[0-9a-f]{40}$/u;
|
|
7161
7162
|
const digestSchema = z.string().regex(/^[0-9a-f]{64}$/u);
|
|
7162
7163
|
/**
|
|
7163
7164
|
* The authority resolved: the live tip of the PR's own base ref served a
|
|
@@ -7181,7 +7182,7 @@ const authoritativePolicyResolutionSchema = z.object({
|
|
|
7181
7182
|
/** sha256 of the merged (base ∪ candidate) protected-path set. */
|
|
7182
7183
|
effectiveDigest: digestSchema,
|
|
7183
7184
|
/** The base-ref tip the base policy was read at. */
|
|
7184
|
-
policyBaseSha: z.string().regex(COMMIT_SHA_PATTERN$
|
|
7185
|
+
policyBaseSha: z.string().regex(COMMIT_SHA_PATTERN$3),
|
|
7185
7186
|
/**
|
|
7186
7187
|
* Whether the candidate's `review` subtree differs from the base's. The
|
|
7187
7188
|
* digests prove which policies were read; this says whether they agreed,
|
|
@@ -8873,7 +8874,6 @@ const foldLedger = (cycles, refs) => {
|
|
|
8873
8874
|
};
|
|
8874
8875
|
};
|
|
8875
8876
|
const openLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition === "open");
|
|
8876
|
-
const nonOpenLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition !== "open");
|
|
8877
8877
|
const blockingLadderFindings = (ledger) => openLadderFindings(ledger).filter((entry) => resolveFindingBlocking(entry, entry.disposition));
|
|
8878
8878
|
const staleRepeatLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition === "stale-repeat");
|
|
8879
8879
|
const inferFixedInThreadDispositions = (ledger, findings, options = {}) => {
|
|
@@ -9137,8 +9137,27 @@ const reviewAcceptedAfterCycleCap = (proof, review) => {
|
|
|
9137
9137
|
});
|
|
9138
9138
|
return state.windowExhausted && state.autoBlockingFindings === 0;
|
|
9139
9139
|
};
|
|
9140
|
+
const ladderRefoldDisagreement = (epoch) => {
|
|
9141
|
+
if (epoch.kind !== "ladder") return {
|
|
9142
|
+
autoBlockingFindings: 0,
|
|
9143
|
+
unmatchedDispositions: 0
|
|
9144
|
+
};
|
|
9145
|
+
if (epoch.snapshot) return {
|
|
9146
|
+
autoBlockingFindings: epoch.snapshot.cycleState.autoBlockingFindings,
|
|
9147
|
+
unmatchedDispositions: epoch.snapshot.evaluation.unmatchedDispositions.length
|
|
9148
|
+
};
|
|
9149
|
+
const { ledger, unmatchedDispositions } = foldLedger(epoch.ladder.cycles, ladderCycleRefsFor(epoch.ladder.cycles));
|
|
9150
|
+
return {
|
|
9151
|
+
autoBlockingFindings: blockingLadderFindings([...ledger.values()]).length,
|
|
9152
|
+
unmatchedDispositions: unmatchedDispositions.length
|
|
9153
|
+
};
|
|
9154
|
+
};
|
|
9140
9155
|
const resolveReviewAcceptanceForEpoch = (proof, review, epoch) => {
|
|
9141
|
-
if (review.outcome === "passed")
|
|
9156
|
+
if (review.outcome === "passed") {
|
|
9157
|
+
const disagreement = ladderRefoldDisagreement(epoch);
|
|
9158
|
+
if (disagreement.autoBlockingFindings > 0 || disagreement.unmatchedDispositions > 0) return "blocked";
|
|
9159
|
+
return "accepted-clean";
|
|
9160
|
+
}
|
|
9142
9161
|
if (review.outcome !== "failed") return "blocked";
|
|
9143
9162
|
if (epoch.kind === "ladder" && epoch.snapshot && reviewAcceptedThroughSnapshot(epoch.snapshot)) return "accepted-through-ladder";
|
|
9144
9163
|
if (epoch.kind === "declared-window" && reviewAcceptedAfterCycleCap(proof, review)) return "accepted-after-cap";
|
|
@@ -9149,7 +9168,10 @@ const resolveReviewTerminalStateForEpoch = (proof, review, epoch, adjudicatedLed
|
|
|
9149
9168
|
const acceptance = resolveReviewAcceptanceForEpoch(proof, review, epoch);
|
|
9150
9169
|
if (acceptance === "accepted-clean") return "clean";
|
|
9151
9170
|
const { autoBlockingFindings, openFindings } = reviewCycleStateForEpoch(proof, epoch, adjudicatedLedger);
|
|
9152
|
-
if (acceptance === "blocked")
|
|
9171
|
+
if (acceptance === "blocked") {
|
|
9172
|
+
if (ladderRefoldDisagreement(epoch).unmatchedDispositions > 0) return "blocked";
|
|
9173
|
+
return openFindings === 0 && autoBlockingFindings === 0 ? "clean" : "blocked";
|
|
9174
|
+
}
|
|
9153
9175
|
return openFindings === 0 ? "clean" : "accepted-with-findings";
|
|
9154
9176
|
};
|
|
9155
9177
|
const resolveReviewTerminalState = (proof, review, policy) => resolveReviewTerminalStateForEpoch(proof, review, reviewEpochModeFor(proof, policy));
|
|
@@ -9475,18 +9497,18 @@ z.object({
|
|
|
9475
9497
|
if (envelope.checkType === "review") {
|
|
9476
9498
|
if (envelope.rung === void 0) context.addIssue({
|
|
9477
9499
|
code: "custom",
|
|
9478
|
-
message: "review-type evidence envelope requires a rung (ADR 0014
|
|
9500
|
+
message: "review-type evidence envelope requires a rung (ADR 0014).",
|
|
9479
9501
|
path: ["rung"]
|
|
9480
9502
|
});
|
|
9481
9503
|
} else {
|
|
9482
9504
|
if (envelope.rung !== void 0) context.addIssue({
|
|
9483
9505
|
code: "custom",
|
|
9484
|
-
message: "verify-type evidence envelope must not carry rung (review-type-only field, ADR 0014
|
|
9506
|
+
message: "verify-type evidence envelope must not carry rung (review-type-only field, ADR 0014).",
|
|
9485
9507
|
path: ["rung"]
|
|
9486
9508
|
});
|
|
9487
9509
|
if (envelope.model !== void 0) context.addIssue({
|
|
9488
9510
|
code: "custom",
|
|
9489
|
-
message: "verify-type evidence envelope must not carry model (review-type-only field, ADR 0014
|
|
9511
|
+
message: "verify-type evidence envelope must not carry model (review-type-only field, ADR 0014).",
|
|
9490
9512
|
path: ["model"]
|
|
9491
9513
|
});
|
|
9492
9514
|
}
|
|
@@ -10708,7 +10730,7 @@ function createBoundaryCheckCommand(output, action) {
|
|
|
10708
10730
|
//#endregion
|
|
10709
10731
|
//#region src/candidate-impact.ts
|
|
10710
10732
|
const CANDIDATE_IMPACT_DECISION_SCHEMA_VERSION = 1;
|
|
10711
|
-
const COMMIT_SHA_PATTERN$
|
|
10733
|
+
const COMMIT_SHA_PATTERN$2 = /^[0-9a-f]{40}$/iu;
|
|
10712
10734
|
const ZERO_SHA_PATTERN$1 = /^0{40}$/u;
|
|
10713
10735
|
const demandedTargets$1 = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
|
|
10714
10736
|
basis: reason,
|
|
@@ -10747,10 +10769,10 @@ const canonicalJson = (content) => {
|
|
|
10747
10769
|
}
|
|
10748
10770
|
};
|
|
10749
10771
|
const assertCommitIdentity$1 = (value, name) => {
|
|
10750
|
-
if (value === void 0 || !COMMIT_SHA_PATTERN$
|
|
10772
|
+
if (value === void 0 || !COMMIT_SHA_PATTERN$2.test(value) || ZERO_SHA_PATTERN$1.test(value)) throw new Error(`${name} must be a non-zero 40-character commit SHA`);
|
|
10751
10773
|
return value.toLowerCase();
|
|
10752
10774
|
};
|
|
10753
|
-
const assertReachableCommit
|
|
10775
|
+
const assertReachableCommit = (cwd, sha, name) => {
|
|
10754
10776
|
try {
|
|
10755
10777
|
runCapture("git", [
|
|
10756
10778
|
"cat-file",
|
|
@@ -10806,8 +10828,8 @@ const classifyCandidateImpact = (args) => {
|
|
|
10806
10828
|
});
|
|
10807
10829
|
const base = assertCommitIdentity$1(args.base, "base");
|
|
10808
10830
|
const head = assertCommitIdentity$1(args.head, "head");
|
|
10809
|
-
assertReachableCommit
|
|
10810
|
-
assertReachableCommit
|
|
10831
|
+
assertReachableCommit(cwd, base, "base");
|
|
10832
|
+
assertReachableCommit(cwd, head, "head");
|
|
10811
10833
|
const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
|
|
10812
10834
|
if (checkedOutHead !== head) throw new Error(`head commit ${head} is not the checked-out HEAD ${checkedOutHead}; refusing to mix commit identities with declarations from another tree`);
|
|
10813
10835
|
const relativeProfilePath = relativeProfile;
|
|
@@ -10819,7 +10841,7 @@ const classifyCandidateImpact = (args) => {
|
|
|
10819
10841
|
base,
|
|
10820
10842
|
head
|
|
10821
10843
|
], cwd).stdout.trim().toLowerCase();
|
|
10822
|
-
if (!COMMIT_SHA_PATTERN$
|
|
10844
|
+
if (!COMMIT_SHA_PATTERN$2.test(mergeBase)) throw new Error(`no merge base exists between base ${base} and head ${head}`);
|
|
10823
10845
|
const changedFiles = [...new Set(filesFromNameStatus(runCapture("git", [
|
|
10824
10846
|
"diff",
|
|
10825
10847
|
"--find-renames",
|
|
@@ -11445,9 +11467,9 @@ const renderCiAnalyzeReport = (report) => {
|
|
|
11445
11467
|
* `gh` CLI, which resolves its own token (GH_TOKEN / GITHUB_TOKEN / login) —
|
|
11446
11468
|
* no credential is stored or minted here.
|
|
11447
11469
|
*/
|
|
11448
|
-
const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
|
|
11470
|
+
const REPOSITORY_PATTERN$1 = /^[\w.-]+\/[\w.-]+$/u;
|
|
11449
11471
|
const parseRepository = (value) => {
|
|
11450
|
-
if (!REPOSITORY_PATTERN.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
|
|
11472
|
+
if (!REPOSITORY_PATTERN$1.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
|
|
11451
11473
|
return value;
|
|
11452
11474
|
};
|
|
11453
11475
|
const parseRunId = (value) => {
|
|
@@ -13900,7 +13922,7 @@ function formatHumanReport(report) {
|
|
|
13900
13922
|
const EPIC_STRUCTURE_SCHEMA_VERSION = 1;
|
|
13901
13923
|
/**
|
|
13902
13924
|
* DAG-node PLANNING statuses — the planning-plane vocabulary a planner authors
|
|
13903
|
-
* on a `dag.yml` node. Carried forward by ADR 0018
|
|
13925
|
+
* on a `dag.yml` node. Carried forward by ADR 0018 and defined by the epic
|
|
13904
13926
|
* skill (`reference/epic-artifacts.md`) and both `CONTEXT.md` glossaries.
|
|
13905
13927
|
*
|
|
13906
13928
|
* A DAG node is a unit of *plan*; an HQ lane is a unit of *runtime execution*.
|
|
@@ -14713,7 +14735,7 @@ const isFactoryReadyCheck = (check) => check.context?.startsWith("patronage-fact
|
|
|
14713
14735
|
* The hosted verification gate's context name — the branch ruleset's other
|
|
14714
14736
|
* source-pinned required check, alongside `patronage-factory/pr-ready`. One
|
|
14715
14737
|
* name across the fleet because one generator emits the workflow that posts
|
|
14716
|
-
* it (ADR 0016
|
|
14738
|
+
* it (ADR 0016).
|
|
14717
14739
|
*/
|
|
14718
14740
|
const HOSTED_VERIFY_CHECK_NAME$1 = "verify";
|
|
14719
14741
|
/**
|
|
@@ -14963,7 +14985,7 @@ function armAutoMerge(input, dependencies = {}) {
|
|
|
14963
14985
|
const MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
|
|
14964
14986
|
const MERGE_FREEZE_APP_SLUG = "patronage-factory";
|
|
14965
14987
|
const HOSTED_VERIFY_CHECK_NAME = "verify";
|
|
14966
|
-
const GITHUB_ACTIONS_APP_ID = 15368;
|
|
14988
|
+
const GITHUB_ACTIONS_APP_ID$1 = 15368;
|
|
14967
14989
|
const GITHUB_ACTIONS_APP_SLUG = "github-actions";
|
|
14968
14990
|
const MERGE_FREEZE_SCHEMA_VERSION = 1;
|
|
14969
14991
|
const CHECK_RUNS_PER_PAGE$1 = 100;
|
|
@@ -14991,7 +15013,7 @@ const inactiveMergeFreezeStateSchema = z.object({
|
|
|
14991
15013
|
const mergeFreezeStateSchema = z.discriminatedUnion("active", [activeMergeFreezeStateSchema, inactiveMergeFreezeStateSchema]);
|
|
14992
15014
|
/**
|
|
14993
15015
|
* The write side of this contract lives in the generated merge-target push
|
|
14994
|
-
* Verify workflow (#356, ADR 0016
|
|
15016
|
+
* Verify workflow (#356, ADR 0016; #429): it is the ONLY producer of
|
|
14995
15017
|
* `patronage-factory/merge-freeze` generations. Its emitted `output.text`
|
|
14996
15018
|
* payload must parse under this exact reader schema, which is what the
|
|
14997
15019
|
* workflow's own tests assert through this export.
|
|
@@ -15028,7 +15050,7 @@ const completedMergeFreezeCheckRunSchema = mergeFreezeCheckRunListItemSchema.ext
|
|
|
15028
15050
|
});
|
|
15029
15051
|
const hostedVerifyCheckRunSchema = z.object({
|
|
15030
15052
|
app: z.object({
|
|
15031
|
-
id: z.literal(GITHUB_ACTIONS_APP_ID),
|
|
15053
|
+
id: z.literal(GITHUB_ACTIONS_APP_ID$1),
|
|
15032
15054
|
slug: z.literal(GITHUB_ACTIONS_APP_SLUG)
|
|
15033
15055
|
}),
|
|
15034
15056
|
head_sha: shaSchema$2,
|
|
@@ -15224,7 +15246,7 @@ const activeMergeFreezeReason = (reason) => `Merge freeze is active (${reason}).
|
|
|
15224
15246
|
*/
|
|
15225
15247
|
const unreadableMergeFreezeReason = (headSha, detail) => `Merge freeze refuses this candidate: the authoritative ${MERGE_FREEZE_CHECK_NAME} state for its base tip ${headSha} could not be read (${detail}). Which recovery applies is what that detail says: restore readability where the authority is missing or unreachable — the merge-target push Verify workflow generated, its writer App credentials configured, GitHub's Checks API reachable with read access — and/or let a fresh valid App-owned generation supersede a defective one, which a later Verify push to this base branch normally produces. Then rerun pr:ready. An operator may waive it for one candidate with patronage-factory demand:waive --pr <pr> --demand merge-freeze --rationale <why>.`;
|
|
15226
15248
|
/** What every settling notice says after it has named its own witness. */
|
|
15227
|
-
const SETTLING_MERGE_FREEZE_TAIL = "readiness proceeds (ADR 0016
|
|
15249
|
+
const SETTLING_MERGE_FREEZE_TAIL = "readiness proceeds (ADR 0016). If this wave authorizes machine merge, arming follows the admission decision. The Verify run for that tip still refuses every later readiness decision against this base branch if it goes red.";
|
|
15228
15250
|
/**
|
|
15229
15251
|
* The notice each settling phase earns, from the witness that phase carries.
|
|
15230
15252
|
*
|
|
@@ -15239,7 +15261,7 @@ const settlingMergeFreezeNotice = (headSha, generation) => {
|
|
|
15239
15261
|
};
|
|
15240
15262
|
/**
|
|
15241
15263
|
* The freeze read during readiness, before any authorized arming (#477,
|
|
15242
|
-
* ADR 0016
|
|
15264
|
+
* ADR 0016).
|
|
15243
15265
|
*
|
|
15244
15266
|
* An **active** generation refuses readiness and names both exits. **Unreadable**
|
|
15245
15267
|
* authority refuses too — the identity and ordering predicates are unchanged,
|
|
@@ -17568,11 +17590,12 @@ const priorLadderStateFor = ({ base, cwd, headSha, isAncestor, output }) => {
|
|
|
17568
17590
|
}, isAncestor) ? prior.ladder : void 0;
|
|
17569
17591
|
};
|
|
17570
17592
|
const normalizedDispositionKey = ({ file, title }) => `(title=${JSON.stringify(normalizeTitle(title))}, file=${file === void 0 ? "<unspecified>" : JSON.stringify(normalizeFile(file))})`;
|
|
17571
|
-
const assertDeclaredDispositionsMatch = (priorLedger, declarations) => {
|
|
17572
|
-
const unmatched = declarations.filter((declaration) => !priorLedger.some((entry) => declarationMatchesEntry(entry, declaration.finding)));
|
|
17593
|
+
const assertDeclaredDispositionsMatch = (priorLedger, declarations, currentFindings = []) => {
|
|
17594
|
+
const unmatched = declarations.filter((declaration) => !priorLedger.some((entry) => declarationMatchesEntry(entry, declaration.finding)) && !currentFindings.some((finding) => declarationMatchesEntry(finding, declaration.finding)));
|
|
17573
17595
|
if (unmatched.length > 0) {
|
|
17574
17596
|
const unmatchedKeys = unmatched.map((declaration) => normalizedDispositionKey(declaration.finding)).join("; ");
|
|
17575
|
-
const
|
|
17597
|
+
const availableTargets = [...priorLedger.map((entry) => normalizedDispositionKey(entry)), ...currentFindings.map((finding) => normalizedDispositionKey(finding))];
|
|
17598
|
+
const availableKeys = availableTargets.length === 0 ? "prior ledger is empty" : availableTargets.join("; ");
|
|
17576
17599
|
throw new Error(`--dispositions entries match no ledger finding. Unmatched normalized declaration keys: ${unmatchedKeys}. Available normalized ledger keys: ${availableKeys}. Declarations are keyed by normalized title plus file; check both against the persisted ladder ledger.`);
|
|
17577
17600
|
}
|
|
17578
17601
|
};
|
|
@@ -17594,10 +17617,19 @@ function ladderStateForRun({ declaredDispositions, cycle, historical, identity,
|
|
|
17594
17617
|
...sessionAttribution?.sessionId ? { sessionId: sessionAttribution.sessionId } : {},
|
|
17595
17618
|
...sessionAttribution?.runtime ? { runtime: sessionAttribution.runtime } : {}
|
|
17596
17619
|
};
|
|
17620
|
+
const sameCycleDeclared = historical === true ? declaredDispositions.flatMap((declaration) => {
|
|
17621
|
+
const finding = findings.find((candidate) => declarationMatchesEntry(candidate, declaration.finding));
|
|
17622
|
+
return finding === void 0 ? [] : [{
|
|
17623
|
+
...declaration,
|
|
17624
|
+
findingReport: finding
|
|
17625
|
+
}];
|
|
17626
|
+
}) : [];
|
|
17627
|
+
const priorDeclared = historical === true ? declaredDispositions.filter((declaration) => !findings.some((candidate) => declarationMatchesEntry(candidate, declaration.finding))) : declaredDispositions;
|
|
17597
17628
|
const currentCycle = {
|
|
17598
17629
|
dispositions: [
|
|
17599
17630
|
...inferenceSafe ? inferFixedInThreadDispositions(priorLedger, findings, { sameHeadAsPriorCycle }) : [],
|
|
17600
|
-
...
|
|
17631
|
+
...priorDeclared,
|
|
17632
|
+
...sameCycleDeclared,
|
|
17601
17633
|
...priorCycles.length >= 1 || (cycle ?? 1) >= 2 ? findings.filter((finding) => {
|
|
17602
17634
|
const severity = resolveReviewFindingSeverity(finding.priority);
|
|
17603
17635
|
return severity === "medium" || severity === "low";
|
|
@@ -17675,7 +17707,7 @@ ${waveRung === void 0 ? "" : ` "producer": "<the engine and model that ran this
|
|
|
17675
17707
|
}
|
|
17676
17708
|
${authorityRule(waveRung)}
|
|
17677
17709
|
${SESSION_ID_RULE}
|
|
17678
|
-
Use category "correctness", "safety", "coordination", or "maintainability"; set blockingAfterCap true for correctness, safety, or coordination findings. citedSpan must be the exact code text the finding is about, quoted verbatim from the diff (identity is derived from this, not from the title, so quote precisely). prescribedAction must be a concise, standalone statement of the required fix (identity is derived from this text too)
|
|
17710
|
+
Use category "correctness", "safety", "coordination", or "maintainability"; set blockingAfterCap true for correctness, safety, or coordination findings. citedSpan must be the exact code text the finding is about, quoted verbatim from the diff (identity is derived from this, not from the title, so quote precisely). prescribedAction must be a concise, standalone statement of the required fix (identity is derived from this text too).`;
|
|
17679
17711
|
};
|
|
17680
17712
|
//#endregion
|
|
17681
17713
|
//#region src/review-prompt.ts
|
|
@@ -17756,7 +17788,7 @@ ${focus.text}`
|
|
|
17756
17788
|
const priorLedgerEntryLine = (entry) => {
|
|
17757
17789
|
const location = entry.file ? ` (${entry.file})` : "";
|
|
17758
17790
|
const reference = entry.reference ? ` — ${entry.reference}` : "";
|
|
17759
|
-
return `- [${entry.disposition}] "${entry.title}"${location}${reference} (flagged ${entry.flagCount}x)`;
|
|
17791
|
+
return `- [${entry.disposition === "open" ? "open, unadjudicated" : entry.disposition}] "${entry.title}"${location}${reference} (flagged ${entry.flagCount}x)`;
|
|
17760
17792
|
};
|
|
17761
17793
|
const priorLedgerSegment = (entries) => ({
|
|
17762
17794
|
section: {
|
|
@@ -17764,13 +17796,13 @@ const priorLedgerSegment = (entries) => ({
|
|
|
17764
17796
|
source: "persisted pr:review disposition ledger (prior cycles, #233)",
|
|
17765
17797
|
text: entries.map(priorLedgerEntryLine).join("\n")
|
|
17766
17798
|
},
|
|
17767
|
-
text: `Prior review ledger (source: persisted pr:review disposition ledger from earlier cycles on this PR
|
|
17799
|
+
text: `Prior review ledger (source: persisted pr:review disposition ledger from earlier cycles on this PR):
|
|
17768
17800
|
${entries.map(priorLedgerEntryLine).join("\n")}
|
|
17769
17801
|
|
|
17770
|
-
|
|
17802
|
+
Open entries above are unadjudicated: they still need a disposition or a supersedes citation. Do not re-raise any already-adjudicated finding (waived, filed as a follow-up, or fixed in this thread) under a new title. If this finding is a restatement of a prior finding shown in this ledger, set supersedes to {"title": "<exact prior title>", "file": "<its file>"} instead of minting a new title for the same theme.`
|
|
17771
17803
|
});
|
|
17772
17804
|
const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile, waveRung }, dependencies = {}) => {
|
|
17773
|
-
const priorLedger = priorLedgerEntries
|
|
17805
|
+
const priorLedger = priorLedgerEntries ?? [];
|
|
17774
17806
|
const optionalSegments = {
|
|
17775
17807
|
"coding-standards": codingStandardsSegment({
|
|
17776
17808
|
contents: kind === "correctness" ? readCodingStandardsAtHead({
|
|
@@ -17991,7 +18023,9 @@ const reviewResults = (findingsFile, kinds, startedAt, endedAt) => {
|
|
|
17991
18023
|
const assertReviewOutcomes = (proof, policy, cycle, maxCycles) => {
|
|
17992
18024
|
if (!proof.reviews.some((review) => resolveReviewAcceptance(proof, review, policy) === "blocked")) return;
|
|
17993
18025
|
const state = reviewCycleStateFor(proof, policy);
|
|
17994
|
-
|
|
18026
|
+
if (proof.reviews.some((review) => review.outcome === "passed")) console.error(`Stored review outcome is passed, but a re-fold of persisted cycles yields ${state.autoBlockingFindings} auto-blocking finding(s) and ${state.openFindings} open finding(s). The stored pass and the ladder disagree.`);
|
|
18027
|
+
else if (cycle >= maxCycles) console.error(`Review cycle limit reached (${cycle}/${maxCycles}). Stop and request human direction before rerunning.`);
|
|
18028
|
+
else console.error(`Fix ${state.openFindings} actionable finding(s) before rerunning pr:review as cycle ${cycle + 1}/${maxCycles}.`);
|
|
17995
18029
|
throw new Error("pr:review found actionable review findings.");
|
|
17996
18030
|
};
|
|
17997
18031
|
const loadIssueFocus = (issue, profile, fetchIssueBody) => {
|
|
@@ -18124,7 +18158,7 @@ const resolveFindingsBinding = ({ args, cwd, findingsPatchId, git, headSha, patc
|
|
|
18124
18158
|
});
|
|
18125
18159
|
if (args.historical !== true) {
|
|
18126
18160
|
if (atCandidate) return { kind: "candidate" };
|
|
18127
|
-
throw new Error(`Review findings patchId ${findingsPatchId} does not match the candidate stable patch-id ${patchId}. Every fix moves the patch-id, so a cycle must be recorded before the fixes that answer it. If these findings read an earlier commit on this branch, record them with pr:review --historical
|
|
18161
|
+
throw new Error(`Review findings patchId ${findingsPatchId} does not match the candidate stable patch-id ${patchId}. Every fix moves the patch-id, so a cycle must be recorded before the fixes that answer it. If these findings read an earlier commit on this branch, record them first with pr:review --historical --findings <path>: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. That historical recording may carry --dispositions for the findings it itself flags. Otherwise declare each finding the fixes answered with --dispositions on the next cycle, once the findings are persisted ledger entries.`);
|
|
18128
18162
|
}
|
|
18129
18163
|
if (atCandidate) throw new Error(`--historical findings already match the candidate stable patch-id ${patchId}. Record them as the current cycle, without --historical.`);
|
|
18130
18164
|
const ancestors = resolveAncestorCandidatePatchIds({
|
|
@@ -18141,6 +18175,7 @@ const resolveFindingsBinding = ({ args, cwd, findingsPatchId, git, headSha, patc
|
|
|
18141
18175
|
ref: matched.ref
|
|
18142
18176
|
};
|
|
18143
18177
|
};
|
|
18178
|
+
const currentCycleDispositionTargets = (historical, findingsFile) => historical ? findingsFile.reviews.flatMap((review) => review.findings) : [];
|
|
18144
18179
|
/** The flag combinations that describe no single run. */
|
|
18145
18180
|
const assertReviewFlagsCombine = (args) => {
|
|
18146
18181
|
if (args.assemble === true && args.findings !== void 0) throw new Error("--assemble and --findings cannot be combined; they are sequential steps.");
|
|
@@ -18363,8 +18398,8 @@ async function runPrReview(args, dependencies = {}) {
|
|
|
18363
18398
|
});
|
|
18364
18399
|
const priorLedger = priorLedgerFor(priorLadder);
|
|
18365
18400
|
const declaredDispositions = declaredDispositionsFor(args.dispositions, cwd);
|
|
18366
|
-
assertDeclaredDispositionsMatch(priorLedger, declaredDispositions);
|
|
18367
18401
|
const historical = binding.kind === "historical";
|
|
18402
|
+
assertDeclaredDispositionsMatch(priorLedger, declaredDispositions, currentCycleDispositionTargets(historical, findingsFile));
|
|
18368
18403
|
const { identity, reviews } = cycleRecordFor({
|
|
18369
18404
|
binding,
|
|
18370
18405
|
cwd,
|
|
@@ -18463,7 +18498,7 @@ function editPullRequestBody({ body, owner, repo, pr }) {
|
|
|
18463
18498
|
repo
|
|
18464
18499
|
}), process.cwd());
|
|
18465
18500
|
}
|
|
18466
|
-
const defaultGhRunner = (args, cwd) => runCapture("gh", args, cwd).stdout;
|
|
18501
|
+
const defaultGhRunner$1 = (args, cwd) => runCapture("gh", args, cwd).stdout;
|
|
18467
18502
|
const resolveFollowUpArgv = (followUp, factoryCliInvocation) => {
|
|
18468
18503
|
const [command, ...args] = followUp.argv;
|
|
18469
18504
|
if (!command) throw new Error("pr:ready returned an empty follow-up argv.");
|
|
@@ -18961,7 +18996,7 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
18961
18996
|
args,
|
|
18962
18997
|
branch: args.pr === void 0 ? (dependencies.currentBranch ?? currentBranchName)(cwd) : void 0,
|
|
18963
18998
|
cwd,
|
|
18964
|
-
gh: dependencies.gh ?? defaultGhRunner
|
|
18999
|
+
gh: dependencies.gh ?? defaultGhRunner$1
|
|
18965
19000
|
});
|
|
18966
19001
|
const readyArgs = {
|
|
18967
19002
|
...args,
|
|
@@ -19165,7 +19200,7 @@ function createPrPublishCommand(_output, action = runPrPublish) {
|
|
|
19165
19200
|
//#endregion
|
|
19166
19201
|
//#region src/commands/pr-ready.ts
|
|
19167
19202
|
function createPrReadyCommand(output, action) {
|
|
19168
|
-
return markCwdOptionDefault(new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger$1("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014
|
|
19203
|
+
return markCwdOptionDefault(new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger$1("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014)").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; readiness then evaluates the wave-demanded review rung (#351, #789)", positiveInteger$1("--epic")).option("--json", "print the readiness proof as JSON").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
|
|
19169
19204
|
gate: "pr:ready",
|
|
19170
19205
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
19171
19206
|
stderr: output.stderr
|
|
@@ -19212,7 +19247,7 @@ function assertForegroundGate({ gate, env = process.env }) {
|
|
|
19212
19247
|
//#endregion
|
|
19213
19248
|
//#region src/commands/pr-review.ts
|
|
19214
19249
|
function createPrReviewCommand(output, action) {
|
|
19215
|
-
return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; --assemble then asks the reviewer for the findings schema the wave-demanded review rung accepts (#1001)", positiveInteger$1("--epic")).option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched.
|
|
19250
|
+
return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; --assemble then asks the reviewer for the findings schema the wave-demanded review rung accepts (#1001)", positiveInteger$1("--epic")).option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. This recording may carry --dispositions for the findings it flags; otherwise declare them on the next cycle once they are persisted ledger entries").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings, or for the findings a --historical recording itself flags").option("--issue <number>", "issue number recorded on the review ladder trace; --assemble also loads Review focus from this issue", positiveInteger$1("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
|
|
19216
19251
|
gate: "pr:review",
|
|
19217
19252
|
resolveCycle: (options) => options.cycle,
|
|
19218
19253
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
@@ -19270,56 +19305,544 @@ function modeFor(options) {
|
|
|
19270
19305
|
if (options.full) return "full";
|
|
19271
19306
|
return "auto";
|
|
19272
19307
|
}
|
|
19308
|
+
/** The GitHub deployment `task` that names receipts this tool wrote. */
|
|
19309
|
+
const DEPLOYMENT_RECEIPT_TASK = "patronage-factory/deployment";
|
|
19310
|
+
/** The `performed_via_github_app.id` of GitHub Actions itself. */
|
|
19311
|
+
const GITHUB_ACTIONS_APP_ID = 15368;
|
|
19312
|
+
const RECEIPT_PAGE_SIZE = 100;
|
|
19313
|
+
var DeploymentReceiptTransportError = class extends Error {
|
|
19314
|
+
code;
|
|
19315
|
+
constructor(code) {
|
|
19316
|
+
super(`deployment receipt transport failed: ${code}`);
|
|
19317
|
+
this.name = "DeploymentReceiptTransportError";
|
|
19318
|
+
this.code = code;
|
|
19319
|
+
}
|
|
19320
|
+
};
|
|
19321
|
+
/** The code a failure is reported as; anything not classified is `transport`. */
|
|
19322
|
+
const transportCode = (error) => error instanceof DeploymentReceiptTransportError ? error.code : "transport";
|
|
19323
|
+
const COMMIT_SHA_PATTERN$1 = /^[0-9a-f]{40}$/u;
|
|
19324
|
+
const REPOSITORY_PATTERN = /^[^/\s]+\/[^/\s]+$/u;
|
|
19325
|
+
const RUN_ID_PATTERN = /^[1-9]\d*$/u;
|
|
19326
|
+
const WORKFLOW_REF_PATTERN = /^(?<repository>[^/\s]+\/[^/\s]+)\/(?<path>\.github\/workflows\/[^@\s]+)@(?<ref>refs\/\S+)$/u;
|
|
19327
|
+
/**
|
|
19328
|
+
* Read the run identity from GitHub Actions environment variables. Returns
|
|
19329
|
+
* the missing or malformed variable names when the environment is not a
|
|
19330
|
+
* workflow run; the caller then refuses to read or write receipts.
|
|
19331
|
+
*/
|
|
19332
|
+
const workflowRunIdentityFromEnv = (env) => {
|
|
19333
|
+
const invalid = [];
|
|
19334
|
+
const workflowRef = WORKFLOW_REF_PATTERN.exec(env.GITHUB_WORKFLOW_REF ?? "");
|
|
19335
|
+
const repository = env.GITHUB_REPOSITORY ?? "";
|
|
19336
|
+
const runId = env.GITHUB_RUN_ID ?? "";
|
|
19337
|
+
const runAttempt = env.GITHUB_RUN_ATTEMPT ?? "";
|
|
19338
|
+
const serverUrl = env.GITHUB_SERVER_URL ?? "";
|
|
19339
|
+
const ref = env.GITHUB_REF ?? "";
|
|
19340
|
+
if (!REPOSITORY_PATTERN.test(repository)) invalid.push("GITHUB_REPOSITORY");
|
|
19341
|
+
if (!workflowRef?.groups || REPOSITORY_PATTERN.test(repository) && workflowRef.groups.repository !== repository) invalid.push("GITHUB_WORKFLOW_REF");
|
|
19342
|
+
if (!RUN_ID_PATTERN.test(runId)) invalid.push("GITHUB_RUN_ID");
|
|
19343
|
+
if (!RUN_ID_PATTERN.test(runAttempt)) invalid.push("GITHUB_RUN_ATTEMPT");
|
|
19344
|
+
if (!/^https:\/\/[^/\s]+$/u.test(serverUrl)) invalid.push("GITHUB_SERVER_URL");
|
|
19345
|
+
if (!ref.startsWith("refs/")) invalid.push("GITHUB_REF");
|
|
19346
|
+
if (invalid.length > 0 || !workflowRef?.groups?.path) return { invalid };
|
|
19347
|
+
return { identity: {
|
|
19348
|
+
ref,
|
|
19349
|
+
repository,
|
|
19350
|
+
runAttempt: Number(runAttempt),
|
|
19351
|
+
runId,
|
|
19352
|
+
serverUrl,
|
|
19353
|
+
workflowPath: workflowRef.groups.path
|
|
19354
|
+
} };
|
|
19355
|
+
};
|
|
19356
|
+
/** The payload this tool writes into every deployment it creates. */
|
|
19357
|
+
const receiptPayloadSchema = z.object({
|
|
19358
|
+
repository: z.string().regex(REPOSITORY_PATTERN),
|
|
19359
|
+
schemaVersion: z.literal(1),
|
|
19360
|
+
sourceSha: z.string().regex(COMMIT_SHA_PATTERN$1),
|
|
19361
|
+
stage: z.string().min(1).optional(),
|
|
19362
|
+
target: z.string().min(1),
|
|
19363
|
+
tool: z.object({
|
|
19364
|
+
name: z.literal(name),
|
|
19365
|
+
version: z.string().min(1)
|
|
19366
|
+
}),
|
|
19367
|
+
workflow: z.object({
|
|
19368
|
+
path: z.string().min(1),
|
|
19369
|
+
ref: z.string().min(1),
|
|
19370
|
+
runAttempt: z.number().int().positive(),
|
|
19371
|
+
runId: z.string().regex(RUN_ID_PATTERN)
|
|
19372
|
+
})
|
|
19373
|
+
});
|
|
19374
|
+
const deploymentRecordSchema = z.object({
|
|
19375
|
+
created_at: z.string(),
|
|
19376
|
+
environment: z.string(),
|
|
19377
|
+
id: z.number().int(),
|
|
19378
|
+
/** Fixed at creation. `environment` can be rewritten by a later status write. */
|
|
19379
|
+
original_environment: z.string(),
|
|
19380
|
+
payload: z.unknown(),
|
|
19381
|
+
performed_via_github_app: z.object({ id: z.number().int() }).nullable().optional(),
|
|
19382
|
+
sha: z.string(),
|
|
19383
|
+
task: z.string()
|
|
19384
|
+
});
|
|
19385
|
+
const workflowRunJobSchema = z.object({
|
|
19386
|
+
conclusion: z.string().nullable(),
|
|
19387
|
+
head_sha: z.string(),
|
|
19388
|
+
id: z.number().int(),
|
|
19389
|
+
name: z.string(),
|
|
19390
|
+
steps: z.array(z.object({
|
|
19391
|
+
conclusion: z.string().nullable(),
|
|
19392
|
+
name: z.string()
|
|
19393
|
+
})).optional()
|
|
19394
|
+
});
|
|
19395
|
+
const workflowRunRecordSchema = z.object({
|
|
19396
|
+
head_branch: z.string().nullable(),
|
|
19397
|
+
head_sha: z.string(),
|
|
19398
|
+
id: z.number().int(),
|
|
19399
|
+
path: z.string(),
|
|
19400
|
+
repository: z.object({ full_name: z.string() })
|
|
19401
|
+
});
|
|
19402
|
+
/** The deployment environment one target's receipts live in. */
|
|
19403
|
+
const deploymentReceiptEnvironment = (target) => target;
|
|
19404
|
+
const runUrl = (identity, runId) => `${identity.serverUrl}/${identity.repository}/actions/runs/${runId}`;
|
|
19405
|
+
/** The `$GITHUB_OUTPUT` key a success publication writes the new deployment id under. */
|
|
19406
|
+
const RECEIPT_DEPLOYMENT_ID_OUTPUT = "deployment_id";
|
|
19407
|
+
/**
|
|
19408
|
+
* The name of the generated step that binds one deployment record to the run
|
|
19409
|
+
* that created it. The identity is the record's server-assigned id alone,
|
|
19410
|
+
* so no two records render the same name and no target or stage text takes
|
|
19411
|
+
* part. `factoryProductionImpactWorkflow().receiptSteps()` emits this text
|
|
19412
|
+
* with the id as a step-output expression; the production-impact integration
|
|
19413
|
+
* test holds the two together. The reader looks the rendered name up in
|
|
19414
|
+
* GitHub's job record, where only the run itself can have put it.
|
|
19415
|
+
*/
|
|
19416
|
+
const boundReceiptStepName = (deploymentId) => `Bind deployment receipt ${deploymentId}`;
|
|
19417
|
+
/**
|
|
19418
|
+
* Bind the deployment record and its payload to this reader: performed by
|
|
19419
|
+
* GitHub Actions, written by this tool for this repository, target, stage,
|
|
19420
|
+
* source, workflow, and ref.
|
|
19421
|
+
*/
|
|
19422
|
+
const bindReceiptPayload = (args, deployment) => {
|
|
19423
|
+
const label = `deployment ${deployment.id}`;
|
|
19424
|
+
if (deployment.performed_via_github_app?.id !== GITHUB_ACTIONS_APP_ID) return { reason: `${label} was not performed by GitHub Actions` };
|
|
19425
|
+
if (deployment.task !== "patronage-factory/deployment") return { reason: `${label} has a foreign task, not a factory receipt` };
|
|
19426
|
+
const payload = receiptPayloadSchema.safeParse(deployment.payload);
|
|
19427
|
+
if (!payload.success) return { reason: `${label} payload is not a schema-version-1 factory receipt` };
|
|
19428
|
+
const receipt = payload.data;
|
|
19429
|
+
if (receipt.repository !== args.identity.repository) return { reason: `${label} names another repository` };
|
|
19430
|
+
if (receipt.target !== args.target || receipt.stage !== args.stage) return { reason: `${label} names another target or stage` };
|
|
19431
|
+
if (receipt.sourceSha !== deployment.sha.toLowerCase()) return { reason: `${label} payload source ${receipt.sourceSha} differs from its deployment sha` };
|
|
19432
|
+
if (receipt.workflow.path !== args.identity.workflowPath) return { reason: `${label} was written by another workflow file, not ${args.identity.workflowPath}` };
|
|
19433
|
+
if (receipt.workflow.ref !== args.identity.ref) return { reason: `${label} was written for another ref, not ${args.identity.ref}` };
|
|
19434
|
+
return { receipt };
|
|
19435
|
+
};
|
|
19436
|
+
/**
|
|
19437
|
+
* Judge one deployment record. The payload bindings come first; then the run
|
|
19438
|
+
* GitHub records for the payload's run id must agree with the receipt and
|
|
19439
|
+
* must have bound this record's id. The first failure names the receipt
|
|
19440
|
+
* untrusted. Statuses are not read: they are caller-written.
|
|
19441
|
+
*/
|
|
19442
|
+
const trustReceipt = (args, deployment) => {
|
|
19443
|
+
const label = `deployment ${deployment.id}`;
|
|
19444
|
+
const bound = bindReceiptPayload(args, deployment);
|
|
19445
|
+
if ("reason" in bound) return bound;
|
|
19446
|
+
const { receipt } = bound;
|
|
19447
|
+
let runRecord;
|
|
19448
|
+
let jobRecords;
|
|
19449
|
+
try {
|
|
19450
|
+
runRecord = args.transport.getWorkflowRun(receipt.workflow.runId);
|
|
19451
|
+
jobRecords = args.transport.listWorkflowRunJobs(receipt.workflow.runId);
|
|
19452
|
+
} catch (error) {
|
|
19453
|
+
return { reason: `${label} names run ${receipt.workflow.runId}, which could not be read (transport error code: ${transportCode(error)})` };
|
|
19454
|
+
}
|
|
19455
|
+
const run = workflowRunRecordSchema.safeParse(runRecord);
|
|
19456
|
+
if (!run.success) return { reason: `${label} names run ${receipt.workflow.runId}, which GitHub does not record` };
|
|
19457
|
+
const expectedBranch = args.identity.ref.replace(/^refs\/heads\//u, "");
|
|
19458
|
+
if (run.data.path !== args.identity.workflowPath || run.data.repository.full_name !== args.identity.repository || run.data.head_sha.toLowerCase() !== receipt.sourceSha || run.data.head_branch !== expectedBranch) return { reason: `${label} names run ${receipt.workflow.runId}, whose recorded workflow, repository, head, or branch disagrees with the receipt` };
|
|
19459
|
+
const jobs = z.object({ jobs: z.array(workflowRunJobSchema) }).safeParse(jobRecords);
|
|
19460
|
+
if (!jobs.success) return { reason: `${label} names run ${receipt.workflow.runId}, whose job records are malformed` };
|
|
19461
|
+
const stepName = boundReceiptStepName(deployment.id);
|
|
19462
|
+
if (jobs.data.jobs.find((job) => job.conclusion === "success" && job.head_sha.toLowerCase() === receipt.sourceSha && job.steps?.some((step) => step.name === stepName && step.conclusion === "success")) === void 0) return { reason: `${label} names run ${receipt.workflow.runId}, but no succeeded job of that run at ${receipt.sourceSha} bound deployment ${deployment.id}` };
|
|
19463
|
+
return {
|
|
19464
|
+
baseline: {
|
|
19465
|
+
deploymentId: deployment.id,
|
|
19466
|
+
runId: receipt.workflow.runId,
|
|
19467
|
+
sha: receipt.sourceSha
|
|
19468
|
+
},
|
|
19469
|
+
trusted: true
|
|
19470
|
+
};
|
|
19471
|
+
};
|
|
19472
|
+
/**
|
|
19473
|
+
* Resolve the trusted baseline for one target from GitHub deployment
|
|
19474
|
+
* records. The repository's newest page of records is read once and the
|
|
19475
|
+
* target's records are those whose `original_environment` is the target;
|
|
19476
|
+
* a target whose newest record is older than that page has no baseline. The
|
|
19477
|
+
* newest such record decides: bound by
|
|
19478
|
+
* its run's job record, it is the baseline; otherwise the baseline is
|
|
19479
|
+
* absent, and an older trusted receipt does not rescue it. Failed, partial,
|
|
19480
|
+
* foreign, and copied records are all unbound, so every one of them demands
|
|
19481
|
+
* reconciliation. Do not declare `environment: <target>` on a job; the
|
|
19482
|
+
* record GitHub creates for it is unbound too.
|
|
19483
|
+
*/
|
|
19484
|
+
const resolveDeploymentBaseline = (args) => {
|
|
19485
|
+
const environment = deploymentReceiptEnvironment(args.target);
|
|
19486
|
+
let deployments;
|
|
19487
|
+
try {
|
|
19488
|
+
const parsed = z.array(deploymentRecordSchema).safeParse(args.transport.listDeployments());
|
|
19489
|
+
if (!parsed.success) return {
|
|
19490
|
+
kind: "absent",
|
|
19491
|
+
reasons: [`deployment records for ${environment} are malformed`]
|
|
19492
|
+
};
|
|
19493
|
+
deployments = parsed.data.filter((deployment) => deployment.original_environment === environment).toSorted((left, right) => left.created_at === right.created_at ? right.id - left.id : right.created_at.localeCompare(left.created_at));
|
|
19494
|
+
} catch (error) {
|
|
19495
|
+
return {
|
|
19496
|
+
kind: "absent",
|
|
19497
|
+
reasons: [`deployment records for ${environment} could not be read (transport error code: ${transportCode(error)})`]
|
|
19498
|
+
};
|
|
19499
|
+
}
|
|
19500
|
+
const [newest] = deployments;
|
|
19501
|
+
if (newest === void 0) return {
|
|
19502
|
+
kind: "absent",
|
|
19503
|
+
reasons: [`no deployment receipt for ${environment}${args.stage ? ` stage ${args.stage}` : ""}`]
|
|
19504
|
+
};
|
|
19505
|
+
const verdict = trustReceipt(args, newest);
|
|
19506
|
+
if ("trusted" in verdict) return {
|
|
19507
|
+
baseline: verdict.baseline,
|
|
19508
|
+
kind: "trusted"
|
|
19509
|
+
};
|
|
19510
|
+
return {
|
|
19511
|
+
kind: "absent",
|
|
19512
|
+
reasons: [`newest receipt is not a bound success: ${verdict.reason}`]
|
|
19513
|
+
};
|
|
19514
|
+
};
|
|
19515
|
+
const createdDeploymentSchema = z.object({ id: z.number().int() });
|
|
19516
|
+
/**
|
|
19517
|
+
* Write one receipt: a deployment bound to this run, then its terminal
|
|
19518
|
+
* status. The payload carries only the fixed identity fields above; no
|
|
19519
|
+
* command output and no secret can enter it. A `success` receipt marks
|
|
19520
|
+
* earlier successful deployments of the environment inactive, so the newest
|
|
19521
|
+
* success is always what production runs.
|
|
19522
|
+
*/
|
|
19523
|
+
const publishDeploymentReceipt = (args) => {
|
|
19524
|
+
const environment = deploymentReceiptEnvironment(args.target);
|
|
19525
|
+
const payload = {
|
|
19526
|
+
repository: args.identity.repository,
|
|
19527
|
+
schemaVersion: 1,
|
|
19528
|
+
sourceSha: args.sourceSha,
|
|
19529
|
+
...args.stage === void 0 ? {} : { stage: args.stage },
|
|
19530
|
+
target: args.target,
|
|
19531
|
+
tool: {
|
|
19532
|
+
name,
|
|
19533
|
+
version
|
|
19534
|
+
},
|
|
19535
|
+
workflow: {
|
|
19536
|
+
path: args.identity.workflowPath,
|
|
19537
|
+
ref: args.identity.ref,
|
|
19538
|
+
runAttempt: args.identity.runAttempt,
|
|
19539
|
+
runId: args.identity.runId
|
|
19540
|
+
}
|
|
19541
|
+
};
|
|
19542
|
+
const created = createdDeploymentSchema.parse(args.transport.createDeployment({
|
|
19543
|
+
auto_merge: false,
|
|
19544
|
+
description: `${DEPLOYMENT_RECEIPT_TASK} ${args.target} at ${args.sourceSha}`,
|
|
19545
|
+
environment,
|
|
19546
|
+
payload,
|
|
19547
|
+
production_environment: true,
|
|
19548
|
+
ref: args.sourceSha,
|
|
19549
|
+
required_contexts: [],
|
|
19550
|
+
task: DEPLOYMENT_RECEIPT_TASK
|
|
19551
|
+
}));
|
|
19552
|
+
args.transport.createDeploymentStatus(created.id, {
|
|
19553
|
+
auto_inactive: args.state === "success",
|
|
19554
|
+
description: `${args.state}: ${args.target} at ${args.sourceSha} by run ${args.identity.runId} attempt ${args.identity.runAttempt}`,
|
|
19555
|
+
log_url: `${runUrl(args.identity, args.identity.runId)}/attempts/${args.identity.runAttempt}`,
|
|
19556
|
+
state: args.state
|
|
19557
|
+
});
|
|
19558
|
+
return {
|
|
19559
|
+
deploymentId: created.id,
|
|
19560
|
+
environment,
|
|
19561
|
+
payload,
|
|
19562
|
+
state: args.state
|
|
19563
|
+
};
|
|
19564
|
+
};
|
|
19565
|
+
const defaultGhRunner = (args, cwd) => runCapture("gh", args, cwd).stdout;
|
|
19566
|
+
const HTTP_STATUS_PATTERN = /\bHTTP (?<status>\d{3})\b/u;
|
|
19567
|
+
/**
|
|
19568
|
+
* Classify a failed `gh` call from the HTTP status gh reports. The error text
|
|
19569
|
+
* is read here and dropped; only the code leaves this function.
|
|
19570
|
+
*/
|
|
19571
|
+
const classifyGhFailure = (error) => {
|
|
19572
|
+
const text = error instanceof Error ? error.message : "";
|
|
19573
|
+
const status = Number(HTTP_STATUS_PATTERN.exec(text)?.groups?.status ?? 0);
|
|
19574
|
+
if (status === 401 || status === 403) return new DeploymentReceiptTransportError("unauthorized");
|
|
19575
|
+
if (status === 404) return new DeploymentReceiptTransportError("not-found");
|
|
19576
|
+
if (status === 429) return new DeploymentReceiptTransportError("rate-limited");
|
|
19577
|
+
if (status >= 500) return new DeploymentReceiptTransportError("unavailable");
|
|
19578
|
+
return new DeploymentReceiptTransportError("transport");
|
|
19579
|
+
};
|
|
19580
|
+
const ghJson = (run, args, cwd) => {
|
|
19581
|
+
let stdout;
|
|
19582
|
+
try {
|
|
19583
|
+
stdout = run(args, cwd);
|
|
19584
|
+
} catch (error) {
|
|
19585
|
+
throw classifyGhFailure(error);
|
|
19586
|
+
}
|
|
19587
|
+
try {
|
|
19588
|
+
return JSON.parse(stdout);
|
|
19589
|
+
} catch {
|
|
19590
|
+
throw new DeploymentReceiptTransportError("malformed-response");
|
|
19591
|
+
}
|
|
19592
|
+
};
|
|
19593
|
+
/**
|
|
19594
|
+
* The `gh`-backed transport. Reads take the first page of 100, which is
|
|
19595
|
+
* newest first; a target with more than 100 deployments still resolves from
|
|
19596
|
+
* the newest. The environment is not filtered by task, so a foreign
|
|
19597
|
+
* deployment of the same environment is judged, not hidden. Writes hand the JSON body to `gh api --input` through a file,
|
|
19598
|
+
* because `runCapture` does not open stdin.
|
|
19599
|
+
*/
|
|
19600
|
+
const ghDeploymentReceiptTransport = (cwd, repository, run = defaultGhRunner) => {
|
|
19601
|
+
const api = (route) => ghJson(run, ["api", route], cwd);
|
|
19602
|
+
const post = (route, body) => {
|
|
19603
|
+
const directory = mkdtempSync(path.join(tmpdir(), "factory-receipt-"));
|
|
19604
|
+
try {
|
|
19605
|
+
const input = path.join(directory, "body.json");
|
|
19606
|
+
writeFileSync(input, JSON.stringify(body), "utf-8");
|
|
19607
|
+
return ghJson(run, [
|
|
19608
|
+
"api",
|
|
19609
|
+
"-X",
|
|
19610
|
+
"POST",
|
|
19611
|
+
route,
|
|
19612
|
+
"--input",
|
|
19613
|
+
input
|
|
19614
|
+
], cwd);
|
|
19615
|
+
} finally {
|
|
19616
|
+
rmSync(directory, {
|
|
19617
|
+
force: true,
|
|
19618
|
+
recursive: true
|
|
19619
|
+
});
|
|
19620
|
+
}
|
|
19621
|
+
};
|
|
19622
|
+
const base = `/repos/${repository}`;
|
|
19623
|
+
return {
|
|
19624
|
+
createDeployment: (body) => post(`${base}/deployments`, body),
|
|
19625
|
+
createDeploymentStatus: (deploymentId, body) => post(`${base}/deployments/${deploymentId}/statuses`, body),
|
|
19626
|
+
getWorkflowRun: (runId) => api(`${base}/actions/runs/${runId}`),
|
|
19627
|
+
listDeploymentStatuses: (deploymentId) => api(`${base}/deployments/${deploymentId}/statuses?per_page=${RECEIPT_PAGE_SIZE}`),
|
|
19628
|
+
listDeployments: () => api(`${base}/deployments?per_page=${RECEIPT_PAGE_SIZE}`),
|
|
19629
|
+
listWorkflowRunJobs: (runId) => api(`${base}/actions/runs/${runId}/jobs?per_page=${RECEIPT_PAGE_SIZE}`)
|
|
19630
|
+
};
|
|
19631
|
+
};
|
|
19273
19632
|
//#endregion
|
|
19274
19633
|
//#region src/production-impact.ts
|
|
19275
|
-
|
|
19634
|
+
/**
|
|
19635
|
+
* Production deployment demand (#1075, epic #1063).
|
|
19636
|
+
*
|
|
19637
|
+
* Two questions live here and they stay separate:
|
|
19638
|
+
*
|
|
19639
|
+
* 1. Per-change impact: does a delta touch a target? `computeImpactStamp`
|
|
19640
|
+
* answers it for any two commits, and `candidate:impact` uses it for pull
|
|
19641
|
+
* requests.
|
|
19642
|
+
* 2. Permission to withdraw a production deploy: may this candidate skip a
|
|
19643
|
+
* target? Only the delta from the target's last trusted successful
|
|
19644
|
+
* deployment receipt to the candidate can answer it. A push's before/after
|
|
19645
|
+
* commits cannot, because a failed or cancelled push leaves its changes
|
|
19646
|
+
* undeployed and the next push's diff does not contain them.
|
|
19647
|
+
*
|
|
19648
|
+
* Every doubt path keeps the target demanded. Only a trusted baseline that
|
|
19649
|
+
* is an ancestor of the candidate and whose delta provably misses the target
|
|
19650
|
+
* withdraws it, and only a trusted baseline that the candidate is an
|
|
19651
|
+
* ancestor of supersedes it.
|
|
19652
|
+
*/
|
|
19653
|
+
const PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION = 2;
|
|
19276
19654
|
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/iu;
|
|
19277
19655
|
const ZERO_SHA_PATTERN = /^0{40}$/u;
|
|
19656
|
+
/**
|
|
19657
|
+
* A refusal this module states in its own fixed words. Only its message may
|
|
19658
|
+
* enter evidence; every other throw is reported by class as a code.
|
|
19659
|
+
*/
|
|
19660
|
+
var RefusalError = class extends Error {
|
|
19661
|
+
constructor(message) {
|
|
19662
|
+
super(message);
|
|
19663
|
+
this.name = "RefusalError";
|
|
19664
|
+
}
|
|
19665
|
+
};
|
|
19666
|
+
/**
|
|
19667
|
+
* Run git for this decision. A failure carries git's stderr, which is
|
|
19668
|
+
* unclassified text, so it is rethrown as the `git` transport code.
|
|
19669
|
+
*/
|
|
19670
|
+
const git = (cwd, args) => {
|
|
19671
|
+
try {
|
|
19672
|
+
return runCapture("git", args, cwd).stdout;
|
|
19673
|
+
} catch {
|
|
19674
|
+
throw new DeploymentReceiptTransportError("git");
|
|
19675
|
+
}
|
|
19676
|
+
};
|
|
19677
|
+
/** The reason a refusal records: fixed words, a code, or a named class. */
|
|
19678
|
+
const refusalReason = (error) => {
|
|
19679
|
+
if (error instanceof RefusalError) return error.message;
|
|
19680
|
+
if (error instanceof DeploymentReceiptTransportError) return `transport error code: ${error.code}`;
|
|
19681
|
+
if (error instanceof ImpactStampConfigError) return "impact declarations contradict the lockfile; run candidate:impact for the detail";
|
|
19682
|
+
return "unclassified error";
|
|
19683
|
+
};
|
|
19278
19684
|
const demandedTargets = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
|
|
19279
19685
|
basis: reason,
|
|
19280
19686
|
name,
|
|
19281
19687
|
state: "demanded"
|
|
19282
19688
|
}));
|
|
19283
|
-
const refusal = ({
|
|
19284
|
-
...after ? { afterSha: after } : {},
|
|
19689
|
+
const refusal = ({ candidate, profile, reason, stage }) => ({
|
|
19285
19690
|
basis: {
|
|
19286
|
-
changedFiles: [],
|
|
19287
19691
|
classification: "refused",
|
|
19288
19692
|
reasons: [reason]
|
|
19289
19693
|
},
|
|
19290
|
-
...
|
|
19694
|
+
...candidate !== void 0 && COMMIT_SHA_PATTERN.test(candidate) && !ZERO_SHA_PATTERN.test(candidate) ? { candidateSha: candidate.toLowerCase() } : {},
|
|
19291
19695
|
decision: "refused",
|
|
19292
19696
|
schemaVersion: PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION,
|
|
19697
|
+
...stage === void 0 ? {} : { stage },
|
|
19293
19698
|
targets: demandedTargets(profile, reason),
|
|
19294
19699
|
unsubscribedPaths: []
|
|
19295
19700
|
});
|
|
19296
19701
|
const assertCommitIdentity = (value, name) => {
|
|
19297
|
-
if (value === void 0 || !COMMIT_SHA_PATTERN.test(value) || ZERO_SHA_PATTERN.test(value)) throw new
|
|
19702
|
+
if (value === void 0 || !COMMIT_SHA_PATTERN.test(value) || ZERO_SHA_PATTERN.test(value)) throw new RefusalError(`${name} must be a non-zero 40-character commit SHA`);
|
|
19298
19703
|
return value.toLowerCase();
|
|
19299
19704
|
};
|
|
19300
|
-
const
|
|
19705
|
+
const isReachableCommit = (cwd, sha) => {
|
|
19301
19706
|
try {
|
|
19302
19707
|
runCapture("git", [
|
|
19303
19708
|
"cat-file",
|
|
19304
19709
|
"-e",
|
|
19305
19710
|
`${sha}^{commit}`
|
|
19306
19711
|
], cwd);
|
|
19712
|
+
return true;
|
|
19307
19713
|
} catch {
|
|
19308
|
-
|
|
19714
|
+
return false;
|
|
19309
19715
|
}
|
|
19310
19716
|
};
|
|
19717
|
+
const assertCheckedOutCandidate = (cwd, candidate) => {
|
|
19718
|
+
if (!isReachableCommit(cwd, candidate)) throw new RefusalError(`candidate commit ${candidate} is not reachable in this checkout`);
|
|
19719
|
+
if (git(cwd, ["rev-parse", "HEAD"]).trim().toLowerCase() !== candidate) throw new RefusalError(`candidate commit ${candidate} is not the checked-out HEAD; refusing to mix commit identities with declarations from another tree`);
|
|
19720
|
+
};
|
|
19721
|
+
const identityFromEnv = () => {
|
|
19722
|
+
const fromEnv = workflowRunIdentityFromEnv(process.env);
|
|
19723
|
+
if ("invalid" in fromEnv) throw new RefusalError(`deployment receipts need a GitHub Actions run identity; missing or malformed: ${fromEnv.invalid.join(", ")}`);
|
|
19724
|
+
return fromEnv.identity;
|
|
19725
|
+
};
|
|
19726
|
+
/**
|
|
19727
|
+
* The run identity and transport the decision reads receipts with. Both come
|
|
19728
|
+
* from the GitHub Actions environment and `gh` unless a test injects them.
|
|
19729
|
+
*/
|
|
19730
|
+
const resolveReceiptSource = (cwd, receipts) => {
|
|
19731
|
+
const identity = receipts?.identity ?? identityFromEnv();
|
|
19732
|
+
return {
|
|
19733
|
+
identity,
|
|
19734
|
+
transport: receipts?.transport ?? ghDeploymentReceiptTransport(cwd, identity.repository)
|
|
19735
|
+
};
|
|
19736
|
+
};
|
|
19737
|
+
const outputState = (state) => state === "superseded" ? "withdrawn" : state;
|
|
19311
19738
|
const writeGithubOutputs = (outputPath, decision) => {
|
|
19312
19739
|
appendFileSync(outputPath, `${[
|
|
19313
19740
|
`decision=${decision.decision}`,
|
|
19314
19741
|
`basis=${JSON.stringify(decision.basis)}`,
|
|
19315
19742
|
`unsubscribed_paths=${JSON.stringify(decision.unsubscribedPaths)}`,
|
|
19316
|
-
...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${target.state}`)
|
|
19743
|
+
...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${outputState(target.state)}`)
|
|
19317
19744
|
].join("\n")}\n`, "utf-8");
|
|
19318
19745
|
};
|
|
19746
|
+
/** Classify the delta from one baseline to the candidate, once per distinct baseline. */
|
|
19747
|
+
const classifyDelta = ({ baseline, candidate, cwd, profile, profilePath }) => {
|
|
19748
|
+
const changedFiles = [...new Set(filesFromNameStatus(git(cwd, [
|
|
19749
|
+
"diff",
|
|
19750
|
+
"--find-renames",
|
|
19751
|
+
"--name-status",
|
|
19752
|
+
baseline,
|
|
19753
|
+
candidate
|
|
19754
|
+
])))].toSorted();
|
|
19755
|
+
const stamp = computeImpactStamp({
|
|
19756
|
+
changedFiles,
|
|
19757
|
+
profile,
|
|
19758
|
+
profilePath: repositoryRelativeProfilePath({
|
|
19759
|
+
cwd,
|
|
19760
|
+
profilePath
|
|
19761
|
+
}),
|
|
19762
|
+
readLockfile: (side) => showFileAtRef(cwd, side === "base" ? baseline : candidate, LOCKFILE_PATH)
|
|
19763
|
+
});
|
|
19764
|
+
return {
|
|
19765
|
+
changedFiles,
|
|
19766
|
+
targets: new Map(stamp.targets.map((target) => [target.name, target.impact])),
|
|
19767
|
+
unsubscribedPaths: stamp.unsubscribedPaths,
|
|
19768
|
+
usable: stamp.basis === "target-scoped"
|
|
19769
|
+
};
|
|
19770
|
+
};
|
|
19771
|
+
const resolveTargets = ({ candidate, cwd, identity, profile, profilePath, stage, transport }) => {
|
|
19772
|
+
const deltas = /* @__PURE__ */ new Map();
|
|
19773
|
+
const unsubscribedPaths = /* @__PURE__ */ new Set();
|
|
19774
|
+
return {
|
|
19775
|
+
targets: (profile.impact?.targets ?? []).map(({ name }) => {
|
|
19776
|
+
const resolution = resolveDeploymentBaseline({
|
|
19777
|
+
identity,
|
|
19778
|
+
stage,
|
|
19779
|
+
target: name,
|
|
19780
|
+
transport
|
|
19781
|
+
});
|
|
19782
|
+
if (resolution.kind === "absent") return {
|
|
19783
|
+
basis: `reconciliation demanded: ${resolution.reasons.join("; ")}`,
|
|
19784
|
+
name,
|
|
19785
|
+
state: "demanded"
|
|
19786
|
+
};
|
|
19787
|
+
const { baseline } = resolution;
|
|
19788
|
+
const bound = {
|
|
19789
|
+
baseline,
|
|
19790
|
+
name
|
|
19791
|
+
};
|
|
19792
|
+
if (baseline.sha === candidate) return {
|
|
19793
|
+
...bound,
|
|
19794
|
+
basis: `trusted baseline ${baseline.sha} is the candidate; already deployed by run ${baseline.runId}`,
|
|
19795
|
+
state: "withdrawn"
|
|
19796
|
+
};
|
|
19797
|
+
if (!isReachableCommit(cwd, baseline.sha)) return {
|
|
19798
|
+
...bound,
|
|
19799
|
+
basis: `reconciliation demanded: trusted baseline ${baseline.sha} is not reachable in this checkout`,
|
|
19800
|
+
state: "demanded"
|
|
19801
|
+
};
|
|
19802
|
+
if (isAncestor(cwd, candidate, baseline.sha)) return {
|
|
19803
|
+
...bound,
|
|
19804
|
+
basis: `candidate ${candidate} is an ancestor of trusted baseline ${baseline.sha}; deploying would regress run ${baseline.runId}`,
|
|
19805
|
+
state: "superseded"
|
|
19806
|
+
};
|
|
19807
|
+
if (!isAncestor(cwd, baseline.sha, candidate)) return {
|
|
19808
|
+
...bound,
|
|
19809
|
+
basis: `reconciliation demanded: trusted baseline ${baseline.sha} is not an ancestor of candidate ${candidate}`,
|
|
19810
|
+
state: "demanded"
|
|
19811
|
+
};
|
|
19812
|
+
let delta = deltas.get(baseline.sha);
|
|
19813
|
+
if (delta === void 0) {
|
|
19814
|
+
delta = classifyDelta({
|
|
19815
|
+
baseline: baseline.sha,
|
|
19816
|
+
candidate,
|
|
19817
|
+
cwd,
|
|
19818
|
+
profile,
|
|
19819
|
+
profilePath
|
|
19820
|
+
});
|
|
19821
|
+
deltas.set(baseline.sha, delta);
|
|
19822
|
+
}
|
|
19823
|
+
for (const unsubscribed of delta.unsubscribedPaths) unsubscribedPaths.add(unsubscribed);
|
|
19824
|
+
const impact = delta.targets.get(name);
|
|
19825
|
+
if (!delta.usable || impact === void 0) return {
|
|
19826
|
+
...bound,
|
|
19827
|
+
basis: `reconciliation demanded: delta from ${baseline.sha} is not target-scoped (conservative impact stamp)`,
|
|
19828
|
+
changedFiles: delta.changedFiles,
|
|
19829
|
+
state: "demanded"
|
|
19830
|
+
};
|
|
19831
|
+
return {
|
|
19832
|
+
...bound,
|
|
19833
|
+
basis: `delta from trusted baseline ${baseline.sha}: target-scoped impact stamp says ${impact}`,
|
|
19834
|
+
changedFiles: delta.changedFiles,
|
|
19835
|
+
state: impact === "not-affected" ? "withdrawn" : "demanded"
|
|
19836
|
+
};
|
|
19837
|
+
}),
|
|
19838
|
+
unsubscribedPaths: [...unsubscribedPaths].toSorted()
|
|
19839
|
+
};
|
|
19840
|
+
};
|
|
19319
19841
|
/**
|
|
19320
|
-
*
|
|
19321
|
-
*
|
|
19322
|
-
*
|
|
19842
|
+
* Resolve production deployment demand for every declared target from its
|
|
19843
|
+
* trusted deployment baseline to the checked-out candidate. The result may
|
|
19844
|
+
* withdraw or supersede consumer work only through a usable decision; every
|
|
19845
|
+
* refusal keeps every known target demanded.
|
|
19323
19846
|
*/
|
|
19324
19847
|
const runProductionImpact = (args) => {
|
|
19325
19848
|
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
@@ -19327,108 +19850,152 @@ const runProductionImpact = (args) => {
|
|
|
19327
19850
|
let profilePath;
|
|
19328
19851
|
let decision;
|
|
19329
19852
|
try {
|
|
19330
|
-
const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
|
|
19331
|
-
cwd,
|
|
19332
|
-
profilePath: args.profilePath
|
|
19333
|
-
});
|
|
19334
|
-
profile = loadedProfile;
|
|
19335
|
-
profilePath = loadedPath;
|
|
19336
|
-
const before = assertCommitIdentity(args.before, "before");
|
|
19337
|
-
const after = assertCommitIdentity(args.after, "after");
|
|
19338
|
-
assertReachableCommit(cwd, before, "before");
|
|
19339
|
-
assertReachableCommit(cwd, after, "after");
|
|
19340
|
-
const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
|
|
19341
|
-
if (checkedOutHead !== after) throw new Error(`after commit ${after} is not the checked-out HEAD ${checkedOutHead}; refusing to mix commit identities with declarations from another tree`);
|
|
19342
19853
|
try {
|
|
19343
|
-
|
|
19344
|
-
|
|
19345
|
-
|
|
19346
|
-
|
|
19347
|
-
|
|
19348
|
-
|
|
19854
|
+
const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
|
|
19855
|
+
cwd,
|
|
19856
|
+
profilePath: args.profilePath
|
|
19857
|
+
});
|
|
19858
|
+
profile = loadedProfile;
|
|
19859
|
+
profilePath = loadedPath;
|
|
19349
19860
|
} catch {
|
|
19350
|
-
throw new
|
|
19861
|
+
throw new RefusalError("project profile could not be loaded or validated; run psf doctor");
|
|
19351
19862
|
}
|
|
19352
|
-
const
|
|
19353
|
-
|
|
19354
|
-
|
|
19355
|
-
|
|
19356
|
-
|
|
19357
|
-
|
|
19358
|
-
|
|
19359
|
-
const stamp = computeImpactStamp({
|
|
19360
|
-
changedFiles,
|
|
19863
|
+
const candidate = assertCommitIdentity(args.candidate, "candidate");
|
|
19864
|
+
assertCheckedOutCandidate(cwd, candidate);
|
|
19865
|
+
const { identity, transport } = resolveReceiptSource(cwd, args.receipts);
|
|
19866
|
+
const resolved = resolveTargets({
|
|
19867
|
+
candidate,
|
|
19868
|
+
cwd,
|
|
19869
|
+
identity,
|
|
19361
19870
|
profile,
|
|
19362
|
-
profilePath
|
|
19363
|
-
|
|
19364
|
-
|
|
19365
|
-
}),
|
|
19366
|
-
readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, LOCKFILE_PATH)
|
|
19871
|
+
profilePath,
|
|
19872
|
+
stage: args.stage,
|
|
19873
|
+
transport
|
|
19367
19874
|
});
|
|
19368
|
-
|
|
19369
|
-
afterSha: after,
|
|
19370
|
-
beforeSha: before,
|
|
19371
|
-
schemaVersion: PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION,
|
|
19372
|
-
unsubscribedPaths: stamp.unsubscribedPaths
|
|
19373
|
-
};
|
|
19374
|
-
decision = stamp.basis === "target-scoped" ? {
|
|
19375
|
-
...core,
|
|
19875
|
+
decision = {
|
|
19376
19876
|
basis: {
|
|
19377
|
-
|
|
19378
|
-
|
|
19379
|
-
reasons: stamp.reasons
|
|
19877
|
+
classification: "receipt-baseline",
|
|
19878
|
+
reasons: [`resolved from trusted ${identity.workflowPath} deployment receipts in ${identity.repository}`]
|
|
19380
19879
|
},
|
|
19880
|
+
candidateSha: candidate,
|
|
19381
19881
|
decision: "usable",
|
|
19382
|
-
|
|
19383
|
-
|
|
19384
|
-
|
|
19385
|
-
|
|
19386
|
-
}))
|
|
19387
|
-
} : {
|
|
19388
|
-
...core,
|
|
19389
|
-
basis: {
|
|
19390
|
-
changedFiles,
|
|
19391
|
-
classification: stamp.basis,
|
|
19392
|
-
reasons: stamp.reasons
|
|
19393
|
-
},
|
|
19394
|
-
decision: "refused",
|
|
19395
|
-
targets: stamp.targets.map((target) => ({
|
|
19396
|
-
basis: target.basis,
|
|
19397
|
-
name: target.name,
|
|
19398
|
-
state: "demanded"
|
|
19399
|
-
}))
|
|
19882
|
+
schemaVersion: PRODUCTION_IMPACT_DECISION_SCHEMA_VERSION,
|
|
19883
|
+
...args.stage === void 0 ? {} : { stage: args.stage },
|
|
19884
|
+
targets: resolved.targets,
|
|
19885
|
+
unsubscribedPaths: resolved.unsubscribedPaths
|
|
19400
19886
|
};
|
|
19401
19887
|
} catch (error) {
|
|
19402
19888
|
decision = refusal({
|
|
19403
|
-
|
|
19404
|
-
before: args.before,
|
|
19889
|
+
candidate: args.candidate,
|
|
19405
19890
|
profile,
|
|
19406
|
-
reason: `production impact classification refused: ${
|
|
19891
|
+
reason: `production impact classification refused: ${refusalReason(error)}`,
|
|
19892
|
+
stage: args.stage
|
|
19407
19893
|
});
|
|
19408
19894
|
}
|
|
19409
19895
|
if (args.githubOutput) writeGithubOutputs(path.resolve(cwd, args.githubOutput), decision);
|
|
19410
19896
|
if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Production impact\n\n${productionImpactSummaryLines(decision).join("\n")}\n`, "utf-8");
|
|
19411
19897
|
return decision;
|
|
19412
19898
|
};
|
|
19413
|
-
const productionImpactSummaryLines = (decision) => decision.
|
|
19899
|
+
const productionImpactSummaryLines = (decision) => decision.decision === "refused" ? [
|
|
19414
19900
|
"Production impact: refused; all consumer work remains demanded.",
|
|
19415
19901
|
...decision.basis.reasons,
|
|
19416
19902
|
unsubscribedPathsLine(void 0, "classification was refused")
|
|
19417
|
-
] : [
|
|
19903
|
+
] : [
|
|
19904
|
+
`Production impact: ${decision.basis.classification}; ${decision.targets.filter(({ state }) => state === "demanded").length}/${decision.targets.length} target(s) demanded.`,
|
|
19905
|
+
...decision.targets.map((target) => `- ${target.name}: ${target.state} — ${target.basis}`),
|
|
19906
|
+
unsubscribedPathsLine(decision.unsubscribedPaths)
|
|
19907
|
+
];
|
|
19908
|
+
/**
|
|
19909
|
+
* The trusted baseline a `success` receipt for this candidate would
|
|
19910
|
+
* overwrite with an older revision, when one stands.
|
|
19911
|
+
*/
|
|
19912
|
+
const overwrittenBaseline = ({ candidate, cwd, identity, stage, target, transport }) => {
|
|
19913
|
+
const standing = resolveDeploymentBaseline({
|
|
19914
|
+
identity,
|
|
19915
|
+
stage,
|
|
19916
|
+
target,
|
|
19917
|
+
transport
|
|
19918
|
+
});
|
|
19919
|
+
if (standing.kind === "trusted" && standing.baseline.sha !== candidate && isReachableCommit(cwd, standing.baseline.sha) && isAncestor(cwd, candidate, standing.baseline.sha)) return standing.baseline;
|
|
19920
|
+
};
|
|
19921
|
+
/**
|
|
19922
|
+
* Publish one target's deployment receipt for the checked-out candidate.
|
|
19923
|
+
* Consumers call it only after deployment and their health checks conclude;
|
|
19924
|
+
* a `success` receipt advances the target's baseline, a `failure` receipt
|
|
19925
|
+
* records the attempt and advances nothing.
|
|
19926
|
+
*/
|
|
19927
|
+
const runProductionReceipt = (args) => {
|
|
19928
|
+
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
19929
|
+
if (args.state !== "success" && args.state !== "failure") throw new RefusalError("--publish-receipt must be success or failure");
|
|
19930
|
+
if (args.target === void 0 || args.target.length === 0) throw new RefusalError("--publish-receipt requires --target <name>");
|
|
19931
|
+
const candidate = assertCommitIdentity(args.candidate, "candidate");
|
|
19932
|
+
assertCheckedOutCandidate(cwd, candidate);
|
|
19933
|
+
const { identity, transport } = resolveReceiptSource(cwd, args.receipts);
|
|
19934
|
+
const { stage } = args;
|
|
19935
|
+
const staleCandidate = args.state === "success" ? overwrittenBaseline({
|
|
19936
|
+
candidate,
|
|
19937
|
+
cwd,
|
|
19938
|
+
identity,
|
|
19939
|
+
stage,
|
|
19940
|
+
target: args.target,
|
|
19941
|
+
transport
|
|
19942
|
+
}) : void 0;
|
|
19943
|
+
const receipt = publishDeploymentReceipt({
|
|
19944
|
+
identity,
|
|
19945
|
+
sourceSha: candidate,
|
|
19946
|
+
stage,
|
|
19947
|
+
state: args.state,
|
|
19948
|
+
target: args.target,
|
|
19949
|
+
transport
|
|
19950
|
+
});
|
|
19951
|
+
if (args.githubOutput) appendFileSync(path.resolve(cwd, args.githubOutput), `${RECEIPT_DEPLOYMENT_ID_OUTPUT}=${receipt.deploymentId}\n`, "utf-8");
|
|
19952
|
+
if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Deployment receipt\n\n${productionReceiptSummaryLines({
|
|
19953
|
+
receipt,
|
|
19954
|
+
...staleCandidate ? { staleCandidate } : {}
|
|
19955
|
+
}).join("\n")}\n`, "utf-8");
|
|
19956
|
+
return {
|
|
19957
|
+
receipt,
|
|
19958
|
+
...staleCandidate ? { staleCandidate } : {}
|
|
19959
|
+
};
|
|
19960
|
+
};
|
|
19961
|
+
const productionReceiptSummaryLines = (result) => [`Deployment receipt: ${result.receipt.state} for ${result.receipt.payload.target}${result.receipt.payload.stage ? ` stage ${result.receipt.payload.stage}` : ""} at ${result.receipt.payload.sourceSha} (deployment ${result.receipt.deploymentId}, run ${result.receipt.payload.workflow.runId}).`, ...result.staleCandidate ? [`Stale candidate: ${result.receipt.payload.sourceSha} is an ancestor of the previously trusted baseline ${result.staleCandidate.sha} (run ${result.staleCandidate.runId}); production now runs the older revision and needs reconciliation.`] : []];
|
|
19418
19962
|
//#endregion
|
|
19419
19963
|
//#region src/commands/production-impact.ts
|
|
19420
|
-
|
|
19421
|
-
|
|
19964
|
+
/**
|
|
19965
|
+
* `production:impact` has two modes on one command. Without
|
|
19966
|
+
* `--publish-receipt` it resolves deployment demand from receipts; with it,
|
|
19967
|
+
* it writes one target's receipt after the consumer deployed and checked
|
|
19968
|
+
* health. `receipts` is the test seam for the transport and run identity.
|
|
19969
|
+
*/
|
|
19970
|
+
function createProductionImpactCommand(output, receipts) {
|
|
19971
|
+
return markCwdOptionDefault(new Command("production:impact").description("Resolve production deployment demand from trusted deployment receipts, or publish one").option("--candidate <sha>", "checked-out candidate commit SHA").option("--cwd <path>", "repository working directory", collectCwdOption).option("--profile <path>", "path to the project profile JSON file (default: software-factory.profile.json in --cwd)").option("--stage <name>", "deployment stage the receipts are bound to (optional)").option("--publish-receipt <state>", "publish a success or failure receipt for --target instead of deciding").option("--target <name>", "declared impact target the receipt names").option("--github-output <path>", "append stable decision outputs (or the published deployment_id) to a GitHub Actions output file").option("--github-summary <path>", "append a human-readable decision to a GitHub Actions summary file").option("--json", "print the complete decision as JSON").action((options) => {
|
|
19972
|
+
const cwd = resolveCwdOption(options.cwd);
|
|
19973
|
+
if (options.publishReceipt !== void 0) {
|
|
19974
|
+
const result = runProductionReceipt({
|
|
19975
|
+
candidate: options.candidate,
|
|
19976
|
+
cwd,
|
|
19977
|
+
githubOutput: options.githubOutput,
|
|
19978
|
+
githubSummary: options.githubSummary,
|
|
19979
|
+
receipts,
|
|
19980
|
+
stage: options.stage,
|
|
19981
|
+
state: options.publishReceipt,
|
|
19982
|
+
target: options.target
|
|
19983
|
+
});
|
|
19984
|
+
output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${productionReceiptSummaryLines(result).join("\n")}\n`);
|
|
19985
|
+
if (result.staleCandidate) throw new Error(`stale candidate ${result.receipt.payload.sourceSha} overwrote trusted baseline ${result.staleCandidate.sha}; reconcile the target`);
|
|
19986
|
+
return;
|
|
19987
|
+
}
|
|
19988
|
+
if (options.target !== void 0) throw new Error("--target applies only with --publish-receipt");
|
|
19422
19989
|
const decision = runProductionImpact({
|
|
19423
|
-
|
|
19424
|
-
|
|
19425
|
-
cwd: resolveCwdOption(options.cwd),
|
|
19990
|
+
candidate: options.candidate,
|
|
19991
|
+
cwd,
|
|
19426
19992
|
githubOutput: options.githubOutput,
|
|
19427
19993
|
githubSummary: options.githubSummary,
|
|
19428
|
-
profilePath: options.profile
|
|
19994
|
+
profilePath: options.profile,
|
|
19995
|
+
receipts,
|
|
19996
|
+
stage: options.stage
|
|
19429
19997
|
});
|
|
19430
|
-
|
|
19431
|
-
else output.stdout.write(`${productionImpactSummaryLines(decision).join("\n")}\n`);
|
|
19998
|
+
output.stdout.write(options.json ? `${JSON.stringify(decision, null, 2)}\n` : `${productionImpactSummaryLines(decision).join("\n")}\n`);
|
|
19432
19999
|
}));
|
|
19433
20000
|
}
|
|
19434
20001
|
//#endregion
|