@kungfu-tech/buildchain 3.0.7 → 3.0.8
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/contracts/engineering-housekeeper-v1.schema.json +11 -1
- package/contracts/fixtures/engineering-housekeeper-v1/cases.json +10 -0
- package/contracts/release-cut-v1.schema.json +70 -0
- package/contracts/release-train-transition-v1.schema.json +85 -0
- package/contracts/release-train-v1.schema.json +82 -0
- package/dist/site/buildchain-contract.json +302 -16
- package/dist/site/buildchain-site.json +83 -27
- package/dist/site/capability-registry.json +4 -3
- package/dist/site/kfd-claims.json +45 -8
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +22 -6
- package/dist/site/node-api-registry.json +1218 -489
- package/dist/site/page-registry.json +65 -17
- package/dist/site/public-surface-audit.json +13 -8
- package/dist/site/publication-authority-registry.json +2 -4
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/site-manifest.json +18 -10
- package/dist/site/workflow-registry.json +10 -9
- package/docs/MAP.md +1 -1
- package/docs/aws-us-elastic-runner-burst-plane.md +20 -7
- package/docs/engineering-housekeeper.md +61 -28
- package/docs/node-api-reference.md +109 -77
- package/docs/release-governance.md +13 -10
- package/docs/release-train.md +102 -0
- package/docs/reusable-build-surface.md +16 -6
- package/docs/site-bundle-contract.md +5 -1
- package/docs/versioning.md +1 -0
- package/package.json +2 -1
- package/packages/core/buildchain-agent-manuals.js +1 -0
- package/packages/core/buildchain-compatibility-proof.js +577 -0
- package/packages/core/buildchain-contract.js +46 -181
- package/packages/core/engineering-housekeeper-github-client.js +18 -1
- package/packages/core/engineering-housekeeper-github.js +247 -41
- package/packages/core/engineering-housekeeper.js +15 -0
- package/packages/core/release-train.js +520 -0
- package/scripts/aws-macos-jit-controller-core.mjs +33 -11
- package/scripts/aws-macos-jit-controller-runtime.mjs +208 -0
- package/scripts/aws-macos-jit-controller.mjs +50 -37
- package/scripts/aws-macos-jit-core.mjs +41 -3
- package/scripts/buildchain-contract-lock.mjs +6 -0
- package/scripts/dispatch-artifact-signing-authority.mjs +4 -7
- package/scripts/engineering-housekeeper-workflow.mjs +13 -6
- package/scripts/generate-site-bundle.mjs +2 -0
- package/scripts/site-capability-metadata.mjs +1 -0
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
assertBuildchainCompatibilityProjection,
|
|
6
|
+
createBuildchainCompatibilityProofRegistry,
|
|
7
|
+
createHistoricalBuildchainCompatibilityProofs,
|
|
8
|
+
} from "./buildchain-compatibility-proof.js";
|
|
4
9
|
import { createControllerRegistry } from "./controller-evidence.js";
|
|
5
10
|
import { enumerateWorkflowInputs } from "./public-surface-audit.js";
|
|
6
11
|
|
|
12
|
+
export {
|
|
13
|
+
BUILDCHAIN_COMPATIBILITY_PROOF_REGISTRY_SCHEMA,
|
|
14
|
+
BUILDCHAIN_COMPATIBILITY_PROOF_SCHEMA,
|
|
15
|
+
BUILDCHAIN_COMPATIBILITY_VERIFICATION_RECEIPT_SCHEMA,
|
|
16
|
+
assertBuildchainCompatibilityProjection,
|
|
17
|
+
contractSummary,
|
|
18
|
+
createBuildchainCompatibilityProof,
|
|
19
|
+
createBuildchainCompatibilityProofRegistry,
|
|
20
|
+
createBuildchainCompatibilityVerificationReceipt,
|
|
21
|
+
createHistoricalBuildchainCompatibilityProofs,
|
|
22
|
+
evaluateBuildchainContractLock,
|
|
23
|
+
renderBuildchainContractDriftIssueBody,
|
|
24
|
+
verifyBuildchainCompatibilityProof,
|
|
25
|
+
verifyBuildchainCompatibilityVerificationReceipt,
|
|
26
|
+
} from "./buildchain-compatibility-proof.js";
|
|
27
|
+
|
|
7
28
|
export const BUILDCHAIN_RUNTIME_CONTRACT_WORLD = "kungfu-buildchain-runtime-contract-world";
|
|
8
29
|
export const BUILDCHAIN_CONTRACT_LOCK = "kungfu-buildchain-contract-lock";
|
|
9
30
|
|
|
10
31
|
const DEFAULT_POLICY = "major-compatible";
|
|
11
|
-
const FLOATING_CLASSES = new Set(["stable", "alpha"]);
|
|
12
32
|
|
|
13
33
|
function optionalString(value) {
|
|
14
34
|
return value === undefined || value === null ? "" : String(value);
|
|
@@ -284,9 +304,6 @@ export function createBuildchainContractWorld({
|
|
|
284
304
|
"publish-gate source locks are created by the wrapper and enforced by promote-buildchain-ref before publish side effects",
|
|
285
305
|
"GitHub Release passport and evidence publication is delegated to promote-buildchain-ref after the semver release transaction completes",
|
|
286
306
|
],
|
|
287
|
-
compatibleBreakingDigests: [
|
|
288
|
-
"sha256:acd401cfc46450115a3763fd4b679d85f185e8757ebd52510d6262e1533df4cf",
|
|
289
|
-
],
|
|
290
307
|
}),
|
|
291
308
|
surface(root, {
|
|
292
309
|
id: "advanced-release-candidate-promote",
|
|
@@ -328,9 +345,6 @@ export function createBuildchainContractWorld({
|
|
|
328
345
|
"promotion reuses PR-stage release-candidate artifacts and does not run the heavy native build matrix",
|
|
329
346
|
"resume-from-candidate-run is fail-closed and uses a fresh caller event instead of rerunning a frozen startup graph",
|
|
330
347
|
],
|
|
331
|
-
compatibleBreakingDigests: [
|
|
332
|
-
"sha256:aa30f22e3af0a89841310bdbdc900844dd95a66974db173fa140a71bbd7e82c0",
|
|
333
|
-
],
|
|
334
348
|
}),
|
|
335
349
|
surface(root, {
|
|
336
350
|
id: "web-surface",
|
|
@@ -477,9 +491,6 @@ export function createBuildchainContractWorld({
|
|
|
477
491
|
"publish transactions can require a resolved publish-gate source lock to prevent floating-ref drift",
|
|
478
492
|
"semver GitHub Releases are created or updated only after transaction completion, with prerelease/latest metadata bound to the authoritative publication channel and tag syntax retained as the fallback for ordinary callers",
|
|
479
493
|
],
|
|
480
|
-
compatibleBreakingDigests: [
|
|
481
|
-
"sha256:a59f0910e6df842e7699139472e5dd69ac2fdd7f7213bf2cb346d1d622556874",
|
|
482
|
-
],
|
|
483
494
|
}),
|
|
484
495
|
surface(root, {
|
|
485
496
|
id: "macos-credential-island-action",
|
|
@@ -755,14 +766,6 @@ export function createBuildchainContractWorld({
|
|
|
755
766
|
registryDigest: resolvedControllerRegistry.digest,
|
|
756
767
|
inputClassifications: descriptor.inputs,
|
|
757
768
|
},
|
|
758
|
-
...(descriptor.id === "build-lifecycle"
|
|
759
|
-
? {
|
|
760
|
-
compatibleBreakingDigests: [
|
|
761
|
-
"sha256:e264a79f9f399038c2fcfd21e4168c68c2e1485ee5c651c02242a02b622ac2be",
|
|
762
|
-
"sha256:30745921541e9b0f70475bb2178c2559f6aef248f6680670ccd44d8c5a69a6b1",
|
|
763
|
-
],
|
|
764
|
-
}
|
|
765
|
-
: {}),
|
|
766
769
|
guarantees: [
|
|
767
770
|
"plans bind exact consumer source SHA, exact Buildchain runtime SHA, and the runtime contract digest",
|
|
768
771
|
"receipts bind the plan digest and preserve pass, fail, skip, and partial stage outcomes",
|
|
@@ -782,24 +785,45 @@ export function createBuildchainContractWorld({
|
|
|
782
785
|
},
|
|
783
786
|
majorLine,
|
|
784
787
|
compatibilityPolicy: DEFAULT_POLICY,
|
|
788
|
+
compatibilityProofs: createHistoricalBuildchainCompatibilityProofs(),
|
|
785
789
|
surfaces,
|
|
786
790
|
};
|
|
787
791
|
return finalizeBuildchainContractWorld(base);
|
|
788
792
|
}
|
|
789
793
|
|
|
790
794
|
export function finalizeBuildchainContractWorld(contractWorld) {
|
|
795
|
+
const sourceSurfaces = (contractWorld.surfaces || []).map((entry) => ({ ...entry }));
|
|
796
|
+
const proofRegistry = createBuildchainCompatibilityProofRegistry({
|
|
797
|
+
proofs: contractWorld.compatibilityProofs || createHistoricalBuildchainCompatibilityProofs(),
|
|
798
|
+
surfaces: sourceSurfaces,
|
|
799
|
+
majorLine: contractWorld.majorLine,
|
|
800
|
+
});
|
|
791
801
|
const world = {
|
|
792
802
|
...contractWorld,
|
|
793
|
-
|
|
803
|
+
compatibilityProofs: proofRegistry.proofs,
|
|
804
|
+
compatibilityProofRegistryRoot: proofRegistry.registryRoot,
|
|
805
|
+
surfaces: sourceSurfaces.map((entry) => {
|
|
806
|
+
const surfaceEntry = { ...entry };
|
|
807
|
+
delete surfaceEntry.compatibleBreakingDigests;
|
|
808
|
+
delete surfaceEntry.compatibilityProofRoots;
|
|
809
|
+
const projection = proofRegistry.projections[entry.id] || [];
|
|
810
|
+
if (projection.length > 0) {
|
|
811
|
+
surfaceEntry.compatibleBreakingDigests = projection.map((item) => item.breakingDigest);
|
|
812
|
+
surfaceEntry.compatibilityProofRoots = projection.map((item) => item.proofRoot);
|
|
813
|
+
}
|
|
814
|
+
return surfaceEntry;
|
|
815
|
+
}),
|
|
794
816
|
};
|
|
795
817
|
const compatibilityModel = {
|
|
796
818
|
schemaVersion: world.schemaVersion,
|
|
797
819
|
contract: world.contract,
|
|
798
820
|
majorLine: world.majorLine,
|
|
821
|
+
compatibilityProofRegistryRoot: world.compatibilityProofRegistryRoot,
|
|
799
822
|
surfaces: world.surfaces.map((entry) => ({
|
|
800
823
|
id: entry.id,
|
|
801
824
|
kind: entry.kind,
|
|
802
825
|
breakingDigest: entry.breakingDigest,
|
|
826
|
+
compatibilityProofRoots: entry.compatibilityProofRoots || [],
|
|
803
827
|
})),
|
|
804
828
|
};
|
|
805
829
|
const digestModel = {
|
|
@@ -831,6 +855,7 @@ export function createBuildchainContractLock({
|
|
|
831
855
|
contract: contractWorld.contract,
|
|
832
856
|
contractDigest: contractWorld.contractDigest,
|
|
833
857
|
compatibilityDigest: contractWorld.compatibilityDigest,
|
|
858
|
+
compatibilityProofRegistryRoot: contractWorld.compatibilityProofRegistryRoot,
|
|
834
859
|
majorLine: contractWorld.majorLine,
|
|
835
860
|
compatibilityPolicy,
|
|
836
861
|
acceptedAt,
|
|
@@ -838,6 +863,7 @@ export function createBuildchainContractLock({
|
|
|
838
863
|
id: entry.id,
|
|
839
864
|
kind: entry.kind,
|
|
840
865
|
breakingDigest: entry.breakingDigest,
|
|
866
|
+
compatibilityProofRoots: entry.compatibilityProofRoots || [],
|
|
841
867
|
})),
|
|
842
868
|
},
|
|
843
869
|
};
|
|
@@ -848,6 +874,7 @@ export function readBuildchainContractWorld(filePath) {
|
|
|
848
874
|
if (!value || value.contract !== BUILDCHAIN_RUNTIME_CONTRACT_WORLD) {
|
|
849
875
|
throw new Error(`Buildchain contract world is missing or invalid: ${filePath}`);
|
|
850
876
|
}
|
|
877
|
+
assertBuildchainCompatibilityProjection(value);
|
|
851
878
|
const computed = finalizeBuildchainContractWorld(value);
|
|
852
879
|
for (const digestName of ["compatibilityDigest", "contractDigest"]) {
|
|
853
880
|
const published = String(value[digestName] || "").trim();
|
|
@@ -873,165 +900,3 @@ export function readBuildchainContractLock(filePath) {
|
|
|
873
900
|
}
|
|
874
901
|
return value;
|
|
875
902
|
}
|
|
876
|
-
|
|
877
|
-
function surfaceMap(surfaces = []) {
|
|
878
|
-
return new Map(surfaces.map((entry) => [entry.id, entry]));
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
export function evaluateBuildchainContractLock({
|
|
882
|
-
lock,
|
|
883
|
-
current,
|
|
884
|
-
runtimeRef = "",
|
|
885
|
-
runtimeSha = "",
|
|
886
|
-
runtimeClass = "",
|
|
887
|
-
compatibilityPolicy = "",
|
|
888
|
-
} = {}) {
|
|
889
|
-
if (!current || current.contract !== BUILDCHAIN_RUNTIME_CONTRACT_WORLD) {
|
|
890
|
-
throw new Error("current must be a Buildchain runtime contract world");
|
|
891
|
-
}
|
|
892
|
-
const floatingRuntime = FLOATING_CLASSES.has(runtimeClass);
|
|
893
|
-
if (!floatingRuntime) {
|
|
894
|
-
return {
|
|
895
|
-
ok: true,
|
|
896
|
-
status: "non-floating-runtime",
|
|
897
|
-
drift: false,
|
|
898
|
-
compatible: true,
|
|
899
|
-
issueRecommended: false,
|
|
900
|
-
reason: `runtime class ${runtimeClass || "unknown"} is not a stable floating ref`,
|
|
901
|
-
};
|
|
902
|
-
}
|
|
903
|
-
if (!lock) {
|
|
904
|
-
return {
|
|
905
|
-
ok: true,
|
|
906
|
-
status: "missing-lock",
|
|
907
|
-
drift: false,
|
|
908
|
-
compatible: true,
|
|
909
|
-
issueRecommended: false,
|
|
910
|
-
reason: "consumer repository has no Buildchain contract lock",
|
|
911
|
-
};
|
|
912
|
-
}
|
|
913
|
-
const accepted = lock.buildchain || {};
|
|
914
|
-
const policy = compatibilityPolicy || accepted.compatibilityPolicy || DEFAULT_POLICY;
|
|
915
|
-
const shaDrift = !!accepted.resolvedSha && !!runtimeSha && accepted.resolvedSha !== runtimeSha;
|
|
916
|
-
const contractDrift = !!accepted.contractDigest && accepted.contractDigest !== current.contractDigest;
|
|
917
|
-
if (!shaDrift && !contractDrift) {
|
|
918
|
-
return {
|
|
919
|
-
ok: true,
|
|
920
|
-
status: "unchanged",
|
|
921
|
-
drift: false,
|
|
922
|
-
compatible: true,
|
|
923
|
-
issueRecommended: false,
|
|
924
|
-
policy,
|
|
925
|
-
accepted,
|
|
926
|
-
current: contractSummary(current, runtimeRef, runtimeSha),
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
const reasons = [];
|
|
930
|
-
if (accepted.contract !== current.contract) {
|
|
931
|
-
reasons.push(`contract changed from ${accepted.contract || "(unknown)"} to ${current.contract}`);
|
|
932
|
-
}
|
|
933
|
-
if (accepted.majorLine && accepted.majorLine !== current.majorLine) {
|
|
934
|
-
reasons.push(`major line changed from ${accepted.majorLine} to ${current.majorLine}`);
|
|
935
|
-
}
|
|
936
|
-
if (policy === "exact" && accepted.contractDigest !== current.contractDigest) {
|
|
937
|
-
reasons.push("exact policy requires the contract digest to remain unchanged");
|
|
938
|
-
}
|
|
939
|
-
if (!["major-compatible", "allow-additive", "exact"].includes(policy)) {
|
|
940
|
-
reasons.push(`unsupported compatibility policy: ${policy}`);
|
|
941
|
-
}
|
|
942
|
-
const currentSurfaces = surfaceMap(current.surfaces);
|
|
943
|
-
for (const oldSurface of accepted.surfaces || []) {
|
|
944
|
-
const nextSurface = currentSurfaces.get(oldSurface.id);
|
|
945
|
-
if (!nextSurface) {
|
|
946
|
-
reasons.push(`surface removed: ${oldSurface.id}`);
|
|
947
|
-
continue;
|
|
948
|
-
}
|
|
949
|
-
const compatibleBreakingDigests = new Set(nextSurface.compatibleBreakingDigests || []);
|
|
950
|
-
if (
|
|
951
|
-
nextSurface.breakingDigest !== oldSurface.breakingDigest
|
|
952
|
-
&& !compatibleBreakingDigests.has(oldSurface.breakingDigest)
|
|
953
|
-
) {
|
|
954
|
-
reasons.push(`surface breaking digest changed: ${oldSurface.id}`);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
const compatible = reasons.length === 0;
|
|
958
|
-
return {
|
|
959
|
-
ok: compatible,
|
|
960
|
-
status: compatible ? "compatible-drift" : "breaking-drift",
|
|
961
|
-
drift: shaDrift || contractDrift,
|
|
962
|
-
shaDrift,
|
|
963
|
-
contractDrift,
|
|
964
|
-
compatible,
|
|
965
|
-
issueRecommended: true,
|
|
966
|
-
policy,
|
|
967
|
-
reasons,
|
|
968
|
-
accepted,
|
|
969
|
-
current: contractSummary(current, runtimeRef, runtimeSha),
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
export function contractSummary(contractWorld, runtimeRef = "", runtimeSha = "") {
|
|
974
|
-
return {
|
|
975
|
-
ref: runtimeRef,
|
|
976
|
-
resolvedSha: runtimeSha,
|
|
977
|
-
contract: contractWorld.contract,
|
|
978
|
-
contractDigest: contractWorld.contractDigest,
|
|
979
|
-
compatibilityDigest: contractWorld.compatibilityDigest,
|
|
980
|
-
majorLine: contractWorld.majorLine,
|
|
981
|
-
surfaceCount: Array.isArray(contractWorld.surfaces) ? contractWorld.surfaces.length : 0,
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
export function renderBuildchainContractDriftIssueBody({
|
|
986
|
-
repository = "",
|
|
987
|
-
workflow = "",
|
|
988
|
-
runUrl = "",
|
|
989
|
-
lockPath = "",
|
|
990
|
-
evaluation,
|
|
991
|
-
} = {}) {
|
|
992
|
-
const accepted = evaluation.accepted || {};
|
|
993
|
-
const current = evaluation.current || {};
|
|
994
|
-
const severity = evaluation.compatible ? "compatible" : "breaking";
|
|
995
|
-
return [
|
|
996
|
-
"# Buildchain contract drift",
|
|
997
|
-
"",
|
|
998
|
-
"## Summary",
|
|
999
|
-
"",
|
|
1000
|
-
`Buildchain detected ${severity} contract drift for a floating runtime ref before expensive Buildchain work continued.`,
|
|
1001
|
-
"",
|
|
1002
|
-
"## Consumer",
|
|
1003
|
-
"",
|
|
1004
|
-
`- Repository: ${repository || "(unknown)"}`,
|
|
1005
|
-
`- Workflow: ${workflow || "(unknown)"}`,
|
|
1006
|
-
`- Run: ${runUrl || "(unknown)"}`,
|
|
1007
|
-
`- Lock path: ${lockPath || "(unknown)"}`,
|
|
1008
|
-
"",
|
|
1009
|
-
"## Accepted Buildchain contract",
|
|
1010
|
-
"",
|
|
1011
|
-
`- Ref: ${accepted.ref || "(unknown)"}`,
|
|
1012
|
-
`- SHA: ${accepted.resolvedSha || "(unknown)"}`,
|
|
1013
|
-
`- Contract digest: ${accepted.contractDigest || "(unknown)"}`,
|
|
1014
|
-
`- Compatibility digest: ${accepted.compatibilityDigest || "(unknown)"}`,
|
|
1015
|
-
`- Policy: ${evaluation.policy || accepted.compatibilityPolicy || "(unknown)"}`,
|
|
1016
|
-
"",
|
|
1017
|
-
"## Current Buildchain contract",
|
|
1018
|
-
"",
|
|
1019
|
-
`- Ref: ${current.ref || "(unknown)"}`,
|
|
1020
|
-
`- SHA: ${current.resolvedSha || "(unknown)"}`,
|
|
1021
|
-
`- Contract digest: ${current.contractDigest || "(unknown)"}`,
|
|
1022
|
-
`- Compatibility digest: ${current.compatibilityDigest || "(unknown)"}`,
|
|
1023
|
-
`- Major line: ${current.majorLine || "(unknown)"}`,
|
|
1024
|
-
"",
|
|
1025
|
-
"## Compatibility",
|
|
1026
|
-
"",
|
|
1027
|
-
`- Status: ${evaluation.status || "(unknown)"}`,
|
|
1028
|
-
`- Compatible: ${evaluation.compatible ? "yes" : "no"}`,
|
|
1029
|
-
evaluation.reasons?.length ? evaluation.reasons.map((reason) => `- ${reason}`).join("\n") : "- No breaking drift detected.",
|
|
1030
|
-
"",
|
|
1031
|
-
"## Suggested next action",
|
|
1032
|
-
"",
|
|
1033
|
-
evaluation.compatible
|
|
1034
|
-
? "Review the Buildchain release notes, then update the consumer contract lock to the current SHA and contract digest."
|
|
1035
|
-
: "Failing before heavy build is intentional. Review the Buildchain contract change, update the consumer workflow/configuration, or pin the previous Buildchain SHA.",
|
|
1036
|
-
].join("\n");
|
|
1037
|
-
}
|
|
@@ -7,7 +7,8 @@ function requiredString(value, field) {
|
|
|
7
7
|
function normalizeRepository(value) {
|
|
8
8
|
const normalized = requiredString(value?.fullName || value, "repository");
|
|
9
9
|
const match = normalized.match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
10
|
-
if (!match)
|
|
10
|
+
if (!match)
|
|
11
|
+
throw new Error(`repository must be owner/repo, got: ${normalized}`);
|
|
11
12
|
return { owner: match[1], repo: match[2], fullName: normalized };
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -162,6 +163,22 @@ export class GitHubHousekeeperClient {
|
|
|
162
163
|
);
|
|
163
164
|
}
|
|
164
165
|
|
|
166
|
+
async listClosedPullRequests(repository) {
|
|
167
|
+
const coordinate = normalizeRepository(repository);
|
|
168
|
+
return this.paginate(
|
|
169
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/pulls?state=closed&sort=updated&direction=asc&per_page=100`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async listPullRequestsForCommit(repository, commitOid) {
|
|
174
|
+
const coordinate = normalizeRepository(repository);
|
|
175
|
+
return this.paginate(
|
|
176
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/commits/${encodeURIComponent(
|
|
177
|
+
requiredString(commitOid, "commit OID"),
|
|
178
|
+
)}/pulls?per_page=100`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
165
182
|
async getBranch(repository, name) {
|
|
166
183
|
const coordinate = normalizeRepository(repository);
|
|
167
184
|
return (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
HOUSEKEEPER_REASON_CODES,
|
|
3
|
+
classifyHousekeeperBranch,
|
|
3
4
|
classifyHousekeeperReplay,
|
|
4
5
|
createEngineeringHousekeeperPlan,
|
|
5
6
|
createEngineeringHousekeeperReceipt,
|
|
@@ -15,6 +16,7 @@ export {
|
|
|
15
16
|
|
|
16
17
|
const DEFAULT_STALE_DAYS = 30;
|
|
17
18
|
const DEFAULT_MAX_ACTIONS = 20;
|
|
19
|
+
const DEFAULT_INVENTORY_CONCURRENCY = 8;
|
|
18
20
|
function requiredString(value, field) {
|
|
19
21
|
const normalized = String(value || "").trim();
|
|
20
22
|
if (!normalized) throw new Error(`${field} is required`);
|
|
@@ -40,6 +42,23 @@ function normalizePositiveInteger(value, fallback, field) {
|
|
|
40
42
|
}
|
|
41
43
|
return selected;
|
|
42
44
|
}
|
|
45
|
+
async function mapConcurrently(values, concurrency, operation) {
|
|
46
|
+
const results = new Array(values.length);
|
|
47
|
+
let nextIndex = 0;
|
|
48
|
+
async function worker() {
|
|
49
|
+
while (nextIndex < values.length) {
|
|
50
|
+
const index = nextIndex;
|
|
51
|
+
nextIndex += 1;
|
|
52
|
+
results[index] = await operation(values[index], index);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await Promise.all(
|
|
56
|
+
Array.from({ length: Math.min(concurrency, values.length) }, () =>
|
|
57
|
+
worker(),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
43
62
|
function providerError(error, operation) {
|
|
44
63
|
if (error instanceof GitHubHousekeeperProviderError) return error;
|
|
45
64
|
return new GitHubHousekeeperProviderError(
|
|
@@ -94,6 +113,123 @@ function openPullRequestNumbers(pullRequests, repository, branchName) {
|
|
|
94
113
|
.sort((left, right) => left - right);
|
|
95
114
|
}
|
|
96
115
|
|
|
116
|
+
function normalizeOptionalBranchName(value) {
|
|
117
|
+
return String(value || "")
|
|
118
|
+
.trim()
|
|
119
|
+
.replace(/^refs\/heads\//, "");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function branchObservation({
|
|
123
|
+
branchState,
|
|
124
|
+
repository,
|
|
125
|
+
repositoryState,
|
|
126
|
+
pullRequests,
|
|
127
|
+
target,
|
|
128
|
+
ancestry,
|
|
129
|
+
}) {
|
|
130
|
+
return {
|
|
131
|
+
repository,
|
|
132
|
+
sourceRepository: repository,
|
|
133
|
+
name: String(branchState.name),
|
|
134
|
+
headOid: headOid(branchState),
|
|
135
|
+
target,
|
|
136
|
+
isDefault:
|
|
137
|
+
String(repositoryState.default_branch) === String(branchState.name),
|
|
138
|
+
isProtected: branchState.protected === true,
|
|
139
|
+
ancestry,
|
|
140
|
+
openPullRequestNumbers: openPullRequestNumbers(
|
|
141
|
+
pullRequests,
|
|
142
|
+
repository,
|
|
143
|
+
String(branchState.name),
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function mainlineTargetNames({
|
|
149
|
+
branchStates,
|
|
150
|
+
repository,
|
|
151
|
+
repositoryState,
|
|
152
|
+
requestedTarget,
|
|
153
|
+
policy,
|
|
154
|
+
}) {
|
|
155
|
+
const protectedTargets = branchStates
|
|
156
|
+
.filter((branchState) => {
|
|
157
|
+
const classification = classifyHousekeeperBranch(
|
|
158
|
+
branchObservation({
|
|
159
|
+
branchState,
|
|
160
|
+
repository,
|
|
161
|
+
repositoryState,
|
|
162
|
+
pullRequests: [],
|
|
163
|
+
target: { name: "__housekeeper_target__", headOid: "0".repeat(40) },
|
|
164
|
+
ancestry: "ancestor",
|
|
165
|
+
}),
|
|
166
|
+
policy,
|
|
167
|
+
);
|
|
168
|
+
return classification.reasonCodes.includes(
|
|
169
|
+
HOUSEKEEPER_REASON_CODES.PROTECTED_BRANCH,
|
|
170
|
+
);
|
|
171
|
+
})
|
|
172
|
+
.map((branchState) => String(branchState.name));
|
|
173
|
+
return [
|
|
174
|
+
...new Set(
|
|
175
|
+
[
|
|
176
|
+
requestedTarget,
|
|
177
|
+
repositoryState.default_branch,
|
|
178
|
+
...protectedTargets,
|
|
179
|
+
].filter(Boolean),
|
|
180
|
+
),
|
|
181
|
+
].sort((left, right) => {
|
|
182
|
+
const priority = (name) =>
|
|
183
|
+
name === requestedTarget
|
|
184
|
+
? 0
|
|
185
|
+
: name === repositoryState.default_branch
|
|
186
|
+
? 1
|
|
187
|
+
: name.startsWith("dev/")
|
|
188
|
+
? 2
|
|
189
|
+
: name.startsWith("alpha/")
|
|
190
|
+
? 3
|
|
191
|
+
: name.startsWith("release/")
|
|
192
|
+
? 4
|
|
193
|
+
: name.startsWith("publish-gate/")
|
|
194
|
+
? 5
|
|
195
|
+
: 6;
|
|
196
|
+
return priority(left) - priority(right) || left.localeCompare(right);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function branchCandidateTargets({
|
|
201
|
+
repository,
|
|
202
|
+
branchName,
|
|
203
|
+
branchOid,
|
|
204
|
+
targets,
|
|
205
|
+
associatedPullRequests,
|
|
206
|
+
}) {
|
|
207
|
+
const mergedTargetNames = new Set(
|
|
208
|
+
associatedPullRequests
|
|
209
|
+
.filter(
|
|
210
|
+
(pullRequest) =>
|
|
211
|
+
Boolean(pullRequest.merged_at || pullRequest.mergedAt) &&
|
|
212
|
+
pullRequestHeadRepository(pullRequest) === repository &&
|
|
213
|
+
String(pullRequest?.head?.ref || pullRequest.headRef || "") ===
|
|
214
|
+
branchName &&
|
|
215
|
+
String(
|
|
216
|
+
pullRequest?.head?.sha || pullRequest.headOid || "",
|
|
217
|
+
).toLowerCase() === branchOid,
|
|
218
|
+
)
|
|
219
|
+
.map((pullRequest) =>
|
|
220
|
+
String(pullRequest?.base?.ref || pullRequest.baseRef || ""),
|
|
221
|
+
)
|
|
222
|
+
.filter(Boolean),
|
|
223
|
+
);
|
|
224
|
+
return targets
|
|
225
|
+
.filter((target) => mergedTargetNames.has(target.name))
|
|
226
|
+
.sort((left, right) => {
|
|
227
|
+
const associated = (target) =>
|
|
228
|
+
mergedTargetNames.has(target.name) ? 0 : 1;
|
|
229
|
+
return associated(left) - associated(right);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
97
233
|
async function branchAncestry(client, repository, branchOid, targetOid) {
|
|
98
234
|
if (branchOid === targetOid) return "ancestor";
|
|
99
235
|
const comparison = await client.compareCommits(
|
|
@@ -116,10 +252,7 @@ export async function collectGitHubHousekeeperInventory({
|
|
|
116
252
|
policy = {},
|
|
117
253
|
}) {
|
|
118
254
|
const coordinate = normalizeRepository(repository);
|
|
119
|
-
const
|
|
120
|
-
/^refs\/heads\//,
|
|
121
|
-
"",
|
|
122
|
-
);
|
|
255
|
+
const requestedTarget = normalizeOptionalBranchName(targetBranch);
|
|
123
256
|
const observation = normalizeDate(observedAt, "observedAt");
|
|
124
257
|
const staleWindow = normalizePositiveInteger(
|
|
125
258
|
staleDays,
|
|
@@ -127,16 +260,42 @@ export async function collectGitHubHousekeeperInventory({
|
|
|
127
260
|
"staleDays",
|
|
128
261
|
);
|
|
129
262
|
try {
|
|
130
|
-
const [
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
263
|
+
const [
|
|
264
|
+
repositoryState,
|
|
265
|
+
branchStates,
|
|
266
|
+
pullRequestStates,
|
|
267
|
+
closedPullRequestStates,
|
|
268
|
+
] = await Promise.all([
|
|
269
|
+
client.getRepository(coordinate.fullName),
|
|
270
|
+
client.listBranches(coordinate.fullName),
|
|
271
|
+
client.listOpenPullRequests(coordinate.fullName),
|
|
272
|
+
client.listClosedPullRequests(coordinate.fullName),
|
|
273
|
+
]);
|
|
274
|
+
const branchStatesByName = new Map(
|
|
275
|
+
branchStates.map((branchState) => [
|
|
276
|
+
String(branchState.name),
|
|
277
|
+
branchState,
|
|
278
|
+
]),
|
|
279
|
+
);
|
|
280
|
+
const targetNames = mainlineTargetNames({
|
|
281
|
+
branchStates,
|
|
282
|
+
repository: coordinate.fullName,
|
|
283
|
+
repositoryState,
|
|
284
|
+
requestedTarget,
|
|
285
|
+
policy,
|
|
286
|
+
});
|
|
287
|
+
const targets = targetNames.map((name) => {
|
|
288
|
+
const state = branchStatesByName.get(name);
|
|
289
|
+
if (!state)
|
|
290
|
+
throw new Error(`mainline target branch ${name} was not found`);
|
|
291
|
+
const target = { name, headOid: headOid(state) };
|
|
292
|
+
if (!target.headOid)
|
|
293
|
+
throw new Error(`mainline target branch ${name} has no head OID`);
|
|
294
|
+
return target;
|
|
295
|
+
});
|
|
296
|
+
const target = targets[0];
|
|
297
|
+
if (!target)
|
|
298
|
+
throw new Error("repository has no discoverable mainline target");
|
|
140
299
|
const pullRequests = pullRequestStates
|
|
141
300
|
.map((entry) =>
|
|
142
301
|
normalizePullRequest(
|
|
@@ -147,35 +306,82 @@ export async function collectGitHubHousekeeperInventory({
|
|
|
147
306
|
),
|
|
148
307
|
)
|
|
149
308
|
.sort((left, right) => left.number - right.number);
|
|
150
|
-
const
|
|
151
|
-
for (const
|
|
309
|
+
const mergedPullRequestsByExactHead = new Map();
|
|
310
|
+
for (const pullRequest of closedPullRequestStates) {
|
|
311
|
+
if (
|
|
312
|
+
!Boolean(pullRequest.merged_at || pullRequest.mergedAt) ||
|
|
313
|
+
pullRequestHeadRepository(pullRequest) !== coordinate.fullName
|
|
314
|
+
)
|
|
315
|
+
continue;
|
|
316
|
+
const key = `${String(
|
|
317
|
+
pullRequest?.head?.ref || pullRequest.headRef || "",
|
|
318
|
+
)}\0${String(
|
|
319
|
+
pullRequest?.head?.sha || pullRequest.headOid || "",
|
|
320
|
+
).toLowerCase()}`;
|
|
321
|
+
const existing = mergedPullRequestsByExactHead.get(key) || [];
|
|
322
|
+
existing.push(pullRequest);
|
|
323
|
+
mergedPullRequestsByExactHead.set(key, existing);
|
|
324
|
+
}
|
|
325
|
+
const sortedBranchStates = [...branchStates].sort((left, right) =>
|
|
152
326
|
String(left.name).localeCompare(String(right.name)),
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
isProtected: branchState.protected === true,
|
|
166
|
-
ancestry: await branchAncestry(
|
|
167
|
-
client,
|
|
168
|
-
coordinate.fullName,
|
|
169
|
-
branchOid,
|
|
170
|
-
target.headOid,
|
|
171
|
-
),
|
|
172
|
-
openPullRequestNumbers: openPullRequestNumbers(
|
|
327
|
+
);
|
|
328
|
+
const branches = await mapConcurrently(
|
|
329
|
+
sortedBranchStates,
|
|
330
|
+
DEFAULT_INVENTORY_CONCURRENCY,
|
|
331
|
+
async (branchState) => {
|
|
332
|
+
const branchOid = headOid(branchState);
|
|
333
|
+
if (!branchOid)
|
|
334
|
+
throw new Error(`branch ${branchState.name} has no head OID`);
|
|
335
|
+
const staticObservation = branchObservation({
|
|
336
|
+
branchState,
|
|
337
|
+
repository: coordinate.fullName,
|
|
338
|
+
repositoryState,
|
|
173
339
|
pullRequests,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
|
|
340
|
+
target,
|
|
341
|
+
ancestry: "ancestor",
|
|
342
|
+
});
|
|
343
|
+
const staticallyEligible = classifyHousekeeperBranch(
|
|
344
|
+
staticObservation,
|
|
345
|
+
policy,
|
|
346
|
+
).eligible;
|
|
347
|
+
let selectedTarget = target;
|
|
348
|
+
let ancestry = "ambiguous";
|
|
349
|
+
if (staticallyEligible) {
|
|
350
|
+
const candidateTargets = await branchCandidateTargets({
|
|
351
|
+
repository: coordinate.fullName,
|
|
352
|
+
branchName: String(branchState.name),
|
|
353
|
+
branchOid,
|
|
354
|
+
targets,
|
|
355
|
+
associatedPullRequests:
|
|
356
|
+
mergedPullRequestsByExactHead.get(
|
|
357
|
+
`${String(branchState.name)}\0${branchOid}`,
|
|
358
|
+
) || [],
|
|
359
|
+
});
|
|
360
|
+
for (const candidateTarget of candidateTargets) {
|
|
361
|
+
if (String(branchState.name) === candidateTarget.name) continue;
|
|
362
|
+
const candidateAncestry = await branchAncestry(
|
|
363
|
+
client,
|
|
364
|
+
coordinate.fullName,
|
|
365
|
+
branchOid,
|
|
366
|
+
candidateTarget.headOid,
|
|
367
|
+
);
|
|
368
|
+
if (candidateAncestry === "ancestor") {
|
|
369
|
+
selectedTarget = candidateTarget;
|
|
370
|
+
ancestry = candidateAncestry;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return branchObservation({
|
|
376
|
+
branchState,
|
|
377
|
+
repository: coordinate.fullName,
|
|
378
|
+
repositoryState,
|
|
379
|
+
pullRequests,
|
|
380
|
+
target: selectedTarget,
|
|
381
|
+
ancestry,
|
|
382
|
+
});
|
|
383
|
+
},
|
|
384
|
+
);
|
|
179
385
|
return createEngineeringHousekeeperPlan({
|
|
180
386
|
repository: coordinate.fullName,
|
|
181
387
|
target,
|