@rightkit/git 0.2.7 → 0.2.9

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
@@ -23,7 +23,7 @@ Sync records exact prior bytes, refuses unmanaged same-name workflows, & preflig
23
23
 
24
24
  ## Credential posture (read before enabling package-manager publishing)
25
25
 
26
- - **Signing credentials stay environment-scoped.** Opt-in Windows candidates use GitHub OIDC with Azure Artifact Signing. Opt-in macOS candidates use a temporary runner keychain plus Apple API-key notarization. npm publishing uses short-lived OIDC.
26
+ - **Signing credentials stay environment-scoped.** Opt-in Windows candidates use GitHub OIDC with Azure Artifact Signing. Opt-in macOS candidates use a temporary runner keychain plus Apple API-key notarization. Signing jobs run only for `v*` tag refs; manual dispatch produces unsigned candidates only. npm publishing uses short-lived OIDC.
27
27
  - **Exception, disclosed:** the optional, opt-in `publish-package-managers` lane *does* require two owner-provisioned, long-lived tokens in GitHub Actions `secrets.*` (`HOMEBREW_TAP_TOKEN`, `WINGET_CREATE_GITHUB_TOKEN`) to open tap/manifest PRs. These are real bearer tokens, public-repo-scoped by the visibility gate but not zero-exposure. The "no signing credential in CI" invariant is specifically about code-signing material; it does not mean "no credential of any kind."
28
28
 
29
29
  Manifest input is treated as untrusted: every field flowing into rendered YAML is charset-allowlisted, so a manifest cannot inject `${{ secrets.* }}` or shell/expression syntax.
@@ -38,4 +38,6 @@ Node builtins only; no runtime dependencies.
38
38
 
39
39
  ## Profiles
40
40
 
41
- `profile` defaults to `node`. `rust-hybrid` requires `ci`, accepts optional `release-candidate`, installs pinned Node plus Rust, then runs product-owned package scripts. Candidate configuration is `{ buildScript, checkScript, artifactRoot, os, signWindows?, signMacos? }`; `os` selects supported release hosts from main matrix. RightGit always uploads an unsigned handoff. `signWindows: true` adds Azure OIDC signing. `signMacos: true` adds Developer ID signing plus notarization from protected environment secrets. Both use product-owned RightRelease finalization & never publish releases.
41
+ `profile` defaults to `node`. `rust-hybrid` requires `ci`, accepts optional `release-candidate`, installs pinned Node plus Rust, then runs product-owned package scripts. Candidate configuration is `{ buildScript, checkScript, artifactRoot, os, targets?, signWindows?, signMacos?, releaseChain? }`. Legacy `os` remains supported & derives one target per runner; explicit `targets` is `{ os, platform, architecture }[]`, independent of main CI matrix. Each target maps to native runner, Rust target triple, exact unsigned artifact, & platform signing matrix. Supported platform values are `windows`, `macos`, `linux`; architectures are `x86_64`, `arm64`. RightGit always uploads an unsigned handoff. `signWindows: true` adds Azure OIDC signing. `signMacos: true` adds Developer ID signing plus notarization from protected environment secrets.
42
+
43
+ `releaseChain` lets RightGit own full protected DAG while product scripts own artifact semantics: `{ windowsFinalizeScript, macosFinalizeScript?, installedQualificationScript, publishScript, qualificationRunnerVariable, publishSecrets? }`. Generated ordering is unsigned candidate → platform signing/finalization → installed qualification → publication. It deliberately rejects more than one Windows target or more than one macOS target because its installed-qualification/publish handoff has one canonical signed artifact per platform. Every protected job is tag-gated, publication needs successful installed qualification plus macOS finalization when enabled, & only publish job receives declared publication secrets plus `contents: write`.
package/manifest.mjs CHANGED
@@ -61,6 +61,24 @@ const TOP_LEVEL_KEYS = new Set([
61
61
  ]);
