@kungfu-tech/buildchain 2.4.6-alpha.0 → 2.4.6

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 CHANGED
@@ -122,6 +122,8 @@ Buildchain repository with a scoped issue-write token:
122
122
 
123
123
  The action deduplicates by fingerprint, comments on existing open reports, and
124
124
  is fail-soft by default so issue reporting does not hide the original failure.
125
+ Use `report-kind: workflow-friction` when Buildchain workflows should report
126
+ their own repeated release friction back to the Buildchain issue tracker.
125
127
 
126
128
  ## Use Buildchain
127
129
 
@@ -252,6 +254,7 @@ npm pack --dry-run --json --registry=https://registry.npmjs.org/
252
254
  - [Site bundle contract](docs/site-bundle-contract.md)
253
255
  - [Lifecycle protocol](docs/lifecycle-protocol.md)
254
256
  - [Reusable build surface](docs/reusable-build-surface.md)
257
+ - [Release candidate passport](docs/release-candidate.md)
255
258
  - [Consumer issue reporting](docs/consumer-issue-reporting.md)
256
259
  - [Publish transaction](docs/publish-transaction.md)
257
260
  - [Release governance](docs/release-governance.md)
@@ -131,6 +131,26 @@ channel ref such as `alpha/v22/v22.22` or `release/v22/v22.22` to already point
131
131
  at the locked `publish-source-sha`. If not, maintainers should merge the source
132
132
  commit through the channel PR first.
133
133
 
134
+ For promote-only release candidates, attach the PR-stage reusable build evidence
135
+ and fail before publish-gate side effects if it no longer matches:
136
+
137
+ ```yaml
138
+ - uses: kungfu-systems/buildchain/actions/promote-buildchain-ref@v2
139
+ with:
140
+ token: ${{ secrets.BUILDCHAIN_PROMOTION_TOKEN }}
141
+ sha: ${{ needs.build.outputs.publish-source-sha }}
142
+ target-ref: alpha/v22/v22.22
143
+ promote-only-release-candidate: "true"
144
+ release-candidate-passport-path: .buildchain/artifacts/release-candidate-passport.json
145
+ release-candidate-build-summary-path: .buildchain/artifacts/build-summary.json
146
+ ```
147
+
148
+ The action validates repository, channel, source SHA, platform matrix, and the
149
+ aggregate build-summary hash before it writes version state, opens release-state,
150
+ runs publish transaction logic, or moves tags/branches. If validation fails, run
151
+ or attach the verified channel PR build first instead of promoting a stale or
152
+ unproven artifact set.
153
+
134
154
  When enabled, the action creates or resumes a release transaction keyed by
135
155
  repository, version, source SHA, and target ref. It persists that transaction to
136
156
  a machine-managed branch under `buildchain/release-state/<version>`, with
@@ -1,6 +1,7 @@
1
1
  # report-buildchain-issue
2
2
 
3
- Create or update a Buildchain repository issue from a consumer workflow.
3
+ Create or update a Buildchain repository issue from a consumer workflow or from
4
+ Buildchain's own workflow-friction feedback loop.
4
5
 
5
6
  This action is intended for consumer repositories that need to report
6
7
  Buildchain-owned failures with enough evidence for maintainers to act. It
@@ -44,3 +45,33 @@ By default issue reporting is fail-soft:
44
45
 
45
46
  Use `dry-run: "true"` to verify the computed fingerprint and body shape without
46
47
  calling GitHub.
48
+
49
+ For Buildchain-owned workflow friction, use `report-kind: workflow-friction`.
50
+ This uses a separate marker and default labels so duplicate PRs, duplicate
51
+ builds, transient API failures, stale release-state, or missing RC evidence can
52
+ be grouped without mixing with consumer failure reports:
53
+
54
+ ```yaml
55
+ permissions:
56
+ issues: write
57
+
58
+ steps:
59
+ - uses: kungfu-systems/buildchain/actions/report-buildchain-issue@v2
60
+ if: failure()
61
+ with:
62
+ token: ${{ github.token }}
63
+ report-kind: workflow-friction
64
+ target-repository: ${{ github.repository }}
65
+ repository: ${{ github.repository }}
66
+ workflow: ${{ github.workflow }}
67
+ run-id: ${{ github.run_id }}
68
+ run-attempt: ${{ github.run_attempt }}
69
+ channel: alpha/v2/v2.4
70
+ source-sha: ${{ github.sha }}
71
+ friction-class: duplicate-build
72
+ comment-cooldown-hours: "24"
73
+ ```
74
+
75
+ `comment-cooldown-hours` is optional. When it is greater than zero, Buildchain
76
+ still deduplicates by fingerprint but skips adding another comment if the
77
+ existing issue already received a recent update.
@@ -13,6 +13,7 @@
13
13
  "./diagnostics": "./packages/core/diagnostics.js",
14
14
  "./issue-reporting": "./packages/core/issue-reporting.js",
