@kungfu-tech/buildchain 2.4.11 → 2.5.0-alpha.0
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/CONTRIBUTING.md +9 -0
- package/dist/site/buildchain-site.json +3 -0
- package/dist/site/release-model.json +3 -0
- package/dist/site/workflow-registry.json +6 -0
- package/docs/MAP.md +3 -0
- package/docs/migration-inventory.md +17 -7
- package/docs/release-governance.md +62 -0
- package/docs/versioning.md +1 -0
- package/package.json +1 -1
- package/scripts/check-inventory.mjs +11 -0
- package/scripts/dev-pr-auto-merge.mjs +436 -0
- package/scripts/generate-site-bundle.mjs +2 -0
- package/scripts/workflow-friction-report.mjs +82 -5
package/CONTRIBUTING.md
CHANGED
|
@@ -103,6 +103,15 @@ release/vX/vX.Y -> publish-gate/major
|
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
- Open normal changes against the relevant `dev/*` branch.
|
|
106
|
+
- Treat `dev/vX/vX.Y` branches as protected development channels. Do not use
|
|
107
|
+
them for direct ad-hoc commits. Work from `feature/*`, `fix/*`, `chore/*`,
|
|
108
|
+
`docs/*`, `ci/*`, or `refactor/*` branches and open a PR into the target dev
|
|
109
|
+
line.
|
|
110
|
+
- Repositories may call
|
|
111
|
+
`.github/workflows/dev-pr-auto-merge.yml` from their own scheduled or manual
|
|
112
|
+
wrapper to merge ready, conflict-free dev PRs. The wrapper is policy-gated:
|
|
113
|
+
required checks, ready/block labels, same-repository heads, approvals,
|
|
114
|
+
branch prefixes, max merges, and dry-run are all declared inputs.
|
|
106
115
|
- When a Buildchain change needs downstream validation before stable refs move,
|
|
107
116
|
publish a temporary runtime train ref such as
|
|
108
117
|
`train/v2/v2.3/<capability>` and ask consumers to run trusted
|
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
"surface": "release-governance",
|
|
21
21
|
"status": "active"
|
|
22
22
|
},
|
|
23
|
+
{
|
|
24
|
+
"id": "dev-pr-auto-merge",
|
|
25
|
+
"path": ".github/workflows/dev-pr-auto-merge.yml",
|
|
26
|
+
"surface": "dev-governance",
|
|
27
|
+
"status": "active"
|
|
28
|
+
},
|
|
23
29
|
{
|
|
24
30
|
"id": "binary-distribution",
|
|
25
31
|
"path": ".github/workflows/binary-distribution.yml",
|
package/docs/MAP.md
CHANGED
|
@@ -24,6 +24,7 @@ running artifact), *use* (consume / extend) - and a **status**:
|
|
|
24
24
|
| How do I import Buildchain toolkit APIs from JavaScript build code? | [`toolkit-observability.md`](toolkit-observability.md) + [`../packages/core/README.md`](../packages/core/README.md) | use | stable |
|
|
25
25
|
| How do I initialize a new repository? | [`cli.md`](cli.md) + [`lifecycle-protocol.md`](lifecycle-protocol.md) | use | stable |
|
|
26
26
|
| Why does Buildchain use branch-driven release governance? | [`release-governance.md`](release-governance.md) | why | stable |
|
|
27
|
+
| How do protected dev branches and scheduled ready-PR merging work? | [`release-governance.md`](release-governance.md#protected-dev-branches) | use | stable |
|
|
27
28
|
| How does Buildchain decide patch, minor, and major release lines? | [`versioning.md`](versioning.md) | why | stable |
|
|
28
29
|
| What exact branch/tag state machine runs on alpha, release, and major gate? | [`release-flow.md`](release-flow.md) | verify | stable |
|
|
29
30
|
| What did Buildchain migrate or retire from old action repositories? | [`migration-inventory.md`](migration-inventory.md) | verify | stable |
|
|
@@ -57,6 +58,8 @@ running artifact), *use* (consume / extend) - and a **status**:
|
|
|
57
58
|
[`versioning.md`](versioning.md).
|
|
58
59
|
- **dry-run / what would happen if this channel PR merges** -> [`cli.md`](cli.md)
|
|
59
60
|
and [`release-flow.md`](release-flow.md).
|
|
61
|
+
- **protected dev branches / scheduled ready-PR merge** ->
|
|
62
|
+
[`release-governance.md`](release-governance.md#protected-dev-branches).
|
|
60
63
|
- **pnpm / npm / yarn / package-manager adapters** ->
|
|
61
64
|
[`lifecycle-protocol.md`](lifecycle-protocol.md).
|
|
62
65
|
- **pip / Conan / CMake / custom commands** -> [`lifecycle-protocol.md`](lifecycle-protocol.md)
|
|
@@ -21,13 +21,14 @@ The active Buildchain-native reusable surface is:
|
|
|
21
21
|
| --- | --- |
|
|
22
22
|
| `.github/workflows/.build.yml` | active reusable build contract: runner presets, trusted event gate, publish source lock, lifecycle commands, deterministic artifacts, aggregate summary, and release manifest outputs |
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
Hidden reusable workflow files from the old `workflows` repository are retained
|
|
25
|
+
only when they still have an active compatibility or migration boundary. Retired
|
|
26
|
+
PR orchestration paths are not kept in `.github/workflows`; they remain listed
|
|
27
|
+
in the inventory as excluded legacy surfaces. In particular,
|
|
28
|
+
`.batch-pull-request.yml` is removed with the retired batch PR action family,
|
|
29
|
+
and the legacy `.release-new-version.yml` path is not the modern publish
|
|
30
|
+
surface. New publish integrations should use `.build.yml`, `buildchain.toml`,
|
|
31
|
+
`lifecycle.publish`, and publish transaction evidence.
|
|
31
32
|
|
|
32
33
|
## Migrated Actions
|
|
33
34
|
|
|
@@ -64,6 +65,15 @@ because the action is part of that retired path.
|
|
|
64
65
|
| `action-sync-pr` | retired PR synchronization helper; not part of the Buildchain reusable contract |
|
|
65
66
|
| `action-update-dependencies-version` | retired dependency-version helper; use package-manager adapters and lifecycle commands |
|
|
66
67
|
|
|
68
|
+
## Retired Workflows Excluded From v2
|
|
69
|
+
|
|
70
|
+
These legacy workflow entrypoints are intentionally not shipped from the root
|
|
71
|
+
`.github/workflows` directory.
|
|
72
|
+
|
|
73
|
+
| Previous workflow | Reason |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `.batch-pull-request.yml` | retired PR orchestration helper; v2.5 dev integration governance will use a new protected-dev PR protocol instead |
|
|
76
|
+
|
|
67
77
|
## Buildchain-Native Actions
|
|
68
78
|
|
|
69
79
|
These actions are new Buildchain v2 surfaces rather than migrations from an
|
|
@@ -219,6 +219,68 @@ Checking out `publish-gate/major` should therefore look like a frozen release
|
|
|
219
219
|
state, not like a branch where day-to-day source work continues. Day-to-day
|
|
220
220
|
source work continues on `dev/vX/vX.Y`.
|
|
221
221
|
|
|
222
|
+
## Protected Dev Branches
|
|
223
|
+
|
|
224
|
+
`dev/vX/vX.Y` is a protected development channel, not a scratch branch. Normal
|
|
225
|
+
source changes should be made on work branches such as `feature/*`, `fix/*`,
|
|
226
|
+
`chore/*`, `docs/*`, `ci/*`, or `refactor/*`, then reviewed through a pull
|
|
227
|
+
request into the target dev line.
|
|
228
|
+
|
|
229
|
+
This keeps the earliest development channel audit-friendly:
|
|
230
|
+
|
|
231
|
+
- the version line being changed is visible in the PR base branch;
|
|
232
|
+
- CI and required checks run before the channel moves;
|
|
233
|
+
- branch protection can prevent direct pushes and stale merges;
|
|
234
|
+
- later `dev -> alpha -> release` promotion inherits a reviewable source
|
|
235
|
+
lineage instead of trying to reconstruct how the dev branch changed.
|
|
236
|
+
|
|
237
|
+
Buildchain provides the reusable
|
|
238
|
+
`.github/workflows/dev-pr-auto-merge.yml` workflow for repositories that want a
|
|
239
|
+
scheduled or manual "merge ready dev PRs" pass. The consumer repository owns
|
|
240
|
+
the trigger schedule, but the merge decision is declared through workflow
|
|
241
|
+
inputs: target dev branch, required status/check names, ready and block labels,
|
|
242
|
+
allowed work-branch prefixes, review requirements, maximum merges per run,
|
|
243
|
+
merge method, and dry-run mode.
|
|
244
|
+
|
|
245
|
+
The workflow defaults are conservative. A PR is skipped unless it targets the
|
|
246
|
+
configured dev line, is not a draft, has the ready label, has no block label,
|
|
247
|
+
comes from the same repository, uses an allowed work-branch prefix, has a
|
|
248
|
+
current approval, is mergeable, and has the configured required checks passing.
|
|
249
|
+
After each merge, the next PR is re-evaluated before it can move the protected
|
|
250
|
+
dev branch. This prevents one merge from silently making the next candidate
|
|
251
|
+
stale or conflicting.
|
|
252
|
+
|
|
253
|
+
Typical consumer wrapper:
|
|
254
|
+
|
|
255
|
+
```yaml
|
|
256
|
+
name: Merge Ready Dev PRs
|
|
257
|
+
|
|
258
|
+
on:
|
|
259
|
+
schedule:
|
|
260
|
+
- cron: "17 * * * *"
|
|
261
|
+
workflow_dispatch:
|
|
262
|
+
inputs:
|
|
263
|
+
dry-run:
|
|
264
|
+
type: boolean
|
|
265
|
+
default: true
|
|
266
|
+
|
|
267
|
+
jobs:
|
|
268
|
+
merge-dev:
|
|
269
|
+
uses: kungfu-systems/buildchain/.github/workflows/dev-pr-auto-merge.yml@v2
|
|
270
|
+
permissions:
|
|
271
|
+
contents: write
|
|
272
|
+
pull-requests: write
|
|
273
|
+
checks: read
|
|
274
|
+
statuses: read
|
|
275
|
+
with:
|
|
276
|
+
target-branch: dev/v2/v2.5
|
|
277
|
+
required-status-checks: Verify
|
|
278
|
+
ready-label: ready
|
|
279
|
+
block-labels: blocked,do-not-merge
|
|
280
|
+
max-merges: 1
|
|
281
|
+
dry-run: ${{ inputs.dry-run || false }}
|
|
282
|
+
```
|
|
283
|
+
|
|
222
284
|
## Package-Manager Adapters
|
|
223
285
|
|
|
224
286
|
Old ABV assumed JavaScript repositories with root version state and often
|
package/docs/versioning.md
CHANGED
|
@@ -44,6 +44,7 @@ expected ref flow requires a major bump.
|
|
|
44
44
|
| 2026-07-02 | Release passport and binary distribution are a minor surface. | `v2.2` | They add agent-readable release passport files, artifact evidence, impact ledger, agent index, GitHub Release collection and verification commands, and standalone binary assets. |
|
|
45
45
|
| 2026-07-02 | Web surface host mapping is a minor surface. | `v2.3` | It adds first-class multi-host surface bindings, reusable workflow URL outputs, per-surface deployment overrides, and an agent-readable fixture contract. |
|
|
46
46
|
| 2026-07-03 | Infra contract lifecycle is a minor surface. | `v2.4` | It adds the provider-neutral `infra-contract` CLI command family, project type, adapter capability contract, lifecycle evidence bundle, propagation evidence, CI evidence mode, and consumer-facing contract artifacts. |
|
|
47
|
+
| 2026-07-04 | Scheduled integration governance is a minor surface. | `v2.5` | It adds scheduled feature-branch discovery, conflict-free integration, reporting, and agent-visible governance automation for dev-line maintenance. |
|
|
47
48
|
|
|
48
49
|
## Runner Policy
|
|
49
50
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kungfu-tech/buildchain",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0-alpha.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
|
|
6
6
|
"repository": "https://github.com/kungfu-systems/buildchain",
|
|
@@ -41,6 +41,7 @@ const requiredPaths = [
|
|
|
41
41
|
".github/actionlint.yaml",
|
|
42
42
|
".github/workflows/self-hosted-runner-smoke.yml",
|
|
43
43
|
".github/workflows/buildchain-ref-promotion.yml",
|
|
44
|
+
".github/workflows/dev-pr-auto-merge.yml",
|
|
44
45
|
".github/workflows/release-candidate-promote.yml",
|
|
45
46
|
".github/workflows/npm-publish.yml",
|
|
46
47
|
".github/workflows/binary-distribution.yml",
|
|
@@ -329,6 +330,9 @@ if (!Array.isArray(inventory.workflowSources) || inventory.workflowSources.lengt
|
|
|
329
330
|
if (!Array.isArray(inventory.retiredActionsExcluded) || inventory.retiredActionsExcluded.length === 0) {
|
|
330
331
|
throw new Error("retiredActionsExcluded must list retired legacy actions");
|
|
331
332
|
}
|
|
333
|
+
if (!Array.isArray(inventory.retiredWorkflowsExcluded) || inventory.retiredWorkflowsExcluded.length === 0) {
|
|
334
|
+
throw new Error("retiredWorkflowsExcluded must list retired legacy workflows");
|
|
335
|
+
}
|
|
332
336
|
|
|
333
337
|
const actualActions = fs
|
|
334
338
|
.readdirSync(path.join(root, "actions"), { withFileTypes: true })
|
|
@@ -356,6 +360,13 @@ for (const retiredRepo of inventory.retiredActionsExcluded || []) {
|
|
|
356
360
|
}
|
|
357
361
|
}
|
|
358
362
|
|
|
363
|
+
for (const retiredWorkflow of inventory.retiredWorkflowsExcluded || []) {
|
|
364
|
+
const retiredPath = path.join(root, retiredWorkflow);
|
|
365
|
+
if (fs.existsSync(retiredPath)) {
|
|
366
|
+
throw new Error(`retired workflow must not be shipped in buildchain v2: ${retiredWorkflow}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
359
370
|
for (const action of shippedActions) {
|
|
360
371
|
for (const key of ["path", "runtime", "build", "bundle"]) {
|
|
361
372
|
if (!action[key]) {
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BLOCK_LABELS = ["blocked", "do-not-merge", "work-in-progress"];
|
|
6
|
+
const DEFAULT_ALLOWED_HEAD_PREFIXES = ["feature/", "fix/", "chore/", "docs/", "ci/", "refactor/"];
|
|
7
|
+
const DEFAULT_REQUIRED_CHECKS = ["Verify"];
|
|
8
|
+
const SUCCESS_STATES = new Set(["success"]);
|
|
9
|
+
const SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
|
|
10
|
+
|
|
11
|
+
function splitList(value, fallback = []) {
|
|
12
|
+
if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
|
|
13
|
+
const text = String(value ?? "").trim();
|
|
14
|
+
if (!text) return [...fallback];
|
|
15
|
+
return text
|
|
16
|
+
.split(/[\n,]+/)
|
|
17
|
+
.map((entry) => entry.trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function boolOption(value, fallback = false) {
|
|
22
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
23
|
+
if (typeof value === "boolean") return value;
|
|
24
|
+
const text = String(value).trim().toLowerCase();
|
|
25
|
+
return ["1", "true", "yes", "on"].includes(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function intOption(value, fallback) {
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeRepo(value) {
|
|
34
|
+
const text = String(value || "").trim();
|
|
35
|
+
const match = text.match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
36
|
+
if (!match) throw new Error(`repository must be owner/repo, got: ${text || "<empty>"}`);
|
|
37
|
+
return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeOptions(options = {}) {
|
|
41
|
+
return {
|
|
42
|
+
repository: normalizeRepo(options.repository || process.env.GITHUB_REPOSITORY),
|
|
43
|
+
targetBranch: String(options.targetBranch || "").replace(/^refs\/heads\//, ""),
|
|
44
|
+
readyLabel: String(options.readyLabel ?? "ready").trim(),
|
|
45
|
+
blockLabels: splitList(options.blockLabels, DEFAULT_BLOCK_LABELS).map((label) => label.toLowerCase()),
|
|
46
|
+
allowedHeadPrefixes: splitList(options.allowedHeadPrefixes, DEFAULT_ALLOWED_HEAD_PREFIXES),
|
|
47
|
+
requiredChecks: splitList(options.requiredChecks, DEFAULT_REQUIRED_CHECKS),
|
|
48
|
+
requireApproval: boolOption(options.requireApproval, true),
|
|
49
|
+
sameRepositoryOnly: boolOption(options.sameRepositoryOnly, true),
|
|
50
|
+
maxMerges: intOption(options.maxMerges, 1),
|
|
51
|
+
mergeMethod: String(options.mergeMethod || "merge").trim(),
|
|
52
|
+
dryRun: boolOption(options.dryRun, true),
|
|
53
|
+
pollMergeableAttempts: intOption(options.pollMergeableAttempts, 3),
|
|
54
|
+
pollMergeableDelayMs: intOption(options.pollMergeableDelayMs, 1000),
|
|
55
|
+
outputPath: String(options.outputPath || ".buildchain/dev-pr-auto-merge/result.json"),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function labelsOf(pr) {
|
|
60
|
+
return (pr.labels || []).map((label) => String(label.name || label).toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasReadyLabel(pr, readyLabel) {
|
|
64
|
+
if (!readyLabel) return true;
|
|
65
|
+
return labelsOf(pr).includes(readyLabel.toLowerCase());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasBlockedLabel(pr, blockLabels) {
|
|
69
|
+
const labels = labelsOf(pr);
|
|
70
|
+
return blockLabels.some((label) => labels.includes(label));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function headPrefixAllowed(pr, prefixes) {
|
|
74
|
+
if (prefixes.length === 0) return true;
|
|
75
|
+
const headRef = String(pr.head?.ref || "");
|
|
76
|
+
return prefixes.some((prefix) => headRef.startsWith(prefix));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sameRepositoryAllowed(pr, repository, sameRepositoryOnly) {
|
|
80
|
+
if (!sameRepositoryOnly) return true;
|
|
81
|
+
return pr.head?.repo?.full_name === repository.fullName;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function latestReviewStates(reviews = []) {
|
|
85
|
+
const latest = new Map();
|
|
86
|
+
for (const review of reviews) {
|
|
87
|
+
const user = review.user?.login;
|
|
88
|
+
if (!user) continue;
|
|
89
|
+
latest.set(user, String(review.state || "").toUpperCase());
|
|
90
|
+
}
|
|
91
|
+
return [...latest.values()];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasRequiredApproval(reviews = []) {
|
|
95
|
+
const states = latestReviewStates(reviews);
|
|
96
|
+
return states.includes("APPROVED") && !states.includes("CHANGES_REQUESTED");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function checkMatchesRequired(name, required) {
|
|
100
|
+
const haystack = String(name || "").toLowerCase();
|
|
101
|
+
return haystack === required.toLowerCase() || haystack.includes(required.toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function summarizeChecks({ statuses = [], checkRuns = [] } = {}, requiredChecks = []) {
|
|
105
|
+
const summary = [];
|
|
106
|
+
for (const required of requiredChecks) {
|
|
107
|
+
const matchingStatuses = statuses.filter((status) => checkMatchesRequired(status.context, required));
|
|
108
|
+
const matchingRuns = checkRuns.filter((run) => checkMatchesRequired(run.name, required));
|
|
109
|
+
const passedStatuses = matchingStatuses.filter((status) => SUCCESS_STATES.has(String(status.state || "").toLowerCase()));
|
|
110
|
+
const passedRuns = matchingRuns.filter((run) => SUCCESS_CONCLUSIONS.has(String(run.conclusion || "").toLowerCase()));
|
|
111
|
+
const passed = passedStatuses.length > 0 || passedRuns.length > 0;
|
|
112
|
+
summary.push({
|
|
113
|
+
required,
|
|
114
|
+
passed,
|
|
115
|
+
matches: [
|
|
116
|
+
...matchingStatuses.map((status) => ({
|
|
117
|
+
type: "status",
|
|
118
|
+
name: status.context,
|
|
119
|
+
state: status.state,
|
|
120
|
+
})),
|
|
121
|
+
...matchingRuns.map((run) => ({
|
|
122
|
+
type: "check-run",
|
|
123
|
+
name: run.name,
|
|
124
|
+
conclusion: run.conclusion,
|
|
125
|
+
status: run.status,
|
|
126
|
+
})),
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
required: requiredChecks,
|
|
132
|
+
entries: summary,
|
|
133
|
+
passed: summary.every((entry) => entry.passed),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function mergeableAccepted(pr) {
|
|
138
|
+
if (pr.mergeable === false) return false;
|
|
139
|
+
const state = String(pr.mergeable_state || pr.mergeStateStatus || "").toLowerCase();
|
|
140
|
+
if (!state) return pr.mergeable === true;
|
|
141
|
+
return ["clean", "has_hooks", "unstable", "unknown"].includes(state);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function skip(reason, details = {}) {
|
|
145
|
+
return { action: "skip", reason, ...details };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function evaluatePullRequest(pr, options, client) {
|
|
149
|
+
options = options.repository?.fullName ? options : normalizeOptions(options);
|
|
150
|
+
if (pr.draft) return skip("draft");
|
|
151
|
+
if (!sameRepositoryAllowed(pr, options.repository, options.sameRepositoryOnly)) {
|
|
152
|
+
return skip("fork-or-cross-repository-head", { headRepository: pr.head?.repo?.full_name || "" });
|
|
153
|
+
}
|
|
154
|
+
if (!headPrefixAllowed(pr, options.allowedHeadPrefixes)) {
|
|
155
|
+
return skip("head-prefix-not-allowed", { headRef: pr.head?.ref || "" });
|
|
156
|
+
}
|
|
157
|
+
if (!hasReadyLabel(pr, options.readyLabel)) {
|
|
158
|
+
return skip("missing-ready-label", { requiredLabel: options.readyLabel });
|
|
159
|
+
}
|
|
160
|
+
if (hasBlockedLabel(pr, options.blockLabels)) {
|
|
161
|
+
return skip("blocked-label", { labels: labelsOf(pr) });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const detailed = await client.getPullRequest(pr.number, {
|
|
165
|
+
attempts: options.pollMergeableAttempts,
|
|
166
|
+
delayMs: options.pollMergeableDelayMs,
|
|
167
|
+
});
|
|
168
|
+
if (!mergeableAccepted(detailed)) {
|
|
169
|
+
return skip("not-mergeable", {
|
|
170
|
+
mergeable: detailed.mergeable,
|
|
171
|
+
mergeableState: detailed.mergeable_state || detailed.mergeStateStatus || "",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (options.requireApproval) {
|
|
176
|
+
const reviews = await client.listReviews(pr.number);
|
|
177
|
+
if (!hasRequiredApproval(reviews)) return skip("missing-approval");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const checks = await client.listCommitChecks(pr.head?.sha);
|
|
181
|
+
const checkSummary = summarizeChecks(checks, options.requiredChecks);
|
|
182
|
+
if (!checkSummary.passed) return skip("required-checks-not-passing", { checks: checkSummary });
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
action: options.dryRun ? "would-merge" : "merge",
|
|
186
|
+
reason: options.dryRun ? "dry-run" : "eligible",
|
|
187
|
+
checks: checkSummary,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function delay(ms) {
|
|
192
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseLinkHeader(header) {
|
|
196
|
+
const links = {};
|
|
197
|
+
for (const part of String(header || "").split(",")) {
|
|
198
|
+
const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/);
|
|
199
|
+
if (match) links[match[2]] = match[1];
|
|
200
|
+
}
|
|
201
|
+
return links;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export class GitHubClient {
|
|
205
|
+
constructor({ token, repository, apiUrl = "https://api.github.com", fetchImpl = globalThis.fetch } = {}) {
|
|
206
|
+
if (!fetchImpl) throw new Error("fetch is required");
|
|
207
|
+
if (!token) throw new Error("GITHUB_TOKEN is required");
|
|
208
|
+
this.token = token;
|
|
209
|
+
this.repository = repository;
|
|
210
|
+
this.apiUrl = apiUrl.replace(/\/+$/, "");
|
|
211
|
+
this.fetch = fetchImpl;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async request(method, requestPath, { body, accept = "application/vnd.github+json" } = {}) {
|
|
215
|
+
const url = requestPath.startsWith("http") ? requestPath : `${this.apiUrl}${requestPath}`;
|
|
216
|
+
const response = await this.fetch(url, {
|
|
217
|
+
method,
|
|
218
|
+
headers: {
|
|
219
|
+
accept,
|
|
220
|
+
authorization: `Bearer ${this.token}`,
|
|
221
|
+
"content-type": "application/json",
|
|
222
|
+
"x-github-api-version": "2022-11-28",
|
|
223
|
+
},
|
|
224
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
225
|
+
});
|
|
226
|
+
const text = await response.text();
|
|
227
|
+
const data = text ? JSON.parse(text) : null;
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
const message = data?.message || text || `${method} ${url} failed`;
|
|
230
|
+
const error = new Error(message);
|
|
231
|
+
error.status = response.status;
|
|
232
|
+
error.data = data;
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
return { data, response };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async paginate(requestPath) {
|
|
239
|
+
let next = requestPath;
|
|
240
|
+
const items = [];
|
|
241
|
+
while (next) {
|
|
242
|
+
const { data, response } = await this.request("GET", next);
|
|
243
|
+
items.push(...data);
|
|
244
|
+
next = parseLinkHeader(response.headers.get("link")).next;
|
|
245
|
+
}
|
|
246
|
+
return items;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async listPullRequests(base) {
|
|
250
|
+
const query = new URLSearchParams({
|
|
251
|
+
state: "open",
|
|
252
|
+
base,
|
|
253
|
+
sort: "updated",
|
|
254
|
+
direction: "asc",
|
|
255
|
+
per_page: "100",
|
|
256
|
+
});
|
|
257
|
+
return this.paginate(`/repos/${this.repository.owner}/${this.repository.repo}/pulls?${query}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async getPullRequest(number, { attempts = 3, delayMs = 1000 } = {}) {
|
|
261
|
+
let last;
|
|
262
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
263
|
+
const { data } = await this.request(
|
|
264
|
+
"GET",
|
|
265
|
+
`/repos/${this.repository.owner}/${this.repository.repo}/pulls/${number}`,
|
|
266
|
+
);
|
|
267
|
+
last = data;
|
|
268
|
+
if (data.mergeable !== null && data.mergeable !== undefined) return data;
|
|
269
|
+
if (attempt < attempts) await delay(delayMs);
|
|
270
|
+
}
|
|
271
|
+
return last;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async listReviews(number) {
|
|
275
|
+
return this.paginate(`/repos/${this.repository.owner}/${this.repository.repo}/pulls/${number}/reviews?per_page=100`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async listCommitChecks(sha) {
|
|
279
|
+
if (!sha) return { statuses: [], checkRuns: [] };
|
|
280
|
+
const [{ data: statusData }, { data: checkRunData }] = await Promise.all([
|
|
281
|
+
this.request("GET", `/repos/${this.repository.owner}/${this.repository.repo}/commits/${sha}/status`),
|
|
282
|
+
this.request("GET", `/repos/${this.repository.owner}/${this.repository.repo}/commits/${sha}/check-runs?per_page=100`),
|
|
283
|
+
]);
|
|
284
|
+
return {
|
|
285
|
+
statuses: statusData.statuses || [],
|
|
286
|
+
checkRuns: checkRunData.check_runs || [],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async mergePullRequest(number, { method, sha }) {
|
|
291
|
+
const { data } = await this.request("PUT", `/repos/${this.repository.owner}/${this.repository.repo}/pulls/${number}/merge`, {
|
|
292
|
+
body: {
|
|
293
|
+
merge_method: method,
|
|
294
|
+
sha,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
return data;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async getBranchSha(branch) {
|
|
301
|
+
const ref = encodeURIComponent(`heads/${branch}`).replace(/%2F/g, "/");
|
|
302
|
+
const { data } = await this.request("GET", `/repos/${this.repository.owner}/${this.repository.repo}/git/ref/${ref}`);
|
|
303
|
+
return data.object?.sha || "";
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
|
|
308
|
+
const options = normalizeOptions(optionsInput);
|
|
309
|
+
if (!options.targetBranch) throw new Error("target branch is required");
|
|
310
|
+
const client = clientInput || new GitHubClient({
|
|
311
|
+
token: optionsInput.token || process.env.GITHUB_TOKEN,
|
|
312
|
+
repository: options.repository,
|
|
313
|
+
apiUrl: optionsInput.apiUrl || process.env.GITHUB_API_URL || "https://api.github.com",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const pullRequests = await client.listPullRequests(options.targetBranch);
|
|
317
|
+
const result = {
|
|
318
|
+
schemaVersion: 1,
|
|
319
|
+
contract: "kungfu-buildchain-dev-pr-auto-merge",
|
|
320
|
+
repository: options.repository.fullName,
|
|
321
|
+
targetBranch: options.targetBranch,
|
|
322
|
+
dryRun: options.dryRun,
|
|
323
|
+
maxMerges: options.maxMerges,
|
|
324
|
+
evaluated: [],
|
|
325
|
+
merged: [],
|
|
326
|
+
skipped: [],
|
|
327
|
+
finalBaseSha: "",
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
for (const pr of pullRequests) {
|
|
331
|
+
if (result.merged.length >= options.maxMerges) {
|
|
332
|
+
const entry = { number: pr.number, title: pr.title || "", action: "skip", reason: "max-merges-reached" };
|
|
333
|
+
result.evaluated.push(entry);
|
|
334
|
+
result.skipped.push(entry);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const decision = await evaluatePullRequest(pr, options, client);
|
|
339
|
+
const entry = {
|
|
340
|
+
number: pr.number,
|
|
341
|
+
title: pr.title || "",
|
|
342
|
+
headRef: pr.head?.ref || "",
|
|
343
|
+
headSha: pr.head?.sha || "",
|
|
344
|
+
action: decision.action,
|
|
345
|
+
reason: decision.reason,
|
|
346
|
+
checks: decision.checks,
|
|
347
|
+
};
|
|
348
|
+
result.evaluated.push(entry);
|
|
349
|
+
|
|
350
|
+
if (decision.action === "merge") {
|
|
351
|
+
const mergeResult = await client.mergePullRequest(pr.number, {
|
|
352
|
+
method: options.mergeMethod,
|
|
353
|
+
sha: pr.head?.sha,
|
|
354
|
+
});
|
|
355
|
+
const mergedEntry = { ...entry, mergeSha: mergeResult.sha || "" };
|
|
356
|
+
result.merged.push(mergedEntry);
|
|
357
|
+
result.evaluated[result.evaluated.length - 1] = mergedEntry;
|
|
358
|
+
} else if (decision.action === "would-merge") {
|
|
359
|
+
result.merged.push(entry);
|
|
360
|
+
} else {
|
|
361
|
+
result.skipped.push(entry);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
result.finalBaseSha = await client.getBranchSha(options.targetBranch).catch(() => "");
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function renderMarkdownSummary(result) {
|
|
370
|
+
const lines = [
|
|
371
|
+
"## Buildchain dev PR auto-merge",
|
|
372
|
+
"",
|
|
373
|
+
`Repository: \`${result.repository}\``,
|
|
374
|
+
`Target branch: \`${result.targetBranch}\``,
|
|
375
|
+
`Mode: \`${result.dryRun ? "dry-run" : "merge"}\``,
|
|
376
|
+
`Evaluated PRs: ${result.evaluated.length}`,
|
|
377
|
+
`Eligible ${result.dryRun ? "dry-run" : "merged"} PRs: ${result.merged.length}`,
|
|
378
|
+
"",
|
|
379
|
+
"| PR | Action | Reason | Head |",
|
|
380
|
+
"| --- | --- | --- | --- |",
|
|
381
|
+
];
|
|
382
|
+
for (const entry of result.evaluated) {
|
|
383
|
+
lines.push(`| #${entry.number} | ${entry.action} | ${entry.reason} | \`${entry.headRef || ""}\` |`);
|
|
384
|
+
}
|
|
385
|
+
if (result.evaluated.length === 0) lines.push("| - | skip | no open pull requests | - |");
|
|
386
|
+
if (!result.dryRun && result.finalBaseSha) {
|
|
387
|
+
lines.push("", `Final target branch SHA: \`${result.finalBaseSha}\``);
|
|
388
|
+
}
|
|
389
|
+
return `${lines.join("\n")}\n`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function writeGitHubOutputs(outputs, outputFile = process.env.GITHUB_OUTPUT) {
|
|
393
|
+
if (!outputFile) return;
|
|
394
|
+
const lines = [];
|
|
395
|
+
for (const [key, value] of Object.entries(outputs)) {
|
|
396
|
+
lines.push(`${key}=${String(value).replace(/\n/g, "%0A")}`);
|
|
397
|
+
}
|
|
398
|
+
fs.appendFileSync(outputFile, `${lines.join("\n")}\n`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function main() {
|
|
402
|
+
const options = normalizeOptions({
|
|
403
|
+
repository: process.env.BUILDCHAIN_DEV_PR_REPOSITORY || process.env.GITHUB_REPOSITORY,
|
|
404
|
+
targetBranch: process.env.BUILDCHAIN_DEV_PR_TARGET_BRANCH || process.env.GITHUB_REF_NAME,
|
|
405
|
+
readyLabel: process.env.BUILDCHAIN_DEV_PR_READY_LABEL,
|
|
406
|
+
blockLabels: process.env.BUILDCHAIN_DEV_PR_BLOCK_LABELS,
|
|
407
|
+
allowedHeadPrefixes: process.env.BUILDCHAIN_DEV_PR_ALLOWED_HEAD_PREFIXES,
|
|
408
|
+
requiredChecks: process.env.BUILDCHAIN_DEV_PR_REQUIRED_CHECKS,
|
|
409
|
+
requireApproval: process.env.BUILDCHAIN_DEV_PR_REQUIRE_APPROVAL,
|
|
410
|
+
sameRepositoryOnly: process.env.BUILDCHAIN_DEV_PR_SAME_REPOSITORY_ONLY,
|
|
411
|
+
maxMerges: process.env.BUILDCHAIN_DEV_PR_MAX_MERGES,
|
|
412
|
+
mergeMethod: process.env.BUILDCHAIN_DEV_PR_MERGE_METHOD,
|
|
413
|
+
dryRun: process.env.BUILDCHAIN_DEV_PR_DRY_RUN,
|
|
414
|
+
outputPath: process.env.BUILDCHAIN_DEV_PR_OUTPUT_PATH,
|
|
415
|
+
});
|
|
416
|
+
const result = await runDevPrAutoMerge(options);
|
|
417
|
+
fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
|
|
418
|
+
fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
419
|
+
const summary = renderMarkdownSummary(result);
|
|
420
|
+
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
|
421
|
+
else process.stdout.write(summary);
|
|
422
|
+
writeGitHubOutputs({
|
|
423
|
+
"evaluated-count": result.evaluated.length,
|
|
424
|
+
"merged-count": result.merged.length,
|
|
425
|
+
"skipped-count": result.skipped.length,
|
|
426
|
+
"final-base-sha": result.finalBaseSha,
|
|
427
|
+
"result-path": options.outputPath,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
432
|
+
main().catch((error) => {
|
|
433
|
+
console.error(error.stack || error.message);
|
|
434
|
+
process.exit(1);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
@@ -84,6 +84,7 @@ function buildSiteBundle() {
|
|
|
84
84
|
{ id: "build", path: ".github/workflows/.build.yml", surface: "reusable-build", status: "active" },
|
|
85
85
|
{ id: "web-surface", path: ".github/workflows/.web-surface.yml", surface: "site-app-deployment", status: "active" },
|
|
86
86
|
{ id: "buildchain-ref-promotion", path: ".github/workflows/buildchain-ref-promotion.yml", surface: "release-governance", status: "active" },
|
|
87
|
+
{ id: "dev-pr-auto-merge", path: ".github/workflows/dev-pr-auto-merge.yml", surface: "dev-governance", status: "active" },
|
|
87
88
|
{ id: "binary-distribution", path: ".github/workflows/binary-distribution.yml", surface: "release-passport", status: "active" },
|
|
88
89
|
],
|
|
89
90
|
actions: [
|
|
@@ -99,6 +100,7 @@ function buildSiteBundle() {
|
|
|
99
100
|
exactTags: "v-prefixed exact tags are immutable release records.",
|
|
100
101
|
floatingTags: "vX, vX.Y, and vX.Y-alpha are channel pointers updated by Buildchain transactions.",
|
|
101
102
|
channelBranches: ["dev/vX/vX.Y", "alpha/vX/vX.Y", "release/vX/vX.Y", "publish-gate/major"],
|
|
103
|
+
protectedDevelopmentBranches: ["dev/vX/vX.Y"],
|
|
102
104
|
releasePassport: {
|
|
103
105
|
entrypoint: "buildchain.release.json",
|
|
104
106
|
bundle: "buildchain-release-bundle.tar.gz",
|
|
@@ -4,7 +4,17 @@ import path from "node:path";
|
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { writeGitHubOutputs } from "./build-contract-core.mjs";
|
|
6
6
|
|
|
7
|
-
const DEFAULT_BUILD_WORKFLOW_FILE = "build
|
|
7
|
+
const DEFAULT_BUILD_WORKFLOW_FILE = "build.yml";
|
|
8
|
+
const DEFAULT_BUILD_WORKFLOW_NAME = "Build";
|
|
9
|
+
|
|
10
|
+
class GitHubApiError extends Error {
|
|
11
|
+
constructor(message, { status, path: requestPath } = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "GitHubApiError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.path = requestPath;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
function env(name, fallback = "") {
|
|
10
20
|
return process.env[name] || fallback;
|
|
@@ -48,11 +58,71 @@ async function githubJson({ apiUrl, token, path: requestPath, fetchImpl = global
|
|
|
48
58
|
const text = await response.text();
|
|
49
59
|
const body = text ? JSON.parse(text) : {};
|
|
50
60
|
if (!response.ok) {
|
|
51
|
-
throw new
|
|
61
|
+
throw new GitHubApiError(`GitHub API ${requestPath} failed with ${response.status}: ${body.message || text}`, {
|
|
62
|
+
status: response.status,
|
|
63
|
+
path: requestPath,
|
|
64
|
+
});
|
|
52
65
|
}
|
|
53
66
|
return body;
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
function workflowRunMatchesConfiguredWorkflow(run, { buildWorkflowFile = "", buildWorkflowName = "" } = {}) {
|
|
70
|
+
const expectedName = String(buildWorkflowName || "").trim();
|
|
71
|
+
const expectedFile = String(buildWorkflowFile || "").trim();
|
|
72
|
+
const runName = String(run.name || run.workflow_name || "").trim();
|
|
73
|
+
const runPath = String(run.path || run.workflow_path || "").trim();
|
|
74
|
+
if (expectedName && runName === expectedName) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (expectedFile && (runPath === expectedFile || runPath.endsWith(`/${expectedFile}`))) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return !expectedName && !expectedFile;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function listPullRequestWorkflowRuns({
|
|
84
|
+
apiUrl,
|
|
85
|
+
token,
|
|
86
|
+
repo,
|
|
87
|
+
buildWorkflowFile,
|
|
88
|
+
buildWorkflowName,
|
|
89
|
+
fetchImpl,
|
|
90
|
+
}) {
|
|
91
|
+
const diagnostics = [];
|
|
92
|
+
const workflowFile = String(buildWorkflowFile || "").trim();
|
|
93
|
+
if (workflowFile) {
|
|
94
|
+
try {
|
|
95
|
+
const runs = await githubJson({
|
|
96
|
+
apiUrl,
|
|
97
|
+
token,
|
|
98
|
+
fetchImpl,
|
|
99
|
+
path: `/repos/${repo.owner}/${repo.repo}/actions/workflows/${encodeURIComponent(workflowFile)}/runs?event=pull_request&per_page=100`,
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
runs: Array.isArray(runs.workflow_runs) ? runs.workflow_runs : [],
|
|
103
|
+
diagnostics,
|
|
104
|
+
};
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error.status !== 404) {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
diagnostics.push(`Configured PR-stage workflow file ${workflowFile} was not found; fell back to repository pull_request workflow runs.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const runs = await githubJson({
|
|
114
|
+
apiUrl,
|
|
115
|
+
token,
|
|
116
|
+
fetchImpl,
|
|
117
|
+
path: `/repos/${repo.owner}/${repo.repo}/actions/runs?event=pull_request&per_page=100`,
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
runs: (Array.isArray(runs.workflow_runs) ? runs.workflow_runs : [])
|
|
121
|
+
.filter((run) => workflowRunMatchesConfiguredWorkflow(run, { buildWorkflowFile, buildWorkflowName })),
|
|
122
|
+
diagnostics,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
56
126
|
function runLabel(run = "") {
|
|
57
127
|
if (typeof run === "string") {
|
|
58
128
|
return run;
|
|
@@ -129,6 +199,7 @@ export async function classifyWorkflowFriction({
|
|
|
129
199
|
token = env("GITHUB_TOKEN"),
|
|
130
200
|
apiUrl = env("GITHUB_API_URL", "https://api.github.com"),
|
|
131
201
|
buildWorkflowFile = DEFAULT_BUILD_WORKFLOW_FILE,
|
|
202
|
+
buildWorkflowName = DEFAULT_BUILD_WORKFLOW_NAME,
|
|
132
203
|
releaseCandidateOutcome = env("BUILDCHAIN_RC_RESOLVE_OUTCOME"),
|
|
133
204
|
releaseCandidateDiagnosis = env("BUILDCHAIN_RC_DIAGNOSIS"),
|
|
134
205
|
runUrl = env("BUILDCHAIN_WORKFLOW_RUN_URL"),
|
|
@@ -154,14 +225,18 @@ export async function classifyWorkflowFriction({
|
|
|
154
225
|
|
|
155
226
|
let relatedRuns = [];
|
|
156
227
|
let heavyBuilds = [];
|
|
228
|
+
let workflowRunDiagnostics = [];
|
|
157
229
|
if (primaryPullRequest?.number) {
|
|
158
|
-
const
|
|
230
|
+
const workflowRuns = await listPullRequestWorkflowRuns({
|
|
159
231
|
apiUrl,
|
|
160
232
|
token,
|
|
233
|
+
repo,
|
|
234
|
+
buildWorkflowFile,
|
|
235
|
+
buildWorkflowName,
|
|
161
236
|
fetchImpl,
|
|
162
|
-
path: `/repos/${repo.owner}/${repo.repo}/actions/workflows/${encodeURIComponent(buildWorkflowFile)}/runs?event=pull_request&per_page=100`,
|
|
163
237
|
});
|
|
164
|
-
|
|
238
|
+
workflowRunDiagnostics = workflowRuns.diagnostics;
|
|
239
|
+
relatedRuns = workflowRuns.runs
|
|
165
240
|
.filter((run) => (run.pull_requests || []).some((pr) => Number(pr.number || 0) === Number(primaryPullRequest.number)));
|
|
166
241
|
heavyBuilds = relatedRuns.filter((run) => run.status === "completed" && ["success", "failure", "cancelled", "timed_out"].includes(run.conclusion || ""));
|
|
167
242
|
}
|
|
@@ -184,6 +259,7 @@ export async function classifyWorkflowFriction({
|
|
|
184
259
|
if (releaseCandidateDiagnosis) {
|
|
185
260
|
diagnosisParts.push(releaseCandidateDiagnosis);
|
|
186
261
|
}
|
|
262
|
+
diagnosisParts.push(...workflowRunDiagnostics);
|
|
187
263
|
const diagnosis = diagnosisParts.join(" ") || "Buildchain ref promotion failed after Verify succeeded; inspect the classified evidence and keep the fix in Buildchain.";
|
|
188
264
|
const nextAction = frictionClass === "late-fail-fast"
|
|
189
265
|
? "Move the missing/stale RC evidence check earlier or make the promotion workflow consume the exact PR-stage RC passport before any publish side effect."
|
|
@@ -229,6 +305,7 @@ export async function workflowFrictionReportCli() {
|
|
|
229
305
|
targetRef: env("BUILDCHAIN_TARGET_REF"),
|
|
230
306
|
targetSha: env("BUILDCHAIN_TARGET_SHA"),
|
|
231
307
|
buildWorkflowFile: env("BUILDCHAIN_BUILD_WORKFLOW_FILE", DEFAULT_BUILD_WORKFLOW_FILE),
|
|
308
|
+
buildWorkflowName: env("BUILDCHAIN_BUILD_WORKFLOW_NAME", env("BUILDCHAIN_RC_WORKFLOW_NAME", DEFAULT_BUILD_WORKFLOW_NAME)),
|
|
232
309
|
outputDir: env("BUILDCHAIN_FRICTION_OUTPUT_DIR", ".buildchain/workflow-friction"),
|
|
233
310
|
});
|
|
234
311
|
} catch (error) {
|