@kungfu-tech/buildchain 2.4.2-alpha.0 → 2.4.2-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.
@@ -125,6 +125,11 @@ contract:
125
125
  specific package publication. Direct `alpha/*` or `release/*` channel refs are
126
126
  not valid publish source locks when `require-publish-source-lock` is enabled,
127
127
  and a mismatched `publish-source-sha` fails before any promotion or publish side effects begin.
128
+ The reusable build workflow performs the cheaper channel-ref preflight earlier:
129
+ after source-lock resolution and before the build matrix, it requires the target
130
+ channel ref such as `alpha/v22/v22.22` or `release/v22/v22.22` to already point
131
+ at the locked `publish-source-sha`. If not, maintainers should merge the source
132
+ commit through the channel PR first.
128
133
 
129
134
  When enabled, the action creates or resumes a release transaction keyed by
130
135
  repository, version, source SHA, and target ref. It persists that transaction to
@@ -232,9 +237,10 @@ governance semantics:
232
237
  The promotion workflow should use `BUILDCHAIN_PROMOTION_TOKEN` for non-dry-run
233
238
  promotion. The token is the buildchain equivalent of the old ABV runner release
234
239
  authority: protected branch review and check rules guard human channel merges,
235
- while this action independently rechecks PR lineage, alpha/release tree
236
- equivalence, and generated version-state verification before moving channel
237
- refs and tags.
240
+ while the reusable build trust gate now checks the source-lock channel HEAD and
241
+ merged same-repository PR lineage before heavy build runners start. This action
242
+ still independently rechecks PR lineage, alpha/release tree equivalence, and
243
+ generated version-state verification before moving channel refs and tags.
238
244
 
239
245
  The tag names intentionally follow the old ABV release semantics:
240
246
  exact release tags are `vX.Y.Z`, exact alpha tags are `vX.Y.Z-alpha.N`, floating
