@kungfu-tech/buildchain 2.4.4 → 2.4.5-alpha.1
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 +19 -0
- package/actions/report-buildchain-issue/README.md +46 -0
- package/dist/site/release-provenance.json +1 -0
- package/docs/MAP.md +4 -1
- package/docs/consumer-issue-reporting.md +82 -0
- package/package.json +3 -1
- package/packages/core/index.js +16 -0
- package/packages/core/issue-reporting.js +388 -0
- package/scripts/check-inventory.mjs +7 -0
package/README.md
CHANGED
|
@@ -106,6 +106,23 @@ writeDiagnosticsArtifact(".buildchain/artifacts/diagnostics.json", {
|
|
|
106
106
|
platform manifest. It includes lifecycle-wide observability, runner/tool/cache
|
|
107
107
|
snapshots, Git state, and links to the larger manifest and artifact outputs.
|
|
108
108
|
|
|
109
|
+
Consumers can report Buildchain-owned workflow failures directly to the
|
|
110
|
+
Buildchain repository with a scoped issue-write token:
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
- uses: kungfu-systems/buildchain/actions/report-buildchain-issue@v2
|
|
114
|
+
if: failure()
|
|
115
|
+
with:
|
|
116
|
+
token: ${{ steps.buildchain-issue-token.outputs.token }}
|
|
117
|
+
summary: "Reusable build failed before artifact finalization"
|
|
118
|
+
failure-code: reusable-build-failed
|
|
119
|
+
buildchain-ref: v2
|
|
120
|
+
diagnostics-path: .buildchain/artifacts/diagnostics.json
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The action deduplicates by fingerprint, comments on existing open reports, and
|
|
124
|
+
is fail-soft by default so issue reporting does not hide the original failure.
|
|
125
|
+
|
|
109
126
|
## Use Buildchain
|
|
110
127
|
|
|
111
128
|
Bootstrap a repository:
|
|
@@ -125,6 +142,7 @@ Buildchain's active GitHub Action surface is deliberately small:
|
|
|
125
142
|
- `actions/validate-config`
|
|
126
143
|
- `actions/run-lifecycle`
|
|
127
144
|
- `actions/promote-buildchain-ref`
|
|
145
|
+
- `actions/report-buildchain-issue`
|
|
128
146
|
|
|
129
147
|
The active reusable workflow surfaces are:
|
|
130
148
|
|
|
@@ -234,5 +252,6 @@ npm pack --dry-run --json --registry=https://registry.npmjs.org/
|
|
|
234
252
|
- [Site bundle contract](docs/site-bundle-contract.md)
|
|
235
253
|
- [Lifecycle protocol](docs/lifecycle-protocol.md)
|
|
236
254
|
- [Reusable build surface](docs/reusable-build-surface.md)
|
|
255
|
+
- [Consumer issue reporting](docs/consumer-issue-reporting.md)
|
|
237
256
|
- [Publish transaction](docs/publish-transaction.md)
|
|
238
257
|
- [Release governance](docs/release-governance.md)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# report-buildchain-issue
|
|
2
|
+
|
|
3
|
+
Create or update a Buildchain repository issue from a consumer workflow.
|
|
4
|
+
|
|
5
|
+
This action is intended for consumer repositories that need to report
|
|
6
|
+
Buildchain-owned failures with enough evidence for maintainers to act. It
|
|
7
|
+
requires a token that can write issues on the target Buildchain repository.
|
|
8
|
+
For cross-repository consumers, generate that token with a GitHub App
|
|
9
|
+
installation token or another scoped credential owned by the consumer
|
|
10
|
+
organization.
|
|
11
|
+
|
|
12
|
+
```yaml
|
|
13
|
+
- uses: actions/create-github-app-token@v2
|
|
14
|
+
id: buildchain-issue-token
|
|
15
|
+
with:
|
|
16
|
+
app-id: ${{ secrets.BUILDCHAIN_ISSUE_APP_ID }}
|
|
17
|
+
private-key: ${{ secrets.BUILDCHAIN_ISSUE_APP_PRIVATE_KEY }}
|
|
18
|
+
owner: kungfu-systems
|
|
19
|
+
repositories: buildchain
|
|
20
|
+
|
|
21
|
+
- uses: kungfu-systems/buildchain/actions/report-buildchain-issue@v2
|
|
22
|
+
if: failure()
|
|
23
|
+
with:
|
|
24
|
+
token: ${{ steps.buildchain-issue-token.outputs.token }}
|
|
25
|
+
summary: "Reusable build failed before artifact finalization"
|
|
26
|
+
failure-code: reusable-build-failed
|
|
27
|
+
buildchain-ref: ${{ inputs.buildchain-ref || 'v2' }}
|
|
28
|
+
diagnostics-path: .buildchain/artifacts/diagnostics.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The action computes a stable fingerprint from the consumer repository,
|
|
32
|
+
workflow, job, failure code, and Buildchain ref. When an open issue already
|
|
33
|
+
exists for that fingerprint, it comments with the new run instead of opening a
|
|
34
|
+
duplicate issue.
|
|
35
|
+
|
|
36
|
+
By default issue reporting is fail-soft:
|
|
37
|
+
|
|
38
|
+
- `fail-on-error: "false"` prevents a reporting outage from hiding the original
|
|
39
|
+
build failure.
|
|
40
|
+
- transient GitHub API 429/5xx errors and connection failures are retried.
|
|
41
|
+
- if a configured label is missing, issue creation is retried without labels.
|
|
42
|
+
- common token, private-key, password, and authorization values are redacted
|
|
43
|
+
before submission.
|
|
44
|
+
|
|
45
|
+
Use `dry-run: "true"` to verify the computed fingerprint and body shape without
|
|
46
|
+
calling GitHub.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
".": "./packages/core/index.js",
|
|
12
12
|
"./core": "./packages/core/index.js",
|
|
13
13
|
"./diagnostics": "./packages/core/diagnostics.js",
|
|
14
|
+
"./issue-reporting": "./packages/core/issue-reporting.js",
|
|
14
15
|
"./logging": "./packages/core/logging.js",
|
|
15
16
|
"./release-passport": "./packages/core/release-passport.js",
|
|
16
17
|
"./site/buildchain-site.json": "./dist/site/buildchain-site.json",
|
package/docs/MAP.md
CHANGED
|
@@ -38,7 +38,8 @@ running artifact), *use* (consume / extend) - and a **status**:
|
|
|
38
38
|
| How do I validate an unreleased Buildchain runtime train while keeping `@v2`? | [`runtime-train-validation.md`](runtime-train-validation.md) | use | stable |
|
|
39
39
|
| How do I deploy a site/app preview, staging, or production surface? | [`web-surface-deployments.md`](web-surface-deployments.md) | use | stable |
|
|
40
40
|
| How do I publish observed infrastructure contracts for downstream consumers? | [`infra-contract.md`](infra-contract.md) | use | preview |
|
|
41
|
-
| How do I use the active actions directly? | [`../actions/validate-config/README.md`](../actions/validate-config/README.md), [`../actions/run-lifecycle/README.md`](../actions/run-lifecycle/README.md), [`../actions/promote-buildchain-ref/README.md`](../actions/promote-buildchain-ref/README.md) | use | stable |
|
|
41
|
+
| How do I use the active actions directly? | [`../actions/validate-config/README.md`](../actions/validate-config/README.md), [`../actions/run-lifecycle/README.md`](../actions/run-lifecycle/README.md), [`../actions/promote-buildchain-ref/README.md`](../actions/promote-buildchain-ref/README.md), [`../actions/report-buildchain-issue/README.md`](../actions/report-buildchain-issue/README.md) | use | stable |
|
|
42
|
+
| How can a consumer workflow report a Buildchain-owned failure back to Buildchain? | [`consumer-issue-reporting.md`](consumer-issue-reporting.md) + [`../actions/report-buildchain-issue/README.md`](../actions/report-buildchain-issue/README.md) | use | stable |
|
|
42
43
|
| What do the fixture repositories demonstrate? | [`../fixtures/libnode-shaped/README.md`](../fixtures/libnode-shaped/README.md), [`../fixtures/publish-transaction-shaped/README.md`](../fixtures/publish-transaction-shaped/README.md), [`../fixtures/web-surface-shaped/README.md`](../fixtures/web-surface-shaped/README.md) | verify | stable |
|
|
43
44
|
| What license and contribution terms apply? | [`../LICENSE`](../LICENSE) + [`../LICENSE-POLICY.md`](../LICENSE-POLICY.md) | use | stable |
|
|
44
45
|
| How do I report a vulnerability? | [`../SECURITY.md`](../SECURITY.md) | use | stable |
|
|
@@ -64,6 +65,8 @@ running artifact), *use* (consume / extend) - and a **status**:
|
|
|
64
65
|
- **runtime train validation / temporary `buildchain-ref` override** ->
|
|
65
66
|
[`runtime-train-validation.md`](runtime-train-validation.md) and
|
|
66
67
|
[`reusable-build-surface.md`](reusable-build-surface.md).
|
|
68
|
+
- **consumer workflow feedback / automatic Buildchain GitHub issues** ->
|
|
69
|
+
[`consumer-issue-reporting.md`](consumer-issue-reporting.md).
|
|
67
70
|
- **infra contract / observed infrastructure outputs / downstream contract propagation** ->
|
|
68
71
|
[`infra-contract.md`](infra-contract.md).
|
|
69
72
|
- **standalone binary install / platform archives / GitHub Release bundle** ->
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Consumer Issue Reporting
|
|
2
|
+
|
|
3
|
+
Buildchain ships a first-class issue reporting surface for consumer workflows.
|
|
4
|
+
It lets a repository open or update a Buildchain-owned GitHub issue when a
|
|
5
|
+
Buildchain reusable workflow, action, or toolkit API produces a failure that
|
|
6
|
+
needs Buildchain maintainers.
|
|
7
|
+
|
|
8
|
+
## Trust model
|
|
9
|
+
|
|
10
|
+
The consumer workflow must provide a token with issue-write access to the
|
|
11
|
+
target repository. A repository's default `GITHUB_TOKEN` only writes to its own
|
|
12
|
+
repository, so cross-repository reports should use a GitHub App installation
|
|
13
|
+
token scoped to:
|
|
14
|
+
|
|
15
|
+
- the `kungfu-systems/buildchain` repository;
|
|
16
|
+
- Issues: read and write;
|
|
17
|
+
- no content write permission unless another workflow step needs it.
|
|
18
|
+
|
|
19
|
+
The recommended pattern is:
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
- uses: actions/create-github-app-token@v2
|
|
23
|
+
id: buildchain-issue-token
|
|
24
|
+
with:
|
|
25
|
+
app-id: ${{ secrets.BUILDCHAIN_ISSUE_APP_ID }}
|
|
26
|
+
private-key: ${{ secrets.BUILDCHAIN_ISSUE_APP_PRIVATE_KEY }}
|
|
27
|
+
owner: kungfu-systems
|
|
28
|
+
repositories: buildchain
|
|
29
|
+
|
|
30
|
+
- uses: kungfu-systems/buildchain/actions/report-buildchain-issue@v2
|
|
31
|
+
if: failure()
|
|
32
|
+
with:
|
|
33
|
+
token: ${{ steps.buildchain-issue-token.outputs.token }}
|
|
34
|
+
summary: "Buildchain reusable build failed before artifact finalization"
|
|
35
|
+
failure-code: reusable-build-failed
|
|
36
|
+
diagnostics-path: .buildchain/artifacts/diagnostics.json
|
|
37
|
+
buildchain-ref: ${{ inputs.buildchain-ref || 'v2' }}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Behavior
|
|
41
|
+
|
|
42
|
+
`report-buildchain-issue` builds an issue body with consumer repository,
|
|
43
|
+
workflow, ref, SHA, Buildchain ref/version, diagnostics links, and optional
|
|
44
|
+
details. It computes a stable fingerprint and embeds a hidden marker:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
buildchain-consumer-issue:fingerprint=<sha256-prefix>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
When an open issue with the same marker exists, the action comments on it with
|
|
51
|
+
the new run evidence. Otherwise it creates a new issue. Consumers may pass an
|
|
52
|
+
explicit `fingerprint` when they need a different dedupe boundary.
|
|
53
|
+
|
|
54
|
+
The action is fail-soft by default. It retries GitHub API 429/5xx responses and
|
|
55
|
+
connection failures, redacts common secret/token/private-key patterns, truncates
|
|
56
|
+
large bodies, and retries issue creation without labels if the target
|
|
57
|
+
repository does not have the configured labels yet.
|
|
58
|
+
|
|
59
|
+
Set `fail-on-error: "true"` only when the reporting step is part of a release
|
|
60
|
+
gate and missing Buildchain feedback should stop the workflow.
|
|
61
|
+
|
|
62
|
+
## JavaScript API
|
|
63
|
+
|
|
64
|
+
Consumer scripts can import the same implementation:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import {
|
|
68
|
+
buildConsumerIssueReport,
|
|
69
|
+
reportBuildchainIssue,
|
|
70
|
+
} from "@kungfu-tech/buildchain/issue-reporting";
|
|
71
|
+
|
|
72
|
+
const result = await reportBuildchainIssue({
|
|
73
|
+
token: process.env.BUILDCHAIN_ISSUE_TOKEN,
|
|
74
|
+
summary: "Native artifact manifest is incomplete",
|
|
75
|
+
failureCode: "native-manifest-incomplete",
|
|
76
|
+
buildchainRef: "v2",
|
|
77
|
+
diagnosticsPath: ".buildchain/artifacts/diagnostics.json",
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Use `buildConsumerIssueReport` for dry-run validation, previewing redaction, or
|
|
82
|
+
unit tests without calling GitHub.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kungfu-tech/buildchain",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.5-alpha.1",
|
|
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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
".": "./packages/core/index.js",
|
|
15
15
|
"./core": "./packages/core/index.js",
|
|
16
16
|
"./diagnostics": "./packages/core/diagnostics.js",
|
|
17
|
+
"./issue-reporting": "./packages/core/issue-reporting.js",
|
|
17
18
|
"./logging": "./packages/core/logging.js",
|
|
18
19
|
"./release-passport": "./packages/core/release-passport.js",
|
|
19
20
|
"./site/buildchain-site.json": "./dist/site/buildchain-site.json",
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
"docs/cli.md",
|
|
43
44
|
"docs/install.md",
|
|
44
45
|
"docs/infra-contract.md",
|
|
46
|
+
"docs/consumer-issue-reporting.md",
|
|
45
47
|
"docs/lifecycle-protocol.md",
|
|
46
48
|
"docs/migration-inventory.md",
|
|
47
49
|
"docs/ownership.md",
|
package/packages/core/index.js
CHANGED
|
@@ -104,3 +104,19 @@ export {
|
|
|
104
104
|
validateKnownReleasePassportContracts,
|
|
105
105
|
verifyReleasePassport,
|
|
106
106
|
} from "./release-passport.js";
|
|
107
|
+
|
|
108
|
+
export {
|
|
109
|
+
BUILDCHAIN_CONSUMER_ISSUE_CONTRACT,
|
|
110
|
+
DEFAULT_BUILDCHAIN_ISSUE_REPOSITORY,
|
|
111
|
+
GitHubIssueRequestError,
|
|
112
|
+
buildConsumerIssueReport,
|
|
113
|
+
computeConsumerIssueFingerprint,
|
|
114
|
+
consumerIssueMarker,
|
|
115
|
+
createGitHubIssueRequest,
|
|
116
|
+
normalizeIssueRepository,
|
|
117
|
+
parseIssueLabels,
|
|
118
|
+
readOptionalIssueBodyFile,
|
|
119
|
+
redactIssueText,
|
|
120
|
+
reportBuildchainIssue,
|
|
121
|
+
truncateUtf8,
|
|
122
|
+
} from "./issue-reporting.js";
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
export const BUILDCHAIN_CONSUMER_ISSUE_CONTRACT = "kungfu-buildchain-consumer-issue";
|
|
5
|
+
export const DEFAULT_BUILDCHAIN_ISSUE_REPOSITORY = "kungfu-systems/buildchain";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LABELS = ["buildchain-consumer-feedback"];
|
|
8
|
+
const DEFAULT_MAX_BODY_BYTES = 60_000;
|
|
9
|
+
const DEFAULT_RETRY_DELAYS_MS = [500, 1_500, 3_000];
|
|
10
|
+
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
13
|
+
/\bgh[oprsu]_[A-Za-z0-9_]{20,}\b/g,
|
|
14
|
+
/\b[A-Z0-9]{20}:[A-Za-z0-9_=-]{20,}\b/g,
|
|
15
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
16
|
+
/(authorization\s*:\s*)(bearer\s+)?[^\s]+/gi,
|
|
17
|
+
/(token|secret|password|private[_-]?key)(\s*[:=]\s*)[^\s"'`]+/gi,
|
|
18
|
+
/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/g,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export class GitHubIssueRequestError extends Error {
|
|
22
|
+
constructor(message, { status, response, body } = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "GitHubIssueRequestError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.response = response;
|
|
27
|
+
this.body = body;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeIssueRepository(repository = DEFAULT_BUILDCHAIN_ISSUE_REPOSITORY) {
|
|
32
|
+
const match = String(repository || "").trim().match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
33
|
+
if (!match) {
|
|
34
|
+
throw new Error(`Invalid GitHub repository: ${repository}`);
|
|
35
|
+
}
|
|
36
|
+
return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseIssueLabels(input = DEFAULT_LABELS) {
|
|
40
|
+
if (Array.isArray(input)) {
|
|
41
|
+
return input.map((label) => String(label).trim()).filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
return String(input || "")
|
|
44
|
+
.split(/[,\n]/)
|
|
45
|
+
.map((label) => label.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function redactIssueText(value) {
|
|
50
|
+
let text = String(value ?? "");
|
|
51
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
52
|
+
text = text.replace(pattern, (...args) => {
|
|
53
|
+
if (args.length >= 4 && typeof args[1] === "string" && typeof args[2] === "string") {
|
|
54
|
+
return `${args[1]}${args[2]}[REDACTED]`;
|
|
55
|
+
}
|
|
56
|
+
return "[REDACTED]";
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function computeConsumerIssueFingerprint(fields = {}) {
|
|
63
|
+
const normalized = {};
|
|
64
|
+
for (const key of Object.keys(fields).sort()) {
|
|
65
|
+
const value = fields[key];
|
|
66
|
+
if (value === undefined || value === null || value === "") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
normalized[key] = String(value);
|
|
70
|
+
}
|
|
71
|
+
return crypto.createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 32);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function consumerIssueMarker(fingerprint) {
|
|
75
|
+
return `buildchain-consumer-issue:fingerprint=${fingerprint}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function truncateUtf8(text, maxBytes = DEFAULT_MAX_BODY_BYTES) {
|
|
79
|
+
const value = String(text ?? "");
|
|
80
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0 || Buffer.byteLength(value, "utf8") <= maxBytes) {
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
const suffix = "\n\n[buildchain truncated issue body]\n";
|
|
84
|
+
const budget = Math.max(0, maxBytes - Buffer.byteLength(suffix, "utf8"));
|
|
85
|
+
let output = "";
|
|
86
|
+
let used = 0;
|
|
87
|
+
for (const char of value) {
|
|
88
|
+
const bytes = Buffer.byteLength(char, "utf8");
|
|
89
|
+
if (used + bytes > budget) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
output += char;
|
|
93
|
+
used += bytes;
|
|
94
|
+
}
|
|
95
|
+
return `${output}${suffix}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildConsumerIssueReport(options = {}) {
|
|
99
|
+
const target = normalizeIssueRepository(options.targetRepository);
|
|
100
|
+
const env = options.env || process.env;
|
|
101
|
+
const consumerRepository = options.consumerRepository || env.GITHUB_REPOSITORY || "";
|
|
102
|
+
const runId = options.runId || env.GITHUB_RUN_ID || "";
|
|
103
|
+
const runUrl = options.runUrl || githubRunUrl(env, consumerRepository, runId);
|
|
104
|
+
const workflow = options.workflow || env.GITHUB_WORKFLOW || "";
|
|
105
|
+
const job = options.job || env.GITHUB_JOB || "";
|
|
106
|
+
const consumerRef = options.consumerRef || env.GITHUB_REF || "";
|
|
107
|
+
const consumerSha = options.consumerSha || env.GITHUB_SHA || "";
|
|
108
|
+
const failureCode = options.failureCode || "consumer-report";
|
|
109
|
+
const fingerprint =
|
|
110
|
+
options.fingerprint ||
|
|
111
|
+
computeConsumerIssueFingerprint({
|
|
112
|
+
targetRepository: target.fullName,
|
|
113
|
+
consumerRepository,
|
|
114
|
+
workflow,
|
|
115
|
+
job,
|
|
116
|
+
failureCode,
|
|
117
|
+
buildchainRef: options.buildchainRef,
|
|
118
|
+
buildchainVersion: options.buildchainVersion,
|
|
119
|
+
title: options.title,
|
|
120
|
+
});
|
|
121
|
+
const title = redactIssueText(
|
|
122
|
+
options.title ||
|
|
123
|
+
`[Buildchain consumer] ${consumerRepository || "unknown repository"}: ${failureCode}`,
|
|
124
|
+
);
|
|
125
|
+
const marker = consumerIssueMarker(fingerprint);
|
|
126
|
+
const body = truncateUtf8(
|
|
127
|
+
redactIssueText(
|
|
128
|
+
[
|
|
129
|
+
`<!-- ${marker} -->`,
|
|
130
|
+
`# Buildchain consumer report`,
|
|
131
|
+
``,
|
|
132
|
+
options.summary ? `## Summary\n\n${options.summary}` : "",
|
|
133
|
+
`## Consumer`,
|
|
134
|
+
``,
|
|
135
|
+
`- Repository: ${consumerRepository || "(unknown)"}`,
|
|
136
|
+
`- Ref: ${consumerRef || "(unknown)"}`,
|
|
137
|
+
`- SHA: ${consumerSha || "(unknown)"}`,
|
|
138
|
+
`- Workflow: ${workflow || "(unknown)"}`,
|
|
139
|
+
`- Job: ${job || "(unknown)"}`,
|
|
140
|
+
`- Run: ${runUrl || runId || "(unknown)"}`,
|
|
141
|
+
``,
|
|
142
|
+
`## Buildchain`,
|
|
143
|
+
``,
|
|
144
|
+
`- Ref: ${options.buildchainRef || "(unknown)"}`,
|
|
145
|
+
`- Version: ${options.buildchainVersion || "(unknown)"}`,
|
|
146
|
+
`- Failure code: ${failureCode}`,
|
|
147
|
+
`- Fingerprint: ${fingerprint}`,
|
|
148
|
+
``,
|
|
149
|
+
optionalLine("## Release Passport", options.passportUrl || options.passportPath),
|
|
150
|
+
optionalLine("## Diagnostics", options.diagnosticsUrl || options.diagnosticsPath),
|
|
151
|
+
optionalLine("## Artifact", options.artifactUrl),
|
|
152
|
+
options.body ? `## Details\n\n${options.body}` : "",
|
|
153
|
+
]
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.join("\n"),
|
|
156
|
+
),
|
|
157
|
+
Number(options.maxBodyBytes || DEFAULT_MAX_BODY_BYTES),
|
|
158
|
+
);
|
|
159
|
+
const commentBody = truncateUtf8(
|
|
160
|
+
redactIssueText(
|
|
161
|
+
[
|
|
162
|
+
`New matching Buildchain consumer report observed.`,
|
|
163
|
+
``,
|
|
164
|
+
`- Repository: ${consumerRepository || "(unknown)"}`,
|
|
165
|
+
`- Ref: ${consumerRef || "(unknown)"}`,
|
|
166
|
+
`- SHA: ${consumerSha || "(unknown)"}`,
|
|
167
|
+
`- Workflow: ${workflow || "(unknown)"}`,
|
|
168
|
+
`- Run: ${runUrl || runId || "(unknown)"}`,
|
|
169
|
+
`- Failure code: ${failureCode}`,
|
|
170
|
+
`- Fingerprint: ${fingerprint}`,
|
|
171
|
+
options.summary ? `\n${options.summary}` : "",
|
|
172
|
+
].join("\n"),
|
|
173
|
+
),
|
|
174
|
+
Number(options.maxBodyBytes || DEFAULT_MAX_BODY_BYTES),
|
|
175
|
+
);
|
|
176
|
+
return {
|
|
177
|
+
contract: BUILDCHAIN_CONSUMER_ISSUE_CONTRACT,
|
|
178
|
+
targetRepository: target.fullName,
|
|
179
|
+
fingerprint,
|
|
180
|
+
marker,
|
|
181
|
+
title,
|
|
182
|
+
body,
|
|
183
|
+
commentBody,
|
|
184
|
+
labels: parseIssueLabels(options.labels ?? DEFAULT_LABELS),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function reportBuildchainIssue(options = {}) {
|
|
189
|
+
const report = buildConsumerIssueReport(options);
|
|
190
|
+
const target = normalizeIssueRepository(report.targetRepository);
|
|
191
|
+
const mode = options.mode || "create-or-comment";
|
|
192
|
+
if (options.dryRun) {
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
action: "dry-run",
|
|
196
|
+
created: false,
|
|
197
|
+
commented: false,
|
|
198
|
+
issueNumber: "",
|
|
199
|
+
issueUrl: "",
|
|
200
|
+
fingerprint: report.fingerprint,
|
|
201
|
+
report,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (!["create", "create-or-comment", "comment"].includes(mode)) {
|
|
205
|
+
throw new Error(`Unsupported consumer issue mode: ${mode}`);
|
|
206
|
+
}
|
|
207
|
+
const request = options.request || createGitHubIssueRequest(options);
|
|
208
|
+
const existing = mode === "create" ? undefined : await findOpenConsumerIssue({ request, target, report });
|
|
209
|
+
if (existing) {
|
|
210
|
+
if (mode !== "create-or-comment" && mode !== "comment") {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
action: "found",
|
|
214
|
+
created: false,
|
|
215
|
+
commented: false,
|
|
216
|
+
issueNumber: existing.number,
|
|
217
|
+
issueUrl: existing.html_url || existing.url || "",
|
|
218
|
+
fingerprint: report.fingerprint,
|
|
219
|
+
report,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
await request({
|
|
223
|
+
method: "POST",
|
|
224
|
+
path: `/repos/${target.owner}/${target.repo}/issues/${existing.number}/comments`,
|
|
225
|
+
body: { body: report.commentBody },
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
ok: true,
|
|
229
|
+
action: "commented",
|
|
230
|
+
created: false,
|
|
231
|
+
commented: true,
|
|
232
|
+
issueNumber: existing.number,
|
|
233
|
+
issueUrl: existing.html_url || existing.url || "",
|
|
234
|
+
fingerprint: report.fingerprint,
|
|
235
|
+
report,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (mode === "comment") {
|
|
239
|
+
throw new Error(`No open Buildchain consumer issue found for fingerprint ${report.fingerprint}`);
|
|
240
|
+
}
|
|
241
|
+
const issue = await createIssueWithLabelFallback({ request, target, report });
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
action: "created",
|
|
245
|
+
created: true,
|
|
246
|
+
commented: false,
|
|
247
|
+
issueNumber: issue.number,
|
|
248
|
+
issueUrl: issue.html_url || issue.url || "",
|
|
249
|
+
fingerprint: report.fingerprint,
|
|
250
|
+
report,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function readOptionalIssueBodyFile(filePath) {
|
|
255
|
+
const resolved = String(filePath || "").trim();
|
|
256
|
+
if (!resolved) {
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
return fs.readFileSync(resolved, "utf8");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function createGitHubIssueRequest({
|
|
263
|
+
token,
|
|
264
|
+
apiUrl = process.env.GITHUB_API_URL || "https://api.github.com",
|
|
265
|
+
fetchImpl = globalThis.fetch,
|
|
266
|
+
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
|
|
267
|
+
} = {}) {
|
|
268
|
+
if (!token) {
|
|
269
|
+
throw new Error("A GitHub token is required to report a Buildchain issue");
|
|
270
|
+
}
|
|
271
|
+
if (typeof fetchImpl !== "function") {
|
|
272
|
+
throw new Error("A fetch implementation is required to report a Buildchain issue");
|
|
273
|
+
}
|
|
274
|
+
const baseUrl = String(apiUrl || "https://api.github.com").replace(/\/+$/, "");
|
|
275
|
+
return async function githubIssueRequest({ method = "GET", path, body }) {
|
|
276
|
+
const url = `${baseUrl}${path}`;
|
|
277
|
+
let attempt = 0;
|
|
278
|
+
while (true) {
|
|
279
|
+
try {
|
|
280
|
+
const response = await fetchImpl(url, {
|
|
281
|
+
method,
|
|
282
|
+
headers: {
|
|
283
|
+
accept: "application/vnd.github+json",
|
|
284
|
+
authorization: `Bearer ${token}`,
|
|
285
|
+
"content-type": "application/json",
|
|
286
|
+
"x-github-api-version": "2022-11-28",
|
|
287
|
+
},
|
|
288
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
289
|
+
});
|
|
290
|
+
const text = await response.text();
|
|
291
|
+
const parsed = text ? parseJsonResponse(text) : {};
|
|
292
|
+
if (response.ok) {
|
|
293
|
+
return parsed;
|
|
294
|
+
}
|
|
295
|
+
const retryAfterMs = retryAfterHeaderMs(response.headers?.get?.("retry-after"));
|
|
296
|
+
if (shouldRetryStatus(response.status) && attempt < retryDelaysMs.length) {
|
|
297
|
+
await sleep(retryAfterMs ?? retryDelaysMs[attempt]);
|
|
298
|
+
attempt += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
throw new GitHubIssueRequestError(
|
|
302
|
+
`GitHub API ${method} ${path} failed with ${response.status}: ${parsed.message || text}`,
|
|
303
|
+
{ status: response.status, response: parsed, body },
|
|
304
|
+
);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error instanceof GitHubIssueRequestError) {
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (attempt < retryDelaysMs.length) {
|
|
310
|
+
await sleep(retryDelaysMs[attempt]);
|
|
311
|
+
attempt += 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
throw new GitHubIssueRequestError(`GitHub API ${method} ${path} failed: ${error.message}`, {
|
|
315
|
+
body,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function findOpenConsumerIssue({ request, target, report }) {
|
|
323
|
+
const query = encodeURIComponent(
|
|
324
|
+
`repo:${target.fullName} is:issue is:open "${consumerIssueMarker(report.fingerprint)}"`,
|
|
325
|
+
);
|
|
326
|
+
const result = await request({ method: "GET", path: `/search/issues?q=${query}&per_page=1` });
|
|
327
|
+
const items = Array.isArray(result.items) ? result.items : [];
|
|
328
|
+
return items[0];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function createIssueWithLabelFallback({ request, target, report }) {
|
|
332
|
+
const payload = {
|
|
333
|
+
title: report.title,
|
|
334
|
+
body: report.body,
|
|
335
|
+
};
|
|
336
|
+
if (report.labels.length > 0) {
|
|
337
|
+
payload.labels = report.labels;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
return await request({
|
|
341
|
+
method: "POST",
|
|
342
|
+
path: `/repos/${target.owner}/${target.repo}/issues`,
|
|
343
|
+
body: payload,
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error?.status === 422 && payload.labels) {
|
|
347
|
+
return await request({
|
|
348
|
+
method: "POST",
|
|
349
|
+
path: `/repos/${target.owner}/${target.repo}/issues`,
|
|
350
|
+
body: { title: payload.title, body: payload.body },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function optionalLine(title, value) {
|
|
358
|
+
return value ? `${title}\n\n${value}` : "";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function githubRunUrl(env, repository, runId) {
|
|
362
|
+
if (!repository || !runId) {
|
|
363
|
+
return "";
|
|
364
|
+
}
|
|
365
|
+
const server = env.GITHUB_SERVER_URL || "https://github.com";
|
|
366
|
+
return `${server}/${repository}/actions/runs/${runId}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function parseJsonResponse(text) {
|
|
370
|
+
try {
|
|
371
|
+
return JSON.parse(text);
|
|
372
|
+
} catch {
|
|
373
|
+
return { message: text };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function shouldRetryStatus(status) {
|
|
378
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function retryAfterHeaderMs(value) {
|
|
382
|
+
const seconds = Number(value || "");
|
|
383
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function sleep(ms) {
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
388
|
+
}
|
|
@@ -17,6 +17,7 @@ const requiredPaths = [
|
|
|
17
17
|
"docs/MAP.md",
|
|
18
18
|
"docs/binary-distribution.md",
|
|
19
19
|
"docs/cli.md",
|
|
20
|
+
"docs/consumer-issue-reporting.md",
|
|
20
21
|
"docs/install.md",
|
|
21
22
|
"docs/product-mechanism.md",
|
|
22
23
|
"docs/release-passport.md",
|
|
@@ -69,6 +70,9 @@ if (rootPackage.exports?.["."] !== "./packages/core/index.js") {
|
|
|
69
70
|
if (rootPackage.exports?.["./diagnostics"] !== "./packages/core/diagnostics.js") {
|
|
70
71
|
throw new Error("root package must export @kungfu-tech/buildchain/diagnostics");
|
|
71
72
|
}
|
|
73
|
+
if (rootPackage.exports?.["./issue-reporting"] !== "./packages/core/issue-reporting.js") {
|
|
74
|
+
throw new Error("root package must export @kungfu-tech/buildchain/issue-reporting");
|
|
75
|
+
}
|
|
72
76
|
if (rootPackage.exports?.["./logging"] !== "./packages/core/logging.js") {
|
|
73
77
|
throw new Error("root package must export @kungfu-tech/buildchain/logging");
|
|
74
78
|
}
|
|
@@ -108,6 +112,9 @@ if (!coreIndexSource.includes("verifyBuildchainLogEvents")) {
|
|
|
108
112
|
if (!coreIndexSource.includes("collectBuildchainDiagnostics")) {
|
|
109
113
|
throw new Error("packages/core/index.js must export collectBuildchainDiagnostics");
|
|
110
114
|
}
|
|
115
|
+
if (!coreIndexSource.includes("reportBuildchainIssue")) {
|
|
116
|
+
throw new Error("packages/core/index.js must export reportBuildchainIssue");
|
|
117
|
+
}
|
|
111
118
|
for (const requiredSnippet of [
|
|
112
119
|
"Release passport and binary distribution are a minor surface.",
|
|
113
120
|
"`v2.2`",
|