62
62
  const GATE_PATH_RE = /^(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
63
63
  const PACKAGE_SCRIPT_RE = /^[A-Za-z0-9][A-Za-z0-9:_-]*$/;
64
+ const GITHUB_VARIABLE_RE = /^[A-Z][A-Z0-9_]*$/;
65
+ const RELEASE_PLATFORMS = new Set(["windows", "macos", "linux"]);
66
+ const RELEASE_ARCHITECTURES = new Set(["x86_64", "arm64"]);
67
+ const NATIVE_RUNNER_TARGETS = new Map([
68
+ ["windows-2025", ["windows", "x86_64"]],
69
+ ["windows-11-arm", ["windows", "arm64"]],
70
+ ["macos-15", ["macos", "arm64"]],
71
+ ["macos-15-intel", ["macos", "x86_64"]],
72
+ ]);
73
+
74
+ function inferredTarget(os) {
75
+ const platform = /^windows-/i.test(os) ? "windows" : /^macos-/i.test(os) ? "macos" : "linux";
76
+ return { os, platform, architecture: platform === "macos" ? "arm64" : "x86_64" };
77
+ }
78
+
79
+ function candidateTargets(candidate) {
80
+ return Array.isArray(candidate?.targets) ? candidate.targets : (candidate?.os ?? []).map(inferredTarget);
81
+ }
64
82
 
65
83
  /**
66
84
  * Validate a parsed manifest object against the right-git manifest contract.
@@ -141,7 +159,7 @@ export function validateManifest(manifest) {
141
159
  errors.push("candidate must be an object");
142
160
  } else {
143
161
  for (const key of Object.keys(candidate)) {
144
- if (!["buildScript", "checkScript", "artifactRoot", "os", "signWindows", "signMacos"].includes(key)) errors.push(`unknown candidate field: ${key}`);
162
+ if (!["buildScript", "checkScript", "artifactRoot", "os", "targets", "signWindows", "signMacos", "releaseChain"].includes(key)) errors.push(`unknown candidate field: ${key}`);
145
163
  }
146
164
  for (const key of ["buildScript", "checkScript"]) {
147
165
  if (typeof candidate[key] !== "string" || !PACKAGE_SCRIPT_RE.test(candidate[key])) {
@@ -153,24 +171,87 @@ export function validateManifest(manifest) {
153
171
  }
154
172
  if (!Array.isArray(candidate.os) || candidate.os.length === 0 || candidate.os.some((value) => typeof value !== "string" || !SAFE_MATRIX_VALUE_RE.test(value))) {
155
173
  errors.push("candidate.os must be a non-empty array of safe runner labels");
156
- } else if (candidate.os.some((value) => !manifest.matrix?.os?.includes(value))) {
174
+ } else if (candidate.targets === undefined && candidate.os.some((value) => !manifest.matrix?.os?.includes(value))) {
157
175
  errors.push("candidate.os entries must also exist in matrix.os");
158
176
  }
177
+ if (candidate.targets !== undefined) {
178
+ if (!Array.isArray(candidate.targets) || candidate.targets.length === 0) {
179
+ errors.push("candidate.targets must be a non-empty array");
180
+ } else {
181
+ const targetKeys = new Set();
182
+ for (const target of candidate.targets) {
183
+ if (target === null || typeof target !== "object" || Array.isArray(target)) {
184
+ errors.push("candidate.targets entries must be objects");
185
+ continue;
186
+ }
187
+ if (Object.keys(target).some((key) => !["os", "platform", "architecture"].includes(key))) errors.push("candidate.targets entries may only contain os, platform, architecture");
188
+ if (typeof target.os !== "string" || !SAFE_MATRIX_VALUE_RE.test(target.os)) errors.push("candidate.targets[].os must be a safe runner label");
189
+ if (!RELEASE_PLATFORMS.has(target.platform)) errors.push("candidate.targets[].platform must be windows, macos, or linux");
190
+ if (!RELEASE_ARCHITECTURES.has(target.architecture)) errors.push("candidate.targets[].architecture must be x86_64 or arm64");
191
+ const nativeTarget = NATIVE_RUNNER_TARGETS.get(target.os);
192
+ if (nativeTarget && (target.platform !== nativeTarget[0] || target.architecture !== nativeTarget[1])) {
193
+ errors.push(`candidate.targets[] must declare ${target.os} as ${nativeTarget[0]}/${nativeTarget[1]}`);
194
+ }
195
+ if ((target.platform === "windows" && !/^windows-/i.test(target.os)) || (target.platform === "macos" && !/^macos-/i.test(target.os))) {
196
+ errors.push("candidate.targets[].os must match its Windows or macOS platform");
197
+ }
198
+ const targetKey = `${target.os}|${target.platform}|${target.architecture}`;
199
+ if (targetKeys.has(targetKey)) errors.push("candidate.targets entries must be unique");
200
+ targetKeys.add(targetKey);
201
+ }
202
+ const targetOs = new Set(candidate.targets.map((target) => target?.os));
203
+ if (candidate.os.some((os) => !targetOs.has(os)) || targetOs.size !== new Set(candidate.os).size || candidate.os.length !== new Set(candidate.os).size) {
204
+ errors.push("candidate.os must contain exactly candidate.targets runner labels");
205
+ }
206
+ }
207
+ }
208
+ const targets = candidateTargets(candidate);
159
209
  if (candidate.signWindows !== undefined && typeof candidate.signWindows !== "boolean") {
160
210
  errors.push("candidate.signWindows must be a boolean");
161
211
  }
162
- if (candidate.signWindows === true && candidate.os.filter((value) => /^windows-/i.test(value)).length !== 1) {
163
- errors.push("candidate.signWindows requires exactly one Windows runner in candidate.os");
212
+ if (candidate.signWindows === true && targets.filter((target) => target?.platform === "windows").length === 0) {
213
+ errors.push("candidate.signWindows requires at least one Windows target");
164
214
  }
165
215
  if (candidate.signMacos !== undefined && typeof candidate.signMacos !== "boolean") {
166
216
  errors.push("candidate.signMacos must be a boolean");
167
217
  }
168
- if (candidate.signMacos === true && candidate.os.filter((value) => /^macos-/i.test(value)).length !== 1) {
169
- errors.push("candidate.signMacos requires exactly one macOS runner in candidate.os");
218
+ if (candidate.signMacos === true && targets.filter((target) => target?.platform === "macos").length === 0) {
219
+ errors.push("candidate.signMacos requires at least one macOS target");
170
220
  }
171
221
  if ((candidate.signWindows === true || candidate.signMacos === true) && manifest.matrix?.node?.length !== 1) {
172
222
  errors.push("candidate signing requires exactly one matrix.node version");
173
223
  }
224
+ if (candidate.releaseChain !== undefined) {
225
+ const chain = candidate.releaseChain;
226
+ const allowedChainKeys = ["windowsFinalizeScript", "macosFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable", "publishSecrets"];
227
+ if (chain === null || typeof chain !== "object" || Array.isArray(chain)) {
228
+ errors.push("candidate.releaseChain must be an object");
229
+ } else {
230
+ for (const key of Object.keys(chain)) {
231
+ if (!allowedChainKeys.includes(key)) errors.push(`unknown candidate.releaseChain field: ${key}`);
232
+ }
233
+ for (const key of ["windowsFinalizeScript", "installedQualificationScript", "publishScript"]) {
234
+ if (typeof chain[key] !== "string" || !PACKAGE_SCRIPT_RE.test(chain[key])) {
235
+ errors.push(`candidate.releaseChain.${key} must be one safe package script name`);
236
+ }
237
+ }
238
+ if (chain.macosFinalizeScript !== undefined && (typeof chain.macosFinalizeScript !== "string" || !PACKAGE_SCRIPT_RE.test(chain.macosFinalizeScript))) {
239
+ errors.push("candidate.releaseChain.macosFinalizeScript must be one safe package script name");
240
+ }
241
+ if (typeof chain.qualificationRunnerVariable !== "string" || !GITHUB_VARIABLE_RE.test(chain.qualificationRunnerVariable)) {
242
+ errors.push("candidate.releaseChain.qualificationRunnerVariable must be one uppercase GitHub variable name");
243
+ }
244
+ if (chain.publishSecrets !== undefined && (!Array.isArray(chain.publishSecrets) || new Set(chain.publishSecrets).size !== chain.publishSecrets.length || chain.publishSecrets.some((value) => typeof value !== "string" || !GITHUB_VARIABLE_RE.test(value)))) {
245
+ errors.push("candidate.releaseChain.publishSecrets must be unique uppercase GitHub secret names");
246
+ }
247
+ if (candidate.signWindows !== true) errors.push("candidate.releaseChain requires candidate.signWindows=true");
248
+ if (targets.filter((target) => target?.platform === "windows").length !== 1 || targets.filter((target) => target?.platform === "macos").length > 1) {
249
+ errors.push("candidate.releaseChain requires at most one target per signing platform; multi-target aggregation is not supported");
250
+ }
251
+ if (candidate.signMacos === true && !chain.macosFinalizeScript) errors.push("candidate.releaseChain with candidate.signMacos=true requires macosFinalizeScript");
252
+ if (chain.macosFinalizeScript && candidate.signMacos !== true) errors.push("candidate.releaseChain.macosFinalizeScript requires candidate.signMacos=true");
253
+ }
254
+ }
174
255
  }
175
256
  }
176
257
  if (profile === "rust-hybrid" && lanes.includes("release-candidate") && candidate === undefined) {
@@ -295,10 +376,8 @@ function yamlFlowStringList(values) {
295
376
 
296
377
  function candidateMatrix(manifest) {
297
378
  const entries = [];
298
- for (const os of manifest.candidate?.os ?? manifest.matrix.os) {
299
- const platform = /^windows-/i.test(os) ? "windows" : /^macos-/i.test(os) ? "macos" : "linux";
300
- const architecture = platform === "macos" ? "arm64" : "x86_64";
301
- for (const node of manifest.matrix.node) entries.push({ os, node, platform, architecture });
379
+ for (const target of candidateTargets(manifest.candidate ?? { os: manifest.matrix.os })) {
380
+ for (const node of manifest.matrix.node) entries.push({ ...target, node, targetTriple: targetTriple(target) });
302
381
  }
303
382
  return [
304
383
  " include:",
@@ -307,6 +386,31 @@ function candidateMatrix(manifest) {
307
386
  ` node: ${JSON.stringify(entry.node)}`,
308
387
  ` platform: ${JSON.stringify(entry.platform)}`,
309
388
  ` architecture: ${JSON.stringify(entry.architecture)}`,
389
+ ` targetTriple: ${JSON.stringify(entry.targetTriple)}`,
390
+ ]),
391
+ ].join("\n");
392
+ }
393
+
394
+ function targetTriple(target) {
395
+ const rustArchitecture = target.architecture === "arm64" ? "aarch64" : target.architecture;
396
+ if (target.platform === "windows") return `${rustArchitecture}-pc-windows-msvc`;
397
+ if (target.platform === "macos") return `${rustArchitecture}-apple-darwin`;
398
+ return `${rustArchitecture}-unknown-linux-gnu`;
399
+ }
400
+
401
+ function signingMatrix(manifest, platform) {
402
+ const node = String(manifest.matrix.node[0]);
403
+ const entries = candidateTargets(manifest.candidate)
404
+ .filter((target) => target.platform === platform)
405
+ .map((target) => ({ ...target, node, targetTriple: targetTriple(target) }));
406
+ return [
407
+ " include:",
408
+ ...entries.flatMap((entry) => [
409
+ ` - os: ${JSON.stringify(entry.os)}`,
410
+ ` platform: ${JSON.stringify(entry.platform)}`,
411
+ ` architecture: ${JSON.stringify(entry.architecture)}`,
412
+ ` targetTriple: ${JSON.stringify(entry.targetTriple)}`,
413
+ ` node: ${JSON.stringify(entry.node)}`,
310
414
  ]),
311
415
  ].join("\n");
312
416
  }
@@ -321,13 +425,30 @@ const WINDOWS_SDK_BUILD_TOOLS_SHA256 = "180DEB372659029864C10A0C04787833234D64AA
321
425
 
322
426
  function windowsSigningJob(manifest, pm, artifactPrefix) {
323
427
  if (manifest.candidate?.signWindows !== true) return "";
324
- const windowsRunner = manifest.candidate.os.find((value) => /^windows-/i.test(value));
325
428
  const nodeVersion = String(manifest.matrix.node[0]);
429
+ const releaseChain = manifest.candidate.releaseChain;
430
+ const finalizeCommand = releaseChain
431
+ ? `${pm.runPrefix} ${releaseChain.windowsFinalizeScript}`
432
+ : "pnpm exec right-release build --platform win --skip-checks";
433
+ const finalizeEnvironment = releaseChain
434
+ ? ` RIGHT_GIT_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
435
+ RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/finalized-windows
436
+ RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`
437
+ : ` LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
438
+ LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`;
439
+ const uploadPath = releaseChain
440
+ ? " ${{ runner.temp }}/finalized-windows/**"
441
+ : " dist/releases/windows/**\n .right-release/receipts/windows-*.json";
326
442
  return `
327
443
  windows-sign:
328
444
  needs: [candidate]
445
+ if: \${{ startsWith(github.ref, 'refs/tags/v') }}
329
446
  environment: release
330
- runs-on: ${windowsRunner}
447
+ strategy:
448
+ fail-fast: false
449
+ matrix:
450
+ ${signingMatrix(manifest, "windows")}
451
+ runs-on: \${{ matrix.os }}
331
452
  permissions:
332
453
  contents: read
333
454
  id-token: write
@@ -344,11 +465,12 @@ ${pm.setup}
344
465
  - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0
345
466
  with:
346
467
  toolchain: 1.98.0
468
+ targets: \${{ matrix.targetTriple }}
347
469
  - run: ${pm.installCommand}
348
470
  - name: Download exact unsigned Windows candidate
349
471
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
350
472
  with:
351
- name: ${artifactPrefix}-unsigned-${windowsRunner}-${nodeVersion}
473
+ name: ${artifactPrefix}-unsigned-\${{ matrix.platform }}-\${{ matrix.architecture }}-${nodeVersion}
352
474
  path: \${{ runner.temp }}/unsigned-windows-candidate
353
475
  - name: Authenticate to Azure with GitHub OIDC
354
476
  uses: azure/login@${AZURE_LOGIN_SHA} # v3
@@ -373,33 +495,50 @@ ${pm.setup}
373
495
  "AZURE_ARTIFACT_SIGNING_DLIB_PATH=$tools\\client\\bin\\x64\\Azure.CodeSigning.Dlib.dll" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
374
496
  "AZURE_SIGNTOOL_PATH=$tools\\sdk\\bin\\10.0.26100.0\\x64\\signtool.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
375
497
  - name: Sign & finalize Windows candidate
376
- run: pnpm exec right-release build --platform win --skip-checks
498
+ run: ${finalizeCommand}
377
499
  env:
378
- LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
379
- LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
500
+ ${finalizeEnvironment}
501
+ RIGHT_GIT_RELEASE_PLATFORM: \${{ matrix.platform }}
502
+ RIGHT_GIT_RELEASE_ARCHITECTURE: \${{ matrix.architecture }}
380
503
  AZURE_ARTIFACT_SIGNING_ENDPOINT: \${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }}
381
504
  AZURE_ARTIFACT_SIGNING_ACCOUNT: \${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT }}
382
505
  AZURE_ARTIFACT_SIGNING_PROFILE: \${{ vars.AZURE_ARTIFACT_SIGNING_PROFILE }}
383
506
  - name: Upload signed Windows candidate
384
507
  uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
385
508
  with:
386
- name: ${artifactPrefix}-signed-windows-${nodeVersion}
509
+ name: ${artifactPrefix}-signed-windows-\${{ matrix.architecture }}-${nodeVersion}
387
510
  path: |
388
- dist/releases/windows/**
389
- .right-release/receipts/windows-*.json
511
+ ${uploadPath}
390
512
  if-no-files-found: error
391
513
  `.trimEnd();
392
514
  }
393
515
 
394
516
  function macosSigningJob(manifest, pm, artifactPrefix) {
395
517
  if (manifest.candidate?.signMacos !== true) return "";
396
- const macosRunner = manifest.candidate.os.find((value) => /^macos-/i.test(value));
397
518
  const nodeVersion = String(manifest.matrix.node[0]);
519
+ const releaseChain = manifest.candidate.releaseChain;
520
+ const finalizeCommand = releaseChain
521
+ ? `${pm.runPrefix} ${releaseChain.macosFinalizeScript}`
522
+ : "pnpm exec right-release build --platform mac --skip-checks";
523
+ const finalizeEnvironment = releaseChain
524
+ ? ` RIGHT_GIT_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
525
+ RIGHT_GIT_FINALIZED_MACOS_ROOT: \${{ runner.temp }}/finalized-macos
526
+ RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`
527
+ : ` LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
528
+ LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`;
529
+ const uploadPath = releaseChain
530
+ ? " ${{ runner.temp }}/finalized-macos/**"
531
+ : " dist/releases/mac/**\n .right-release/receipts/macos-*.json";
398
532
  return `
399
533
  macos-sign:
400
534
  needs: [candidate]
535
+ if: \${{ startsWith(github.ref, 'refs/tags/v') }}
401
536
  environment: release
402
- runs-on: ${macosRunner}
537
+ strategy:
538
+ fail-fast: false
539
+ matrix:
540
+ ${signingMatrix(manifest, "macos")}
541
+ runs-on: \${{ matrix.os }}
403
542
  permissions:
404
543
  contents: read
405
544
  steps:
@@ -415,11 +554,12 @@ ${pm.setup}
415
554
  - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0
416
555
  with:
417
556
  toolchain: 1.98.0
557
+ targets: \${{ matrix.targetTriple }}
418
558
  - run: ${pm.installCommand}
419
559
  - name: Download exact unsigned macOS candidate
420
560
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
421
561
  with:
422
- name: ${artifactPrefix}-unsigned-${macosRunner}-${nodeVersion}
562
+ name: ${artifactPrefix}-unsigned-\${{ matrix.platform }}-\${{ matrix.architecture }}-${nodeVersion}
423
563
  path: \${{ runner.temp }}/unsigned-macos-candidate
424
564
  - name: Install Apple signing identity in temporary keychain
425
565
  shell: bash
@@ -443,20 +583,20 @@ ${pm.setup}
443
583
  security list-keychain -d user -s "$keychain_path"
444
584
  printf 'APPLE_API_KEY_PATH=%s\\n' "$key_path" >> "$GITHUB_ENV"
445
585
  - name: Sign, notarize & finalize macOS candidate
446
- run: pnpm exec right-release build --platform mac --skip-checks
586
+ run: ${finalizeCommand}
447
587
  env:
448
- LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
449
- LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
588
+ ${finalizeEnvironment}
589
+ RIGHT_GIT_RELEASE_PLATFORM: \${{ matrix.platform }}
590
+ RIGHT_GIT_RELEASE_ARCHITECTURE: \${{ matrix.architecture }}
450
591
  APPLE_DEVELOPER_ID: \${{ vars.APPLE_DEVELOPER_ID }}
451
592
  APPLE_API_KEY: \${{ secrets.APPLE_API_KEY }}
452
593
  APPLE_API_ISSUER: \${{ secrets.APPLE_API_ISSUER }}
453
594
  - name: Upload signed & notarized macOS candidate
454
595
  uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
455
596
  with:
456
- name: ${artifactPrefix}-signed-macos-${nodeVersion}
597
+ name: ${artifactPrefix}-signed-macos-\${{ matrix.architecture }}-${nodeVersion}
457
598
  path: |
458
- dist/releases/mac/**
459
- .right-release/receipts/macos-*.json
599
+ ${uploadPath}
460
600
  if-no-files-found: error
461
601
  - name: Remove Apple signing material
462
602
  if: \${{ always() }}
@@ -467,6 +607,105 @@ ${pm.setup}
467
607
  `.trimEnd();
468
608
  }
469
609
 
610
+ function releaseChainJobs(manifest, pm, artifactPrefix) {
611
+ const chain = manifest.candidate?.releaseChain;
612
+ if (!chain) return "";
613
+ const windowsTarget = candidateTargets(manifest.candidate).find((target) => target.platform === "windows");
614
+ const windowsRunner = windowsTarget.os;
615
+ const windowsArchitecture = windowsTarget.architecture;
616
+ const macosTarget = candidateTargets(manifest.candidate).find((target) => target.platform === "macos");
617
+ const nodeVersion = String(manifest.matrix.node[0]);
618
+ const macosDependency = manifest.candidate.signMacos === true ? ", macos-sign" : "";
619
+ const macosSuccess = manifest.candidate.signMacos === true ? " && needs.macos-sign.result == 'success'" : "";
620
+ const macosDownload = manifest.candidate.signMacos === true
621
+ ? ` - name: Download same-run signed & notarized macOS release
622
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
623
+ with:
624
+ name: ${artifactPrefix}-signed-macos-${macosTarget.architecture}-${nodeVersion}
625
+ path: \${{ runner.temp }}/right-git-release/macos`
626
+ : "";
627
+ const publishSecretEnvironment = (chain.publishSecrets ?? [])
628
+ .map((name) => ` ${name}: \${{ secrets.${name} }}`)
629
+ .join("\n");
630
+ return `
631
+ installed-qualification:
632
+ needs: [candidate, windows-sign]
633
+ if: \${{ always() && startsWith(github.ref, 'refs/tags/v') && needs.candidate.result == 'success' }}
634
+ environment: release
635
+ runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
636
+ steps:
637
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
638
+ - name: Resolve checked-out source revision
639
+ shell: pwsh
640
+ run: '"RIGHT_GIT_SOURCE_REVISION=$(git rev-parse HEAD)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append'
641
+ - name: Fail closed when protected finalization is absent
642
+ if: \${{ needs.windows-sign.result != 'success' }}
643
+ shell: pwsh
644
+ run: throw 'installed_qualification_failed { reason: prerequisites_absent, detail: "same-run Windows signing/finalization did not succeed" }'
645
+ - name: Download same-run finalized Windows release
646
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
647
+ with:
648
+ name: ${artifactPrefix}-signed-windows-${windowsArchitecture}-${nodeVersion}
649
+ path: \${{ runner.temp }}/right-git-release/windows
650
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
651
+ with:
652
+ node-version: ${nodeVersion}
653
+ package-manager-cache: false
654
+ ${pm.setup}
655
+ - run: ${pm.installCommand}
656
+ - name: Run installed-artifact qualification
657
+ run: ${pm.runPrefix} ${chain.installedQualificationScript}
658
+ env:
659
+ RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/right-git-release/windows
660
+ RIGHT_GIT_QUALIFICATION_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
661
+ RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
662
+ - name: Upload installed qualification evidence
663
+ uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
664
+ with:
665
+ name: ${artifactPrefix}-installed-qualification-${nodeVersion}
666
+ path: \${{ runner.temp }}/right-git-release/qualification/**
667
+ if-no-files-found: error
668
+ publish:
669
+ needs: [installed-qualification${macosDependency}]
670
+ if: \${{ startsWith(github.ref, 'refs/tags/v') && needs.installed-qualification.result == 'success'${macosSuccess} }}
671
+ environment: release
672
+ runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
673
+ permissions:
674
+ contents: write
675
+ steps:
676
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
677
+ - name: Resolve checked-out source revision
678
+ shell: pwsh
679
+ run: '"RIGHT_GIT_SOURCE_REVISION=$(git rev-parse HEAD)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append'
680
+ - name: Download same-run finalized Windows release
681
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
682
+ with:
683
+ name: ${artifactPrefix}-signed-windows-${windowsArchitecture}-${nodeVersion}
684
+ path: \${{ runner.temp }}/right-git-release/windows
685
+ ${macosDownload}
686
+ - name: Download same-run installed qualification evidence
687
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
688
+ with:
689
+ name: ${artifactPrefix}-installed-qualification-${nodeVersion}
690
+ path: \${{ runner.temp }}/right-git-release/qualification
691
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
692
+ with:
693
+ node-version: ${nodeVersion}
694
+ package-manager-cache: false
695
+ ${pm.setup}
696
+ - run: ${pm.installCommand}
697
+ - name: Publish qualified signed release
698
+ run: ${pm.runPrefix} ${chain.publishScript}
699
+ env:
700
+ GH_TOKEN: \${{ github.token }}
701
+ RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/right-git-release/windows
702
+ RIGHT_GIT_FINALIZED_MACOS_ROOT: \${{ runner.temp }}/right-git-release/macos
703
+ RIGHT_GIT_QUALIFICATION_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
704
+ RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
705
+ ${publishSecretEnvironment}
706
+ `.trimEnd();
707
+ }
708
+
470
709
  /**
471
710
  * Derive one project name from the target repo's package.json, falling back
472
711
  * to the repo directory's basename. Never throws.
@@ -513,6 +752,7 @@ export function buildRenderContext(manifest, repoPath) {
513
752
  CANDIDATE_MATRIX: candidateMatrix(manifest),
514
753
  WINDOWS_SIGNING_JOB: windowsSigningJob(manifest, pm, artifactPrefix),
515
754
  MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
755
+ RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
516
756
  };
517
757
 
518
758
  if (manifest.publish?.npm) {
@@ -95,8 +95,39 @@
95
95
  "checkScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
96
96
  "artifactRoot": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$" },
97
97
  "os": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
98
+ "targets": {
99
+ "type": "array",
100
+ "minItems": 1,
101
+ "items": {
102
+ "type": "object",
103
+ "required": ["os", "platform", "architecture"],
104
+ "additionalProperties": false,
105
+ "properties": {
106
+ "os": { "type": "string", "minLength": 1 },
107
+ "platform": { "enum": ["windows", "macos", "linux"] },
108
+ "architecture": { "enum": ["x86_64", "arm64"] }
109
+ }
110
+ }
111
+ },
98
112
  "signWindows": { "type": "boolean" },
99
- "signMacos": { "type": "boolean" }
113
+ "signMacos": { "type": "boolean" },
114
+ "releaseChain": {
115
+ "type": "object",
116
+ "required": ["windowsFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable"],
117
+ "additionalProperties": false,
118
+ "properties": {
119
+ "windowsFinalizeScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
120
+ "macosFinalizeScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
121
+ "installedQualificationScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
122
+ "publishScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
123
+ "qualificationRunnerVariable": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" },
124
+ "publishSecrets": {
125
+ "type": "array",
126
+ "uniqueItems": true,
127
+ "items": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }
128
+ }
129
+ }
130
+ }
100
131
  }
101
132
  }
102
133
  }
package/manifest.test.mjs CHANGED
@@ -41,6 +41,54 @@ test("rust-hybrid supports ci plus a configured unsigned release-candidate lane"
41
41
  assert.equal(validateManifest({ ...candidate, candidate: { ...candidate.candidate, buildScript: "echo unsafe" } }).valid, false);
42
42
  assert.equal(validateManifest({ ...candidate, candidate: { ...candidate.candidate, artifactRoot: "../escape" } }).valid, false);
43
43
  assert.equal(validateManifest({ ...candidate, candidate: { ...candidate.candidate, os: ["windows-latest"] } }).valid, false);
44
+
45
+ const releaseChain = {
46
+ ...windowsCandidate,
47
+ candidate: {
48
+ ...windowsCandidate.candidate,
49
+ releaseChain: {
50
+ windowsFinalizeScript: "release:finalize:win",
51
+ installedQualificationScript: "release:qualify:installed",
52
+ publishScript: "release:publish:qualified",
53
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
54
+ publishSecrets: ["CLOUDFLARE_API_TOKEN"],
55
+ },
56
+ },
57
+ };
58
+ assert.equal(validateManifest(releaseChain).valid, true);
59
+ assert.equal(validateManifest({ ...releaseChain, candidate: { ...releaseChain.candidate, signWindows: false } }).valid, false);
60
+ assert.equal(validateManifest({ ...releaseChain, candidate: { ...releaseChain.candidate, releaseChain: { ...releaseChain.candidate.releaseChain, publishScript: "echo unsafe" } } }).valid, false);
61
+ assert.equal(validateManifest({ ...releaseChain, candidate: { ...releaseChain.candidate, releaseChain: { ...releaseChain.candidate.releaseChain, qualificationRunnerVariable: "${{ secrets.X }}" } } }).valid, false);
62
+ assert.equal(validateManifest({ ...releaseChain, candidate: { ...releaseChain.candidate, releaseChain: { ...releaseChain.candidate.releaseChain, publishSecrets: ["TOKEN", "TOKEN"] } } }).valid, false);
63
+ });
64
+
65
+ test("candidate targets permit native multi-architecture matrices independent of normal CI runners", () => {
66
+ const multiArchitecture = {
67
+ ...VALID_MINIMAL,
68
+ profile: "rust-hybrid",
69
+ gate: { path: "scripts/gate.sh" },
70
+ lanes: ["ci", "release-candidate"],
71
+ candidate: {
72
+ buildScript: "ci:candidate",
73
+ checkScript: "ci:candidate-check",
74
+ artifactRoot: "widgets-unsigned",
75
+ os: ["windows-2025", "windows-11-arm", "macos-15", "macos-15-intel"],
76
+ targets: [
77
+ { os: "windows-2025", platform: "windows", architecture: "x86_64" },
78
+ { os: "windows-11-arm", platform: "windows", architecture: "arm64" },
79
+ { os: "macos-15", platform: "macos", architecture: "arm64" },
80
+ { os: "macos-15-intel", platform: "macos", architecture: "x86_64" },
81
+ ],
82
+ signWindows: true,
83
+ signMacos: true,
84
+ },
85
+ };
86
+ assert.equal(validateManifest(multiArchitecture).valid, true, JSON.stringify(validateManifest(multiArchitecture).errors));
87
+ assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, targets: [{ os: "windows-2025", platform: "windows", architecture: "riscv64" }] } }).valid, false);
88
+ assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, targets: [{ os: "windows-11-arm", platform: "windows", architecture: "x86_64" }] } }).valid, false);
89
+ assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, targets: [{ os: "ubuntu-24.04", platform: "windows", architecture: "x86_64" }] } }).valid, false);
90
+ assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, targets: [{ os: "${{ secrets.X }}", platform: "windows", architecture: "x86_64" }] } }).valid, false);
91
+ assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, releaseChain: { windowsFinalizeScript: "release:finalize:win", macosFinalizeScript: "release:finalize:mac", installedQualificationScript: "release:qualify", publishScript: "release:publish", qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL" } } }).valid, false);
44
92
  });
45
93
 
46
94
  test("rejects non-object input", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/git",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Public-repo-only GitHub Actions workflow layer: templates, visibility gate, sync/drift/adopt/uninstall for right-git.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
package/render.test.mjs CHANGED
@@ -90,6 +90,7 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
90
90
  buildRenderContext(manifest, "/repos/widgets"),
91
91
  );
92
92
  assert.match(content, /environment: release/);
93
+ assert.match(content, /windows-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) \}\}/);
93
94
  assert.match(content, /id-token: write/);
94
95
  assert.match(content, /azure\/login@[a-f0-9]{40}/);
95
96
  assert.match(content, /vars\.AZURE_CLIENT_ID/);
@@ -116,6 +117,7 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
116
117
  buildRenderContext(manifest, "/repos/widgets"),
117
118
  );
118
119
  assert.match(content, /macos-sign:/);
120
+ assert.match(content, /macos-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) \}\}/);
119
121
  assert.match(content, /environment: release/);
120
122
  assert.match(content, /secrets\.APPLE_CERTIFICATE_BASE64/);
121
123
  assert.match(content, /temporary keychain/i);
@@ -128,6 +130,90 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
128
130
  assert.doesNotMatch(content, /pull_request_target/);
129
131
  });
130
132
 
133
+ test("rust-hybrid multi-architecture candidates bind every native target to exact artifacts and signing", () => {
134
+ const manifest = {
135
+ ...FULL_MANIFEST,
136
+ lanes: ["ci", "release-candidate"],
137
+ profile: "rust-hybrid",
138
+ gate: { path: "scripts/gate.sh" },
139
+ matrix: { os: ["ubuntu-24.04"], node: ["22.23.2"] },
140
+ candidate: {
141
+ buildScript: "ci:candidate",
142
+ checkScript: "ci:candidate-check",
143
+ artifactRoot: "widgets-unsigned",
144
+ os: ["windows-2025", "windows-11-arm", "macos-15", "macos-15-intel"],
145
+ targets: [
146
+ { os: "windows-2025", platform: "windows", architecture: "x86_64" },
147
+ { os: "windows-11-arm", platform: "windows", architecture: "arm64" },
148
+ { os: "macos-15", platform: "macos", architecture: "arm64" },
149
+ { os: "macos-15-intel", platform: "macos", architecture: "x86_64" },
150
+ ],
151
+ signWindows: true,
152
+ signMacos: true,
153
+ },
154
+ templateVersion: "1.5.0",
155
+ };
156
+ const content = renderTemplate(
157
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
158
+ buildRenderContext(manifest, "/repos/widgets"),
159
+ );
160
+ for (const [runner, triple] of [["windows-2025", "x86_64-pc-windows-msvc"], ["windows-11-arm", "aarch64-pc-windows-msvc"], ["macos-15", "aarch64-apple-darwin"], ["macos-15-intel", "x86_64-apple-darwin"]]) {
161
+ assert.match(content, new RegExp(`os: ${JSON.stringify(runner).replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`));
162
+ assert.match(content, new RegExp(`targetTriple: ${JSON.stringify(triple).replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`));
163
+ }
164
+ assert.match(content, /targets: \$\{\{ matrix\.targetTriple \}\}/);
165
+ assert.match(content, /windows-sign:[\s\S]*matrix:[\s\S]*windows-11-arm/);
166
+ assert.match(content, /macos-sign:[\s\S]*matrix:[\s\S]*macos-15-intel/);
167
+ assert.match(content, /name: widgets-unsigned-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
168
+ assert.match(content, /name: widgets-signed-windows-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
169
+ assert.match(content, /name: widgets-signed-macos-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
170
+ assert.equal((content.match(/RIGHT_GIT_RELEASE_ARCHITECTURE: \$\{\{ matrix\.architecture \}\}/g) ?? []).length, 4);
171
+ });
172
+
173
+ test("rust-hybrid release chain preserves signed candidate, installed qualification, then publish", () => {
174
+ const manifest = {
175
+ ...FULL_MANIFEST,
176
+ lanes: ["ci", "release-candidate"],
177
+ profile: "rust-hybrid",
178
+ gate: { path: "scripts/gate.sh" },
179
+ candidate: {
180
+ buildScript: "ci:candidate",
181
+ checkScript: "ci:candidate-check",
182
+ artifactRoot: "widgets-unsigned",
183
+ os: ["windows-2025", "macos-15"],
184
+ signWindows: true,
185
+ signMacos: true,
186
+ releaseChain: {
187
+ windowsFinalizeScript: "release:finalize:win",
188
+ macosFinalizeScript: "release:finalize:mac",
189
+ installedQualificationScript: "release:qualify:installed",
190
+ publishScript: "release:publish:qualified",
191
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
192
+ publishSecrets: ["CLOUDFLARE_API_TOKEN"],
193
+ },
194
+ },
195
+ matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
196
+ templateVersion: "1.4.0",
197
+ };
198
+ const content = renderTemplate(
199
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
200
+ buildRenderContext(manifest, "/repos/widgets"),
201
+ );
202
+ assert.match(content, /windows-sign:[\s\S]*pnpm run release:finalize:win/);
203
+ assert.match(content, /macos-sign:[\s\S]*pnpm run release:finalize:mac/);
204
+ assert.match(content, /installed-qualification:\n needs: \[candidate, windows-sign\]/);
205
+ assert.match(content, /installed_qualification_failed/);
206
+ assert.match(content, /pnpm run release:qualify:installed/);
207
+ assert.match(content, /publish:\n needs: \[installed-qualification, macos-sign\]/);
208
+ assert.match(content, /needs\.macos-sign\.result == 'success'/);
209
+ assert.match(content, /contents: write/);
210
+ assert.match(content, /pnpm run release:publish:qualified/);
211
+ assert.match(content, /CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}/);
212
+ assert.equal((content.match(/RIGHT_GIT_SOURCE_REVISION=\$\(git rev-parse HEAD\)/g) ?? []).length, 3);
213
+ assert.doesNotMatch(content, /RIGHT_GIT_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
214
+ assert.doesNotMatch(content.slice(0, content.indexOf(" windows-sign:")), /secrets\.|id-token: write|contents: write/);
215
+ });
216
+
131
217
  test("render determinism: same manifest renders byte-identical output every time", () => {
132
218
  const first = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
133
219
  const second = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
@@ -34,6 +34,7 @@ ${PACKAGE_MANAGER_SETUP}
34
34
  with:
35
35
  toolchain: 1.98.0
36
36
  components: rustfmt, clippy
37
+ targets: ${{ matrix.targetTriple }}
37
38
  - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
38
39
  with:
39
40
  workspaces: engine
@@ -53,8 +54,9 @@ ${PACKAGE_MANAGER_SETUP}
53
54
  - name: Upload unsigned candidate handoff
54
55
  uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
55
56
  with:
56
- name: ${ARTIFACT_PREFIX}-unsigned-${{ matrix.os }}-${{ matrix.node }}
57
+ name: ${ARTIFACT_PREFIX}-unsigned-${{ matrix.platform }}-${{ matrix.architecture }}-${{ matrix.node }}
57
58
  path: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}/**
58
59
  if-no-files-found: error
59
60
  ${WINDOWS_SIGNING_JOB}
60
61
  ${MACOS_SIGNING_JOB}
62
+ ${RELEASE_CHAIN_JOBS}