15
15
  "./logging": "./packages/core/logging.js",
16
+ "./release-candidate": "./packages/core/release-candidate.js",
16
17
  "./release-passport": "./packages/core/release-passport.js",
17
18
  "./site/buildchain-site.json": "./dist/site/buildchain-site.json",
18
19
  "./site/site-manifest.json": "./dist/site/site-manifest.json",
package/docs/MAP.md CHANGED
@@ -31,6 +31,7 @@ running artifact), *use* (consume / extend) - and a **status**:
31
31
  | How do I declare version files and custom lifecycle commands? | [`lifecycle-protocol.md`](lifecycle-protocol.md) | use | stable |
32
32
  | How does publish evidence, recovery, and finalization work? | [`publish-transaction.md`](publish-transaction.md) | verify | stable |
33
33
  | How do I publish or verify release passport artifacts? | [`release-passport.md`](release-passport.md) | use | stable |
34
+ | How do I prove a PR-stage reusable build is the artifact source promoted later? | [`release-candidate.md`](release-candidate.md) + [`reusable-build-surface.md`](reusable-build-surface.md) | verify | stable |
34
35
  | Why are binary release assets archived by platform, and where is the single bundle? | [`binary-distribution.md`](binary-distribution.md) | verify | stable |
35
36
  | How do I add timestamped logs inside build scripts? | [`toolkit-observability.md`](toolkit-observability.md) | use | stable |
36
37
  | What package-owned facts should buildchain.libkungfu.dev render? | [`site-bundle-contract.md`](site-bundle-contract.md) | use | stable |
@@ -67,6 +68,9 @@ running artifact), *use* (consume / extend) - and a **status**:
67
68
  [`reusable-build-surface.md`](reusable-build-surface.md).
68
69
  - **consumer workflow feedback / automatic Buildchain GitHub issues** ->
69
70
  [`consumer-issue-reporting.md`](consumer-issue-reporting.md).
71
+ - **PR-stage RC artifacts / promote-only release candidates** ->
72
+ [`release-candidate.md`](release-candidate.md) and
73
+ [`reusable-build-surface.md`](reusable-build-surface.md).
70
74
  - **infra contract / observed infrastructure outputs / downstream contract propagation** ->
71
75
  [`infra-contract.md`](infra-contract.md).
72
76
  - **standalone binary install / platform archives / GitHub Release bundle** ->
@@ -0,0 +1,62 @@
1
+ # Release Candidate Passport
2
+
3
+ The release-candidate passport is the pre-promotion evidence contract produced
4
+ after a reusable build matrix succeeds and before any publish-gate side effects
5
+ run. It is different from the release passport:
6
+
7
+ - `release-candidate-passport.json` proves which source SHA, channel, runtime,
8
+ workflow run, and platform artifacts were verified before promotion.
9
+ - `buildchain.release.json` is generated after publish finalization and remains
10
+ the durable audit entrypoint for the published release.
11
+
12
+ Enable it on the reusable build workflow:
13
+
14
+ ```yaml
15
+ jobs:
16
+ build:
17
+ uses: kungfu-systems/buildchain/.github/workflows/.build.yml@v2
18
+ with:
19
+ artifact-name: libnode
20
+ release-candidate: true
21
+ publish-channel: alpha
22
+ publish-source-ref: publish-gate/alpha/v22/v22.22/22.22.3-kf.3-alpha.7
23
+ ```
24
+
25
+ When the platform matrix and aggregate summaries complete, Buildchain uploads:
26
+
27
+ ```text
28
+ <artifact-name>-release-candidate-<publish-source-sha>
29
+ ```
30
+
31
+ The passport contract is `kungfu-buildchain-release-candidate-passport`. It
32
+ contains:
33
+
34
+ - repository and pull request context;
35
+ - target channel, target ref, and product version or a non-publish
36
+ `source-<shortSha>` candidate label;
37
+ - source head SHA, merge ref SHA, and source tree hash;
38
+ - Buildchain runtime ref/SHA and workflow shell ref;
39
+ - workflow run id/attempt/url;
40
+ - normalized platform matrix and artifact summaries;
41
+ - the hash of the aggregate `build-summary.json`.
42
+
43
+ Promotion workflows that should not rebuild artifacts can enable:
44
+
45
+ ```yaml
46
+ - uses: kungfu-systems/buildchain/actions/promote-buildchain-ref@v2
47
+ with:
48
+ token: ${{ secrets.BUILDCHAIN_PROMOTION_TOKEN }}
49
+ sha: ${{ needs.build.outputs.publish-source-sha }}
50
+ target-ref: alpha/v22/v22.22
51
+ promote-only-release-candidate: "true"
52
+ release-candidate-passport-path: .buildchain/artifacts/release-candidate-passport.json
53
+ release-candidate-build-summary-path: .buildchain/artifacts/build-summary.json
54
+ ```
55
+
56
+ With `promote-only-release-candidate: "true"`, promotion fails before
57
+ version-state, publish transaction, tag, or branch side effects when the
58
+ passport does not match the repository, channel, source SHA, platform matrix,
59
+ or build-summary hash. The diagnostic tells maintainers to run or attach the
60
+ verified channel PR build first instead of allowing publish-gate to discover
61
+ the mismatch late.
62
+
@@ -25,6 +25,7 @@ jobs:
25
25
  expected-artifacts-json: >-