@@ -97,7 +97,7 @@ function usage() {
97
97
  [--path <jsonl>] -- <command> [args...]
98
98
  buildchain web-surface ...
99
99
  buildchain infra-contract ...
100
- buildchain publish-source <lock|manifest|verify-lock|validate-anchored-release> ...
100
+ buildchain publish-source <lock|manifest|verify-lock|verify-channel-ref|validate-anchored-release> ...
101
101
  buildchain build-contract ...
102
102
 
103
103
  Examples:
@@ -823,6 +823,10 @@ async function main(argv = process.argv.slice(2)) {
823
823
  runScript("verify-publish-source-lock.mjs", publishArgs);
824
824
  return;
825
825
  }
826
+ if (mode === "verify-channel-ref") {
827
+ runScript("verify-publish-channel-ref.mjs", publishArgs);
828
+ return;
829
+ }
826
830
  if (mode === "validate-anchored-release") {
827
831
  const report = validateAnchoredPackageRelease({
828
832
  cwd: readFlag(publishArgs, "cwd", process.cwd()),
@@ -16,6 +16,12 @@ The release lane does not upload loose top-level `buildchain` or
16
16
  inside their archives, so top-level loose assets would collide when matrix
17
17
  artifacts are merged.
18
18
 
19
+ Release asset upload is gated before the matrix starts. Manual
20
+ `workflow_dispatch` runs are binary dry-runs only and must keep
21
+ `upload-release=false`; real GitHub Release uploads must come from a true
22
+ `v*` tag-triggered run so an invalid manual upload request cannot spend the
23
+ three-platform build matrix and then fail at `gh release upload`.
24
+
19
25
  Each archive is accompanied by:
20
26
 
21
27
  - a platform manifest from the standalone binary builder;
@@ -67,4 +73,3 @@ node scripts/create-release-bundle.mjs \
67
73
  --output-dir .buildchain/release-passport \
68
74
  --tag v0.0.0-local
69
75
  ```
70
-
@@ -308,6 +308,17 @@ that SHA in every build job, and uses the same SHA in artifact names, manifests,
308
308
  and aggregate summaries. Reruns therefore rebuild the same source tree even if a
309
309
  gate branch moves later.
310
310
 
311
+ Before any heavy build matrix is scheduled, the workflow also verifies that the
312
+ target channel ref implied by the source lock already points at
313
+ `publish-source-sha` and that the target channel HEAD came from the required
314
+ merged same-repository channel PR. `publish-gate/alpha/<line>/<version>` must
315
+ match `alpha/<line>` and have PR lineage `dev/<line> -> alpha/<line>`;
316
+ `publish-gate/release/<line>/<version>` must match `release/<line>` and have PR
317
+ lineage `alpha/<line> -> release/<line>`. If either check fails, the run fails
318
+ fast with a diagnostic telling maintainers to merge the source commit through
319
+ the channel PR first. This keeps verify from spending runner time on a source
320
+ tree that cannot legally enter the requested publish channel.
321
+
311
322
  The resolved release manifest is uploaded as an artifact and emitted as
312
323
  `release-manifest-json`. It records:
313
324
 
@@ -335,6 +346,18 @@ If the branch tip no longer matches the manifest SHA, the publish job must fail
335
346
  closed. Moving a gate branch creates a new publish decision and should produce a
336
347
  new build run.
337
348
 
349
+ Custom publish jobs can also repeat the channel-ref preflight:
350
+
351
+ ```yaml
352
+ - name: Verify publish channel ref still matches
353
+ run: node .buildchain/runtime/scripts/verify-publish-channel-ref.mjs
354
+ env:
355
+ BUILDCHAIN_PUBLISH_SOURCE_REF: ${{ needs.build.outputs.publish-source-ref }}
356
+ BUILDCHAIN_PUBLISH_SOURCE_SHA: ${{ needs.build.outputs.publish-source-sha }}
357
+ BUILDCHAIN_SOURCE_REPOSITORY: ${{ github.repository }}
358
+ GITHUB_TOKEN: ${{ github.token }}
359
+ ```
360
+
338
361
  Anchored/manual package release jobs should also make the Buildchain promotion
339
362
  action validate that publication is entering through the same
340
363
  `publish-gate/{alpha,release,major}` source-lock contract before any package
@@ -368,6 +368,7 @@ The reusable workflow maps GitHub events to Buildchain web-surface semantics:
368
368
  | `pull_request` opened / synchronized / reopened | validate, build, verify, and plan `preview` for `pr-N` |
369
369
  | `pull_request` closed | plan apply-mode cleanup for the `pr-N` preview alias and manifest |
370
370
  | `push` to `main` | validate, build, verify, and plan `staging` from the merged `main` SHA |
371
+ | `push` to `main` from a matching release PR merge | validate the associated release PR, plan `production`, and enter the configured GitHub Environment gate |
371
372
  | `workflow_dispatch` with `production-approved = true` | plan `production` and enter the configured GitHub Environment gate |
372
373
 
373
374
  The optional `buildchain-ref` input is empty by default. Empty keeps the
@@ -427,6 +428,7 @@ jobs:
427
428
  staging-apply: true
428
429
  staging-aws-role-arn: arn:aws:iam::123456789012:role/site-staging-github-actions
429
430
  production-apply: false
431
+ production-release-on-main: false
430
432
  production-aws-role-arn: arn:aws:iam::123456789012:role/site-production-github-actions
431
433
  production-environment: production
432
434
  ```
@@ -439,9 +441,41 @@ When enabled, Buildchain owns the full release apply state machine:
439
441
  only.
440
442
  - Pushes to `main` run staging `deploy-apply --dry-run false` with the staging
441
443
  role.
442
- - Production runs only on `workflow_dispatch` when both `production-approved`
443
- and `production-apply` are true, and the job is gated by the configured GitHub
444
- Environment.
444
+ - Production runs when `production-apply` is true and either:
445
+ - a trusted `workflow_dispatch` passes `production-approved=true`; or
446
+ - `production-release-on-main=true` and the `main` push commit is associated
447
+ with exactly one same-repository, merged release pull request matching
448
+ `production-release-label` and `production-release-head-prefix`.
449
+ The production job is then gated by the configured GitHub Environment.
450
+
451
+ For release-PR publishing, callers opt in explicitly:
452
+
453
+ ```yaml
454
+ jobs:
455
+ web-surface:
456
+ uses: kungfu-systems/buildchain/.github/workflows/.web-surface.yml@v2
457
+ with:
458
+ build-command: npm run build
459
+ verify-command: npm run check
460
+ artifact-path: dist
461
+ production-apply: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
462
+ production-release-on-main: true
463
+ production-release-label: buildchain-release
464
+ production-release-head-prefix: feature/release-
465
+ production-aws-role-arn: arn:aws:iam::123456789012:role/site-production-github-actions
466
+ production-environment: production
467
+ ```
468
+
469
+ The merge button becomes the production approval only for a PR that carries the
470
+ release label and comes from the configured source-branch prefix. Ordinary
471
+ pull requests merged into `main` keep the staging plan behavior and do not
472
+ publish production.
473
+
474
+ Apply-only inputs are validated before the caller build or verification command
475
+ runs. If the current event would run preview, staging, or production apply,
476
+ missing role inputs or a production apply without `production-approved=true`
477
+ on manual dispatch fail immediately instead of spending the build and plan jobs
478
+ first.
445
479
 
446
480
  Callers must grant `id-token: write` for OIDC role assumption. Preview comments
447
481
  also need `pull-requests: write`. The AWS roles remain caller-owned and should
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.4.2-alpha.0",
3
+ "version": "2.4.2-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",
@@ -348,6 +348,162 @@ export function verifyPublishSourceLock({
348
348
  };
349
349
  }
350
350
 
351
+ export function resolvePublishChannelTargetRef({
352
+ sourceRef = "",
353
+ targetRef = "",
354
+ } = {}) {
355
+ const requestedTarget = normalizeGitRefName(targetRef);
356
+ if (requestedTarget) {
357
+ return requestedTarget;
358
+ }
359
+ const parsed = parsePublishSourceRef(sourceRef);
360
+ if (!parsed.enabled || parsed.anchor) {
361
+ return "";
362
+ }
363
+ if (parsed.channel === "alpha" || parsed.channel === "release") {
364
+ return `${parsed.channel}/${parsed.line}`;
365
+ }
366
+ if (parsed.channel === "major") {
367
+ return parsed.legacyAlias ? "major-gate" : "publish-gate/major";
368
+ }
369
+ return "";
370
+ }
371
+
372
+ function expectedPublishChannelHeadRef(targetRef) {
373
+ const ref = normalizeGitRefName(targetRef);
374
+ if (ref === "publish-gate/major" || ref === "major-gate") {
375
+ return "release/vN/vN.M";
376
+ }
377
+ const match = ref.match(/^(alpha|release)\/v(\d+)\/v(\d+)\.(\d+)$/);
378
+ if (!match) {
379
+ throw new Error(
380
+ `publish target channel ref must be alpha/vN/vN.M, release/vN/vN.M, publish-gate/major, or major-gate; got ${ref || "<empty>"}`,
381
+ );
382
+ }
383
+ const [, channel, major, lineMajor, minor] = match;
384
+ if (major !== lineMajor) {
385
+ throw new Error(`publish target channel ref major mismatch: ${ref}`);
386
+ }
387
+ return channel === "alpha"
388
+ ? `dev/v${major}/v${major}.${minor}`
389
+ : `alpha/v${major}/v${major}.${minor}`;
390
+ }
391
+
392
+ function isReleaseLineRef(ref = "") {
393
+ const match = String(ref || "").match(/^release\/v(\d+)\/v(\d+)\.(\d+)$/);
394
+ return !!match && match[1] === match[2];
395
+ }
396
+
397
+ export function verifyPublishChannelPrLineage({
398
+ sourceRef = "",
399
+ sourceSha = "",
400
+ targetRef = "",
401
+ repository = "",
402
+ pullRequests = [],
403
+ } = {}) {
404
+ const parsed = parsePublishSourceRef(sourceRef);
405
+ const resolvedTargetRef = resolvePublishChannelTargetRef({
406
+ sourceRef,
407
+ targetRef,
408
+ });
409
+ if (!parsed.enabled || parsed.anchor || !resolvedTargetRef) {
410
+ return {
411
+ ok: true,
412
+ skipped: true,
413
+ sourceRef: parsed.sourceRef || normalizeGitRefName(sourceRef),
414
+ targetRef: resolvedTargetRef,
415
+ reason: !parsed.enabled
416
+ ? "publish source ref is not configured"
417
+ : parsed.anchor
418
+ ? "publish-gate/anchor has no channel target ref"
419
+ : "publish target channel ref is not configured",
420
+ };
421
+ }
422
+ const sha = assertSha(sourceSha, "sourceSha");
423
+ const expectedHeadRef = expectedPublishChannelHeadRef(resolvedTargetRef);
424
+ const expectedRepository = String(repository || "").trim();
425
+ if (!expectedRepository) {
426
+ throw new Error("repository is required to verify publish channel PR lineage");
427
+ }
428
+ const matchingPullRequest = (pullRequests || []).find((pullRequest) => {
429
+ const baseRef = pullRequest?.base?.ref || "";
430
+ const headRef = pullRequest?.head?.ref || "";
431
+ const headRepo = pullRequest?.head?.repo?.full_name || "";
432
+ if (!pullRequest?.merged_at || baseRef !== resolvedTargetRef || headRepo !== expectedRepository) {
433
+ return false;
434
+ }
435
+ if (resolvedTargetRef === "publish-gate/major" || resolvedTargetRef === "major-gate") {
436
+ return isReleaseLineRef(headRef);
437
+ }
438
+ return headRef === expectedHeadRef;
439
+ });
440
+ if (!matchingPullRequest) {
441
+ throw new Error(
442
+ `publish source-lock PR lineage mismatch: ${parsed.sourceRef} is locked at ${sha}, but target channel ref ${resolvedTargetRef} must come from a merged same-repository PR ${expectedHeadRef} -> ${resolvedTargetRef}. Merge the source commit through that channel PR before running publish verification.`,
443
+ );
444
+ }
445
+ return {
446
+ ok: true,
447
+ skipped: false,
448
+ sourceRef: parsed.sourceRef,
449
+ sourceSha: sha,
450
+ targetRef: resolvedTargetRef,
451
+ expectedHeadRef,
452
+ pullRequest: {
453
+ number: matchingPullRequest.number,
454
+ url: matchingPullRequest.html_url || matchingPullRequest.url || "",
455
+ headRef: matchingPullRequest.head?.ref || "",
456
+ baseRef: matchingPullRequest.base?.ref || "",
457
+ mergedAt: matchingPullRequest.merged_at || "",
458
+ },
459
+ };
460
+ }
461
+
462
+ export function verifyPublishChannelRef({
463
+ sourceRef = "",
464
+ sourceSha = "",
465
+ targetRef = "",
466
+ targetSha = "",
467
+ } = {}) {
468
+ const parsed = parsePublishSourceRef(sourceRef);
469
+ const resolvedTargetRef = resolvePublishChannelTargetRef({
470
+ sourceRef,
471
+ targetRef,
472
+ });
473
+ if (!parsed.enabled || parsed.anchor || !resolvedTargetRef) {
474
+ return {
475
+ ok: true,
476
+ skipped: true,
477
+ sourceRef: parsed.sourceRef || normalizeGitRefName(sourceRef),
478
+ targetRef: resolvedTargetRef,
479
+ reason: !parsed.enabled
480
+ ? "publish source ref is not configured"
481
+ : parsed.anchor
482
+ ? "publish-gate/anchor has no channel target ref"
483
+ : "publish target channel ref is not configured",
484
+ };
485
+ }
486
+ const expected = assertSha(sourceSha, "sourceSha");
487
+ const current = assertSha(targetSha, "targetSha");
488
+ if (expected !== current) {
489
+ const channelHint =
490
+ parsed.channel === "alpha" || parsed.channel === "release"
491
+ ? `Merge the source commit through the channel PR into ${resolvedTargetRef} before running publish verification.`
492
+ : `Move ${resolvedTargetRef} to the reviewed source commit before running publish verification.`;
493
+ throw new Error(
494
+ `publish source-lock target mismatch: ${parsed.sourceRef} is locked at ${expected}, but target channel ref ${resolvedTargetRef} points at ${current}. ${channelHint}`,
495
+ );
496
+ }
497
+ return {
498
+ ok: true,
499
+ skipped: false,
500
+ sourceRef: parsed.sourceRef,
501
+ sourceSha: expected,
502
+ targetRef: resolvedTargetRef,
503
+ targetSha: current,
504
+ };
505
+ }
506
+
351
507
  function normalizePackageSet(packages) {
352
508
  if (!Array.isArray(packages) || packages.length === 0) {
353
509
  throw new Error("package set must include at least one package");
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import {
4
+ resolvePublishChannelTargetRef,
5
+ verifyPublishChannelPrLineage,
6
+ verifyPublishChannelRef,
7
+ } from "./build-contract-core.mjs";
8
+ import {
9
+ normalizeSourceRef,
10
+ resolvePublishSourceRefSha,
11
+ } from "./publish-source-ref-resolver.mjs";
12
+
13
+ function readEnv(env, name, fallback = "") {
14
+ return env[name] || fallback;
15
+ }
16
+
17
+ function splitRepository(repository) {
18
+ const value = String(repository || "").trim();
19
+ const match = value.match(/^([^/\s]+)\/([^/\s]+)$/);
20
+ if (!match) {
21
+ throw new Error(`BUILDCHAIN_SOURCE_REPOSITORY must be owner/repo, got: ${value || "<empty>"}`);
22
+ }
23
+ return { owner: match[1], repo: match[2] };
24
+ }
25
+
26
+ async function resolveAssociatedPullRequests({
27
+ repository,
28
+ sha,
29
+ env = process.env,
30
+ fetchImpl = globalThis.fetch,
31
+ } = {}) {
32
+ if (typeof fetchImpl !== "function") {
33
+ throw new Error("fetch is required to verify publish channel PR lineage through GitHub REST API");
34
+ }
35
+ const { owner, repo } = splitRepository(repository);
36
+ const apiBase = String(readEnv(env, "GITHUB_API_URL", "https://api.github.com")).replace(/\/+$/, "");
37
+ const url = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(sha)}/pulls`;
38
+ const headers = {
39
+ Accept: "application/vnd.github+json",
40
+ "User-Agent": "buildchain-publish-channel-verifier",
41
+ "X-GitHub-Api-Version": "2022-11-28",
42
+ };
43
+ const token = readEnv(env, "GITHUB_TOKEN");
44
+ if (token) {
45
+ headers.Authorization = `Bearer ${token}`;
46
+ }
47
+ const response = await fetchImpl(url, { headers });
48
+ if (!response?.ok) {
49
+ let detail = "";
50
+ try {
51
+ const body = await response.json();
52
+ detail = body?.message ? `: ${body.message}` : "";
53
+ } catch {
54
+ detail = "";
55
+ }
56
+ throw new Error(`publish channel PR lineage could not be read for ${sha} (GitHub API ${response?.status || "unknown"}${detail})`);
57
+ }
58
+ const body = await response.json();
59
+ if (!Array.isArray(body)) {
60
+ throw new Error("publish channel PR lineage response must be an array");
61
+ }
62
+ return body;
63
+ }
64
+
65
+ export async function verifyPublishChannelRefCli({
66
+ env = process.env,
67
+ fetchImpl = globalThis.fetch,
68
+ } = {}) {
69
+ const sourceRef = normalizeSourceRef(readEnv(env, "BUILDCHAIN_PUBLISH_SOURCE_REF"));
70
+ const sourceSha = readEnv(env, "BUILDCHAIN_PUBLISH_SOURCE_SHA");
71
+ const targetRef = resolvePublishChannelTargetRef({
72
+ sourceRef,
73
+ targetRef: readEnv(env, "BUILDCHAIN_PUBLISH_TARGET_REF"),
74
+ });
75
+ if (!targetRef) {
76
+ return verifyPublishChannelRef({ sourceRef, sourceSha, targetRef });
77
+ }
78
+ const repository = readEnv(env, "BUILDCHAIN_SOURCE_REPOSITORY", readEnv(env, "GITHUB_REPOSITORY"));
79
+ const targetSha = readEnv(env, "BUILDCHAIN_CURRENT_TARGET_SHA")
80
+ || await resolvePublishSourceRefSha({
81
+ repository,
82
+ sourceRef: targetRef,
83
+ env,
84
+ fetchImpl,
85
+ });
86
+ const refResult = verifyPublishChannelRef({
87
+ sourceRef,
88
+ sourceSha,
89
+ targetRef,
90
+ targetSha,
91
+ });
92
+ if (refResult.skipped) {
93
+ return refResult;
94
+ }
95
+ const pullRequestsJson = readEnv(env, "BUILDCHAIN_CURRENT_TARGET_PULLS_JSON");
96
+ const pullRequests = pullRequestsJson
97
+ ? JSON.parse(pullRequestsJson)
98
+ : await resolveAssociatedPullRequests({
99
+ repository,
100
+ sha: sourceSha,
101
+ env,
102
+ fetchImpl,
103
+ });
104
+ const lineageResult = verifyPublishChannelPrLineage({
105
+ sourceRef,
106
+ sourceSha,
107
+ targetRef,
108
+ repository,
109
+ pullRequests,
110
+ });
111
+ return {
112
+ ...refResult,
113
+ prLineage: lineageResult,
114
+ };
115
+ }
116
+
117
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
118
+ try {
119
+ const result = await verifyPublishChannelRefCli();
120
+ console.log(JSON.stringify(result));
121
+ } catch (error) {
122
+ console.error(`::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`);
123
+ process.exitCode = 1;
124
+ }
125
+ }