26
26
  {"minFiles":2,"requiredPaths":["dist/libnode.tar.gz","dist/checksums.txt"]}
27
27
  process-summary-path: .buildchain/diagnostics/process-summary.json
28
+ release-candidate: true
28
29
  publish-channel: release
29
30
  publish-source-ref: publish-gate/release/v22/v22.22/22.22.3-kf.0
30
31
  ```
@@ -190,6 +191,8 @@ The reusable workflow exposes the resolved contract:
190
191
  | `linux-container-image` | Resolved digest-pinned Linux job container image |
191
192
  | `build-summary-artifact` | Uploaded aggregate summary artifact name |
192
193
  | `build-diagnostics-summary-artifact` | Uploaded aggregate diagnostics summary artifact name |
194
+ | `release-candidate-passport-artifact` | Uploaded PR-stage release-candidate passport artifact name when `release-candidate` is enabled |
195
+ | `release-candidate-passport-json` | Compact release-candidate passport JSON when `release-candidate` is enabled |
193
196
  | `build-summary-json` | Compact aggregate JSON with platform count, file count, and byte total |
194
197
  | `build-diagnostics-summary-json` | Compact aggregate diagnostics JSON with platform, lifecycle warning/error, diagnostics contract warning, and sidecar manifest warning totals |
195
198
  | `trusted-event` | `true` when the event is trusted enough to reach build runners |
@@ -233,6 +236,14 @@ release jobs can detect drifting diagnostics JSON contracts and missing or
233
236
  drifting diagnostics sidecar manifests without downloading the per-platform
234
237
  diagnostics artifacts first.
235
238
 
239
+ Set `release-candidate: true` when the successful reusable build is meant to be
240
+ the artifact source promoted later. Buildchain then uploads
241
+ `release-candidate-passport.json` under the
242
+ `<artifact-name>-release-candidate-<publish-source-sha>` artifact name. Promotion
243
+ jobs can pass that passport to `promote-buildchain-ref` with
244
+ `promote-only-release-candidate: "true"` so source, channel, platforms, and the
245
+ aggregate build-summary hash are checked before publish-gate side effects.
246
+
236
247
  ## Publish Gate
237
248
 
238
249
  Buildchain separates "may build/verify" from "may publish." A same-repository
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.4.6-alpha.0",
3
+ "version": "2.4.6",
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",
@@ -16,6 +16,7 @@
16
16
  "./diagnostics": "./packages/core/diagnostics.js",
17
17
  "./issue-reporting": "./packages/core/issue-reporting.js",
18
18
  "./logging": "./packages/core/logging.js",
19
+ "./release-candidate": "./packages/core/release-candidate.js",
19
20
  "./release-passport": "./packages/core/release-passport.js",
20
21
  "./site/buildchain-site.json": "./dist/site/buildchain-site.json",
21
22
  "./site/site-manifest.json": "./dist/site/site-manifest.json",
@@ -50,6 +51,7 @@
50
51
  "docs/product-mechanism.md",
51
52
  "docs/publish-transaction.md",
52
53
  "docs/release-passport.md",
54
+ "docs/release-candidate.md",
53
55
  "docs/release-flow.md",
54
56
  "docs/release-governance.md",
55
57
  "docs/reusable-build-surface.md",
@@ -85,6 +85,13 @@ export {
85
85
  writeDiagnosticsArtifact,
86
86
  } from "./diagnostics.js";
87
87
 
88
+ export {
89
+ RELEASE_CANDIDATE_PASSPORT_CONTRACT,
90
+ createReleaseCandidatePassport,
91
+ sha256Json,
92
+ validateReleaseCandidatePassport,
93
+ } from "./release-candidate.js";
94
+
88
95
  export {
89
96
  AGENT_INDEX_CONTRACT,
90
97
  ARTIFACT_EVIDENCE_CONTRACT,
@@ -107,9 +114,11 @@ export {
107
114
 
108
115
  export {
109
116
  BUILDCHAIN_CONSUMER_ISSUE_CONTRACT,
117
+ BUILDCHAIN_WORKFLOW_FRICTION_ISSUE_CONTRACT,
110
118
  DEFAULT_BUILDCHAIN_ISSUE_REPOSITORY,
111
119
  GitHubIssueRequestError,
112
120
  buildConsumerIssueReport,
121
+ buildWorkflowFrictionIssueReport,
113
122
  computeConsumerIssueFingerprint,
114
123
  consumerIssueMarker,
115
124
  createGitHubIssueRequest,
@@ -118,5 +127,7 @@ export {
118
127
  readOptionalIssueBodyFile,
119
128
  redactIssueText,
120
129
  reportBuildchainIssue,
130
+ reportWorkflowFrictionIssue,
121
131
  truncateUtf8,
132
+ workflowFrictionMarker,
122
133
  } from "./issue-reporting.js";
@@ -2,9 +2,11 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
 
4
4
  export const BUILDCHAIN_CONSUMER_ISSUE_CONTRACT = "kungfu-buildchain-consumer-issue";
5
+ export const BUILDCHAIN_WORKFLOW_FRICTION_ISSUE_CONTRACT = "kungfu-buildchain-workflow-friction-issue";
5
6
  export const DEFAULT_BUILDCHAIN_ISSUE_REPOSITORY = "kungfu-systems/buildchain";
6
7
 
7
8
  const DEFAULT_LABELS = ["buildchain-consumer-feedback"];
9
+ const DEFAULT_FRICTION_LABELS = ["buildchain-feedback", "workflow-friction"];
8
10
  const DEFAULT_MAX_BODY_BYTES = 60_000;
9
11
  const DEFAULT_RETRY_DELAYS_MS = [500, 1_500, 3_000];
10
12
 
@@ -75,6 +77,10 @@ export function consumerIssueMarker(fingerprint) {
75
77
  return `buildchain-consumer-issue:fingerprint=${fingerprint}`;
76
78
  }
77
79
 
80
+ export function workflowFrictionMarker(fingerprint) {
81
+ return `buildchain-workflow-friction:fingerprint=${fingerprint}`;
82
+ }
83
+
78
84
  export function truncateUtf8(text, maxBytes = DEFAULT_MAX_BODY_BYTES) {
79
85
  const value = String(text ?? "");
80
86
  if (!Number.isFinite(maxBytes) || maxBytes <= 0 || Buffer.byteLength(value, "utf8") <= maxBytes) {
@@ -185,8 +191,95 @@ export function buildConsumerIssueReport(options = {}) {
185
191
  };
186
192
  }
187
193
 
194
+ export function buildWorkflowFrictionIssueReport(options = {}) {
195
+ const target = normalizeIssueRepository(options.targetRepository);
196
+ const env = options.env || process.env;
197
+ const repository = options.repository || env.GITHUB_REPOSITORY || "";
198
+ const workflow = options.workflow || env.GITHUB_WORKFLOW || "";
199
+ const runId = options.runId || env.GITHUB_RUN_ID || "";
200
+ const runAttempt = options.runAttempt || env.GITHUB_RUN_ATTEMPT || "";
201
+ const runUrl = options.runUrl || githubRunUrl(env, repository, runId);
202
+ const channel = options.channel || "";
203
+ const releaseIntent = options.releaseIntent || options.version || "";
204
+ const sourceRef = options.sourceRef || env.GITHUB_REF || "";
205
+ const sourceSha = options.sourceSha || env.GITHUB_SHA || "";
206
+ const frictionClass = options.frictionClass || "workflow-friction";
207
+ const fingerprint = options.fingerprint || computeConsumerIssueFingerprint({
208
+ targetRepository: target.fullName,
209
+ repository,
210
+ workflow,
211
+ channel,
212
+ releaseIntent,
213
+ sourceRef,
214
+ sourceSha,
215
+ frictionClass,
216
+ });
217
+ const marker = workflowFrictionMarker(fingerprint);
218
+ const heavyBuilds = Array.isArray(options.heavyBuilds) ? options.heavyBuilds : [];
219
+ const relatedRuns = Array.isArray(options.relatedRuns) ? options.relatedRuns : [];
220
+ const title = redactIssueText(
221
+ options.title || `[Buildchain feedback] ${frictionClass}: ${repository || "unknown repository"}`,
222
+ );
223
+ const body = truncateUtf8(
224
+ redactIssueText(
225
+ [
226
+ `<!-- ${marker} -->`,
227
+ `# Buildchain workflow friction`,
228
+ ``,
229
+ options.summary ? `## Summary\n\n${options.summary}` : "",
230
+ `## Context`,
231
+ ``,
232
+ `- Repository: ${repository || "(unknown)"}`,
233
+ `- Workflow: ${workflow || "(unknown)"}`,
234
+ `- Run: ${runUrl || runId || "(unknown)"}`,
235
+ `- Attempt: ${runAttempt || "(unknown)"}`,
236
+ `- Pull request: ${options.pullRequest || "(unknown)"}`,
237
+ `- Channel: ${channel || "(unknown)"}`,
238
+ `- Release intent: ${releaseIntent || "(unknown)"}`,
239
+ `- Source ref: ${sourceRef || "(unknown)"}`,
240
+ `- Source SHA: ${sourceSha || "(unknown)"}`,
241
+ `- Source tree: ${options.sourceTreeHash || "(unknown)"}`,
242
+ `- Friction class: ${frictionClass}`,
243
+ `- Fingerprint: ${fingerprint}`,
244
+ ``,
245
+ relatedRuns.length ? `## Related runs\n\n${relatedRuns.map((run) => `- ${run}`).join("\n")}` : "",
246
+ heavyBuilds.length ? `## Heavy build jobs\n\n${heavyBuilds.map((job) => `- ${job.name || job}: ${job.durationMs || ""}`).join("\n")}` : "",
247
+ options.diagnosis ? `## Diagnosis\n\n${options.diagnosis}` : "",
248
+ options.nextAction ? `## Suggested next action\n\n${options.nextAction}` : "",
249
+ ].filter(Boolean).join("\n"),
250
+ ),
251
+ Number(options.maxBodyBytes || DEFAULT_MAX_BODY_BYTES),
252
+ );
253
+ const commentBody = truncateUtf8(
254
+ redactIssueText(
255
+ [
256
+ `New matching Buildchain workflow friction observed.`,
257
+ ``,
258
+ `- Repository: ${repository || "(unknown)"}`,
259
+ `- Workflow: ${workflow || "(unknown)"}`,
260
+ `- Run: ${runUrl || runId || "(unknown)"}`,
261
+ `- Attempt: ${runAttempt || "(unknown)"}`,
262
+ `- Friction class: ${frictionClass}`,
263
+ `- Fingerprint: ${fingerprint}`,
264
+ options.summary ? `\n${options.summary}` : "",
265
+ ].join("\n"),
266
+ ),
267
+ Number(options.maxBodyBytes || DEFAULT_MAX_BODY_BYTES),
268
+ );
269
+ return {
270
+ contract: BUILDCHAIN_WORKFLOW_FRICTION_ISSUE_CONTRACT,
271
+ targetRepository: target.fullName,
272
+ fingerprint,
273
+ marker,
274
+ title,
275
+ body,
276
+ commentBody,
277
+ labels: parseIssueLabels(options.labels ?? DEFAULT_FRICTION_LABELS),
278
+ };
279
+ }
280
+
188
281
  export async function reportBuildchainIssue(options = {}) {
189
- const report = buildConsumerIssueReport(options);
282
+ const report = options.report || buildConsumerIssueReport(options);
190
283
  const target = normalizeIssueRepository(report.targetRepository);
191
284
  const mode = options.mode || "create-or-comment";
192
285
  if (options.dryRun) {
@@ -219,6 +312,26 @@ export async function reportBuildchainIssue(options = {}) {
219
312
  report,
220
313
  };
221
314
  }
315
+ const cooldown = await evaluateCommentCooldown({
316
+ request,
317
+ target,
318
+ issueNumber: existing.number,
319
+ cooldownHours: options.commentCooldownHours,
320
+ now: options.now,
321
+ });
322
+ if (cooldown.active) {
323
+ return {
324
+ ok: true,
325
+ action: "cooldown",
326
+ created: false,
327
+ commented: false,
328
+ issueNumber: existing.number,
329
+ issueUrl: existing.html_url || existing.url || "",
330
+ fingerprint: report.fingerprint,
331
+ report,
332
+ cooldown,
333
+ };
334
+ }
222
335
  await request({
223
336
  method: "POST",
224
337
  path: `/repos/${target.owner}/${target.repo}/issues/${existing.number}/comments`,
@@ -251,6 +364,13 @@ export async function reportBuildchainIssue(options = {}) {
251
364
  };
252
365
  }
253
366
 
367
+ export async function reportWorkflowFrictionIssue(options = {}) {
368
+ return reportBuildchainIssue({
369
+ ...options,
370
+ report: buildWorkflowFrictionIssueReport(options),
371
+ });
372
+ }
373
+
254
374
  export function readOptionalIssueBodyFile(filePath) {
255
375
  const resolved = String(filePath || "").trim();
256
376
  if (!resolved) {
@@ -321,13 +441,47 @@ export function createGitHubIssueRequest({
321
441
 
322
442
  async function findOpenConsumerIssue({ request, target, report }) {
323
443
  const query = encodeURIComponent(
324
- `repo:${target.fullName} is:issue is:open "${consumerIssueMarker(report.fingerprint)}"`,
444
+ `repo:${target.fullName} is:issue is:open "${report.marker || consumerIssueMarker(report.fingerprint)}"`,
325
445
  );
326
446
  const result = await request({ method: "GET", path: `/search/issues?q=${query}&per_page=1` });
327
447
  const items = Array.isArray(result.items) ? result.items : [];
328
448
  return items[0];
329
449
  }
330
450
 
451
+ async function evaluateCommentCooldown({
452
+ request,
453
+ target,
454
+ issueNumber,
455
+ cooldownHours = 0,
456
+ now = new Date(),
457
+ }) {
458
+ const hours = Number(cooldownHours || 0);
459
+ if (!Number.isFinite(hours) || hours <= 0) {
460
+ return { active: false, cooldownHours: 0 };
461
+ }
462
+ const result = await request({
463
+ method: "GET",
464
+ path: `/repos/${target.owner}/${target.repo}/issues/${issueNumber}/comments?per_page=100`,
465
+ });
466
+ const comments = Array.isArray(result) ? result : [];
467
+ const latest = comments[comments.length - 1]?.created_at || "";
468
+ if (!latest) {
469
+ return { active: false, cooldownHours: hours };
470
+ }
471
+ const latestMs = Date.parse(latest);
472
+ const nowMs = now instanceof Date ? now.getTime() : Date.parse(String(now));
473
+ if (!Number.isFinite(latestMs) || !Number.isFinite(nowMs)) {
474
+ return { active: false, cooldownHours: hours, latestCommentAt: latest };
475
+ }
476
+ const elapsedHours = (nowMs - latestMs) / 3_600_000;
477
+ return {
478
+ active: elapsedHours >= 0 && elapsedHours < hours,
479
+ cooldownHours: hours,
480
+ latestCommentAt: latest,
481
+ elapsedHours,
482
+ };
483
+ }
484
+
331
485
  async function createIssueWithLabelFallback({ request, target, report }) {
332
486
  const payload = {
333
487
  title: report.title,
@@ -0,0 +1,183 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export const RELEASE_CANDIDATE_PASSPORT_CONTRACT = "kungfu-buildchain-release-candidate-passport";
4
+
5
+ function nowIso() {
6
+ return new Date().toISOString();
7
+ }
8
+
9
+ function optionalString(value) {
10
+ return value === undefined || value === null ? "" : String(value);
11
+ }
12
+
13
+ function nonEmptyString(value, label) {
14
+ const normalized = optionalString(value).trim();
15
+ if (!normalized) {
16
+ throw new Error(`${label} must be a non-empty string`);
17
+ }
18
+ return normalized;
19
+ }
20
+
21
+ function stableJson(value) {
22
+ if (Array.isArray(value)) {
23
+ return `[${value.map(stableJson).join(",")}]`;
24
+ }
25
+ if (value && typeof value === "object") {
26
+ return `{${Object.keys(value)
27
+ .sort()
28
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
29
+ .join(",")}}`;
30
+ }
31
+ return JSON.stringify(value);
32
+ }
33
+
34
+ export function sha256Json(value) {
35
+ return crypto.createHash("sha256").update(stableJson(value)).digest("hex");
36
+ }
37
+
38
+ function normalizePlatformEntry(platform = {}, index = 0) {
39
+ const platformId = optionalString(platform.platform?.id || platform.platformId || platform.platform || `platform-${index}`);
40
+ const artifacts = Array.isArray(platform.summary?.files)
41
+ ? platform.summary.files
42
+ : Array.isArray(platform.artifacts)
43
+ ? platform.artifacts
44
+ : [];
45
+ return {
46
+ platformId,
47
+ artifactName: optionalString(platform.artifactName),
48
+ runner: {
49
+ labels: Array.isArray(platform.runner?.labels) ? platform.runner.labels.map(String) : [],
50
+ os: optionalString(platform.runner?.os),
51
+ arch: optionalString(platform.runner?.arch),
52
+ },
53
+ lifecycle: platform.observability?.lifecycle?.stages || {},
54
+ summary: {
55
+ fileCount: Number(platform.summary?.fileCount || 0),
56
+ totalBytes: Number(platform.summary?.totalBytes || 0),
57
+ },
58
+ artifacts: artifacts.map((artifact, artifactIndex) => ({
59
+ name: optionalString(artifact.name || artifact.path || `artifact-${artifactIndex}`),
60
+ path: optionalString(artifact.path),
61
+ size: Number(artifact.size || artifact.sizeBytes || 0),
62
+ sha256: optionalString(artifact.sha256 || artifact.digest || artifact.checksum).replace(/^sha256:/, ""),
63
+ })),
64
+ manifestPath: optionalString(platform.manifestPath),
65
+ };
66
+ }
67
+
68
+ export function createReleaseCandidatePassport({
69
+ repository = "",
70
+ pullRequest = {},
71
+ targetChannel = "",
72
+ version = "",
73
+ sourceHeadSha = "",
74
+ baseSha = "",
75
+ mergeRefSha = "",
76
+ sourceTreeHash = "",
77
+ buildSummary = {},
78
+ buildchain = {},
79
+ workflow = {},
80
+ createdAt = nowIso(),
81
+ } = {}) {
82
+ const normalizedSummary = buildSummary && typeof buildSummary === "object" ? buildSummary : {};
83
+ const sourceSha = sourceHeadSha || normalizedSummary.publishSource?.sha || normalizedSummary.git?.sha || "";
84
+ const channel = targetChannel || normalizedSummary.publishSource?.channel || normalizedSummary.publishGate?.channel || "";
85
+ const candidate = {
86
+ schemaVersion: 1,
87
+ contract: RELEASE_CANDIDATE_PASSPORT_CONTRACT,
88
+ createdAt,
89
+ repository: nonEmptyString(repository || normalizedSummary.git?.repository, "repository"),
90
+ pullRequest: {
91
+ number: optionalString(pullRequest.number),
92
+ url: optionalString(pullRequest.url),
93
+ headRef: optionalString(pullRequest.headRef),
94
+ baseRef: optionalString(pullRequest.baseRef),
95
+ },
96
+ target: {
97
+ channel: nonEmptyString(channel, "targetChannel"),
98
+ ref: optionalString(normalizedSummary.publishSource?.ref || normalizedSummary.git?.ref),
99
+ version: nonEmptyString(version || normalizedSummary.publishSource?.consumerVersion, "version"),
100
+ },
101
+ source: {
102
+ headSha: nonEmptyString(sourceSha, "sourceHeadSha"),
103
+ baseSha: optionalString(baseSha),
104
+ mergeRefSha: optionalString(mergeRefSha || sourceSha),
105
+ treeHash: optionalString(sourceTreeHash || sha256Json(normalizedSummary.platforms || [])),
106
+ },
107
+ buildchain: {
108
+ ref: optionalString(buildchain.ref || normalizedSummary.runtime?.ref),
109
+ sha: optionalString(buildchain.sha || normalizedSummary.runtime?.sha),
110
+ version: optionalString(buildchain.version),
111
+ workflowShellRef: optionalString(buildchain.workflowShellRef || normalizedSummary.runtime?.workflowShellRef),
112
+ },
113
+ workflow: {
114
+ name: optionalString(workflow.name),
115
+ runId: optionalString(workflow.runId || normalizedSummary.git?.runId),
116
+ runAttempt: optionalString(workflow.runAttempt || normalizedSummary.git?.runAttempt),
117
+ url: optionalString(workflow.url),
118
+ },
119
+ platformMatrix: (Array.isArray(normalizedSummary.platforms) ? normalizedSummary.platforms : [])
120
+ .map((platform, index) => normalizePlatformEntry(platform, index)),
121
+ diagnostics: {
122
+ buildSummaryContract: optionalString(normalizedSummary.contract),
123
+ buildSummaryHash: sha256Json(normalizedSummary),
124
+ },
125
+ };
126
+ candidate.candidateHash = sha256Json({
127
+ repository: candidate.repository,
128
+ target: candidate.target,
129
+ source: candidate.source,
130
+ platformMatrix: candidate.platformMatrix,
131
+ buildchain: candidate.buildchain,
132
+ });
133
+ return candidate;
134
+ }
135
+
136
+ export function validateReleaseCandidatePassport({
137
+ passport,
138
+ repository = "",
139
+ targetChannel = "",
140
+ version = "",
141
+ sourceHeadSha = "",
142
+ buildSummary = undefined,
143
+ requirePlatforms = true,
144
+ } = {}) {
145
+ const errors = [];
146
+ const check = (condition, message) => {
147
+ if (!condition) {
148
+ errors.push(message);
149
+ }
150
+ };
151
+ check(passport && typeof passport === "object" && !Array.isArray(passport), "passport must be an object");
152
+ if (!passport || typeof passport !== "object" || Array.isArray(passport)) {
153
+ return { ok: false, errors };
154
+ }
155
+ check(passport.contract === RELEASE_CANDIDATE_PASSPORT_CONTRACT, `contract must be ${RELEASE_CANDIDATE_PASSPORT_CONTRACT}`);
156
+ check(Number(passport.schemaVersion) === 1, "schemaVersion must be 1");
157
+ if (repository) {
158
+ check(passport.repository === repository, `repository mismatch: expected ${repository}, got ${passport.repository || "<empty>"}`);
159
+ }
160
+ if (targetChannel) {
161
+ check(passport.target?.channel === targetChannel, `target channel mismatch: expected ${targetChannel}, got ${passport.target?.channel || "<empty>"}`);
162
+ }
163
+ if (version) {
164
+ check(passport.target?.version === version, `version mismatch: expected ${version}, got ${passport.target?.version || "<empty>"}`);
165
+ }
166
+ if (sourceHeadSha) {
167
+ check(passport.source?.headSha === sourceHeadSha, `source head mismatch: expected ${sourceHeadSha}, got ${passport.source?.headSha || "<empty>"}`);
168
+ }
169
+ check(Boolean(passport.source?.mergeRefSha || passport.source?.treeHash), "source mergeRefSha or treeHash is required");
170
+ check(Array.isArray(passport.platformMatrix), "platformMatrix must be an array");
171
+ if (requirePlatforms) {
172
+ check((passport.platformMatrix || []).length > 0, "platformMatrix must include at least one platform");
173
+ }
174
+ for (const [index, platform] of (passport.platformMatrix || []).entries()) {
175
+ check(Boolean(platform.platformId), `platformMatrix[${index}].platformId is required`);
176
+ check(Boolean(platform.artifactName), `platformMatrix[${index}].artifactName is required`);
177
+ }
178
+ if (buildSummary) {
179
+ const expectedHash = sha256Json(buildSummary);
180
+ check(passport.diagnostics?.buildSummaryHash === expectedHash, "build summary hash mismatch");
181
+ }
182
+ return { ok: errors.length === 0, errors };
183
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import {
6
+ createReleaseCandidatePassport,
7
+ validateReleaseCandidatePassport,
8
+ } from "../packages/core/release-candidate.js";
9
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
10
+
11
+ function env(name, fallback = "") {
12
+ return process.env[name] || fallback;
13
+ }
14
+
15
+ function readJsonFile(filePath) {
16
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
17
+ }
18
+
19
+ export function generateReleaseCandidatePassportCli() {
20
+ const buildSummaryPath = path.resolve(env("BUILDCHAIN_BUILD_SUMMARY_PATH", ".buildchain/artifacts/build-summary.json"));
21
+ const outputPath = path.resolve(env("BUILDCHAIN_RC_PASSPORT_PATH", ".buildchain/artifacts/release-candidate-passport.json"));
22
+ const buildSummary = readJsonFile(buildSummaryPath);
23
+ const sourceSha = env("BUILDCHAIN_RC_SOURCE_HEAD_SHA", buildSummary.publishSource?.sha || buildSummary.git?.sha || "");
24
+ const version = env(
25
+ "BUILDCHAIN_RC_VERSION",
26
+ buildSummary.publishSource?.consumerVersion || `source-${String(sourceSha).slice(0, 12)}`,
27
+ );
28
+ const passport = createReleaseCandidatePassport({
29
+ repository: env("GITHUB_REPOSITORY", buildSummary.git?.repository || ""),
30
+ pullRequest: {
31
+ number: env("BUILDCHAIN_PULL_REQUEST_NUMBER"),
32
+ url: env("BUILDCHAIN_PULL_REQUEST_URL"),
33
+ headRef: env("BUILDCHAIN_PULL_REQUEST_HEAD_REF"),
34
+ baseRef: env("BUILDCHAIN_PULL_REQUEST_BASE_REF"),
35
+ },
36
+ targetChannel: env("BUILDCHAIN_RC_TARGET_CHANNEL", buildSummary.publishSource?.channel || buildSummary.publishGate?.channel || ""),
37
+ version,
38
+ sourceHeadSha: sourceSha,
39
+ baseSha: env("BUILDCHAIN_RC_BASE_SHA"),
40
+ mergeRefSha: env("BUILDCHAIN_RC_MERGE_REF_SHA", buildSummary.git?.sha || ""),
41
+ sourceTreeHash: env("BUILDCHAIN_RC_SOURCE_TREE_HASH"),
42
+ buildSummary,
43
+ buildchain: {
44
+ ref: env("BUILDCHAIN_RUNTIME_REF", buildSummary.runtime?.ref || ""),
45
+ sha: env("BUILDCHAIN_RUNTIME_SHA", buildSummary.runtime?.sha || ""),
46
+ version: env("BUILDCHAIN_RUNTIME_VERSION"),
47
+ workflowShellRef: env("BUILDCHAIN_WORKFLOW_SHELL_REF", buildSummary.runtime?.workflowShellRef || ""),
48
+ },
49
+ workflow: {
50
+ name: env("GITHUB_WORKFLOW"),
51
+ runId: env("GITHUB_RUN_ID", buildSummary.git?.runId || ""),
52
+ runAttempt: env("GITHUB_RUN_ATTEMPT", buildSummary.git?.runAttempt || ""),
53
+ url: env("BUILDCHAIN_WORKFLOW_RUN_URL"),
54
+ },
55
+ });
56
+ const validation = validateReleaseCandidatePassport({
57
+ passport,
58
+ repository: env("GITHUB_REPOSITORY", buildSummary.git?.repository || ""),
59
+ sourceHeadSha: sourceSha,
60
+ buildSummary,
61
+ });
62
+ if (!validation.ok) {
63
+ throw new Error(`release candidate passport invalid: ${validation.errors.join("; ")}`);
64
+ }
65
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
66
+ fs.writeFileSync(outputPath, `${JSON.stringify(passport, null, 2)}\n`);
67
+ writeGitHubOutputs({
68
+ "release-candidate-passport-path": path.relative(process.cwd(), outputPath).split(path.sep).join("/"),
69
+ "release-candidate-passport-json": JSON.stringify({
70
+ contract: passport.contract,
71
+ repository: passport.repository,
72
+ target: passport.target,
73
+ source: passport.source,
74
+ candidateHash: passport.candidateHash,
75
+ platformCount: passport.platformMatrix.length,
76
+ }),
77
+ });
78
+ return passport;
79
+ }
80
+
81
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
82
+ try {
83
+ generateReleaseCandidatePassportCli();
84
+ } catch (error) {
85
+ console.error(`::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`);
86
+ process.exitCode = 1;
87
+ }
88
+ }