@rightkit/git 0.2.7 → 0.2.8

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, signWindows?, signMacos?, releaseChain? }`; `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.
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. 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,7 @@ 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_]*$/;
64
65
 
65
66
  /**
66
67
  * Validate a parsed manifest object against the right-git manifest contract.
@@ -141,7 +142,7 @@ export function validateManifest(manifest) {
141
142
  errors.push("candidate must be an object");
142
143
  } else {
143
144
  for (const key of Object.keys(candidate)) {
144
- if (!["buildScript", "checkScript", "artifactRoot", "os", "signWindows", "signMacos"].includes(key)) errors.push(`unknown candidate field: ${key}`);
145
+ if (!["buildScript", "checkScript", "artifactRoot", "os", "signWindows", "signMacos", "releaseChain"].includes(key)) errors.push(`unknown candidate field: ${key}`);
145
146
  }
146
147
  for (const key of ["buildScript", "checkScript"]) {
147
148
  if (typeof candidate[key] !== "string" || !PACKAGE_SCRIPT_RE.test(candidate[key])) {
@@ -171,6 +172,34 @@ export function validateManifest(manifest) {
171
172
  if ((candidate.signWindows === true || candidate.signMacos === true) && manifest.matrix?.node?.length !== 1) {
172
173
  errors.push("candidate signing requires exactly one matrix.node version");
173
174
  }
175
+ if (candidate.releaseChain !== undefined) {
176
+ const chain = candidate.releaseChain;
177
+ const allowedChainKeys = ["windowsFinalizeScript", "macosFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable", "publishSecrets"];
178
+ if (chain === null || typeof chain !== "object" || Array.isArray(chain)) {
179
+ errors.push("candidate.releaseChain must be an object");
180
+ } else {
181
+ for (const key of Object.keys(chain)) {
182
+ if (!allowedChainKeys.includes(key)) errors.push(`unknown candidate.releaseChain field: ${key}`);
183
+ }
184
+ for (const key of ["windowsFinalizeScript", "installedQualificationScript", "publishScript"]) {
185
+ if (typeof chain[key] !== "string" || !PACKAGE_SCRIPT_RE.test(chain[key])) {
186
+ errors.push(`candidate.releaseChain.${key} must be one safe package script name`);
187
+ }
188
+ }
189
+ if (chain.macosFinalizeScript !== undefined && (typeof chain.macosFinalizeScript !== "string" || !PACKAGE_SCRIPT_RE.test(chain.macosFinalizeScript))) {
190
+ errors.push("candidate.releaseChain.macosFinalizeScript must be one safe package script name");
191
+ }
192
+ if (typeof chain.qualificationRunnerVariable !== "string" || !GITHUB_VARIABLE_RE.test(chain.qualificationRunnerVariable)) {
193
+ errors.push("candidate.releaseChain.qualificationRunnerVariable must be one uppercase GitHub variable name");
194
+ }
195
+ 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)))) {
196
+ errors.push("candidate.releaseChain.publishSecrets must be unique uppercase GitHub secret names");
197
+ }
198
+ if (candidate.signWindows !== true) errors.push("candidate.releaseChain requires candidate.signWindows=true");
199
+ if (candidate.signMacos === true && !chain.macosFinalizeScript) errors.push("candidate.releaseChain with candidate.signMacos=true requires macosFinalizeScript");
200
+ if (chain.macosFinalizeScript && candidate.signMacos !== true) errors.push("candidate.releaseChain.macosFinalizeScript requires candidate.signMacos=true");
201
+ }
202
+ }
174
203
  }
175
204
  }
176
205
  if (profile === "rust-hybrid" && lanes.includes("release-candidate") && candidate === undefined) {
@@ -323,9 +352,23 @@ function windowsSigningJob(manifest, pm, artifactPrefix) {
323
352
  if (manifest.candidate?.signWindows !== true) return "";
324
353
  const windowsRunner = manifest.candidate.os.find((value) => /^windows-/i.test(value));
325
354
  const nodeVersion = String(manifest.matrix.node[0]);
355
+ const releaseChain = manifest.candidate.releaseChain;
356
+ const finalizeCommand = releaseChain
357
+ ? `${pm.runPrefix} ${releaseChain.windowsFinalizeScript}`
358
+ : "pnpm exec right-release build --platform win --skip-checks";
359
+ const finalizeEnvironment = releaseChain
360
+ ? ` RIGHT_GIT_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
361
+ RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/finalized-windows
362
+ RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`
363
+ : ` LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
364
+ LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`;
365
+ const uploadPath = releaseChain
366
+ ? " ${{ runner.temp }}/finalized-windows/**"
367
+ : " dist/releases/windows/**\n .right-release/receipts/windows-*.json";
326
368
  return `
327
369
  windows-sign:
328
370
  needs: [candidate]
371
+ if: \${{ startsWith(github.ref, 'refs/tags/v') }}
329
372
  environment: release
330
373
  runs-on: ${windowsRunner}
331
374
  permissions:
@@ -373,10 +416,9 @@ ${pm.setup}
373
416
  "AZURE_ARTIFACT_SIGNING_DLIB_PATH=$tools\\client\\bin\\x64\\Azure.CodeSigning.Dlib.dll" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
374
417
  "AZURE_SIGNTOOL_PATH=$tools\\sdk\\bin\\10.0.26100.0\\x64\\signtool.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
375
418
  - name: Sign & finalize Windows candidate
376
- run: pnpm exec right-release build --platform win --skip-checks
419
+ run: ${finalizeCommand}
377
420
  env:
378
- LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
379
- LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
421
+ ${finalizeEnvironment}
380
422
  AZURE_ARTIFACT_SIGNING_ENDPOINT: \${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }}
381
423
  AZURE_ARTIFACT_SIGNING_ACCOUNT: \${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT }}
382
424
  AZURE_ARTIFACT_SIGNING_PROFILE: \${{ vars.AZURE_ARTIFACT_SIGNING_PROFILE }}
@@ -385,8 +427,7 @@ ${pm.setup}
385
427
  with:
386
428
  name: ${artifactPrefix}-signed-windows-${nodeVersion}
387
429
  path: |
388
- dist/releases/windows/**
389
- .right-release/receipts/windows-*.json
430
+ ${uploadPath}
390
431
  if-no-files-found: error
391
432
  `.trimEnd();
392
433
  }
@@ -395,9 +436,23 @@ function macosSigningJob(manifest, pm, artifactPrefix) {
395
436
  if (manifest.candidate?.signMacos !== true) return "";
396
437
  const macosRunner = manifest.candidate.os.find((value) => /^macos-/i.test(value));
397
438
  const nodeVersion = String(manifest.matrix.node[0]);
439
+ const releaseChain = manifest.candidate.releaseChain;
440
+ const finalizeCommand = releaseChain
441
+ ? `${pm.runPrefix} ${releaseChain.macosFinalizeScript}`
442
+ : "pnpm exec right-release build --platform mac --skip-checks";
443
+ const finalizeEnvironment = releaseChain
444
+ ? ` RIGHT_GIT_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
445
+ RIGHT_GIT_FINALIZED_MACOS_ROOT: \${{ runner.temp }}/finalized-macos
446
+ RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`
447
+ : ` LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
448
+ LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`;
449
+ const uploadPath = releaseChain
450
+ ? " ${{ runner.temp }}/finalized-macos/**"
451
+ : " dist/releases/mac/**\n .right-release/receipts/macos-*.json";
398
452
  return `
399
453
  macos-sign:
400
454
  needs: [candidate]
455
+ if: \${{ startsWith(github.ref, 'refs/tags/v') }}
401
456
  environment: release
402
457
  runs-on: ${macosRunner}
403
458
  permissions:
@@ -443,10 +498,9 @@ ${pm.setup}
443
498
  security list-keychain -d user -s "$keychain_path"
444
499
  printf 'APPLE_API_KEY_PATH=%s\\n' "$key_path" >> "$GITHUB_ENV"
445
500
  - name: Sign, notarize & finalize macOS candidate
446
- run: pnpm exec right-release build --platform mac --skip-checks
501
+ run: ${finalizeCommand}
447
502
  env:
448
- LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
449
- LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
503
+ ${finalizeEnvironment}
450
504
  APPLE_DEVELOPER_ID: \${{ vars.APPLE_DEVELOPER_ID }}
451
505
  APPLE_API_KEY: \${{ secrets.APPLE_API_KEY }}
452
506
  APPLE_API_ISSUER: \${{ secrets.APPLE_API_ISSUER }}
@@ -455,8 +509,7 @@ ${pm.setup}
455
509
  with:
456
510
  name: ${artifactPrefix}-signed-macos-${nodeVersion}
457
511
  path: |
458
- dist/releases/mac/**
459
- .right-release/receipts/macos-*.json
512
+ ${uploadPath}
460
513
  if-no-files-found: error
461
514
  - name: Remove Apple signing material
462
515
  if: \${{ always() }}
@@ -467,6 +520,102 @@ ${pm.setup}
467
520
  `.trimEnd();
468
521
  }
469
522
 
523
+ function releaseChainJobs(manifest, pm, artifactPrefix) {
524
+ const chain = manifest.candidate?.releaseChain;
525
+ if (!chain) return "";
526
+ const windowsRunner = manifest.candidate.os.find((value) => /^windows-/i.test(value));
527
+ const nodeVersion = String(manifest.matrix.node[0]);
528
+ const macosDependency = manifest.candidate.signMacos === true ? ", macos-sign" : "";
529
+ const macosSuccess = manifest.candidate.signMacos === true ? " && needs.macos-sign.result == 'success'" : "";
530
+ const macosDownload = manifest.candidate.signMacos === true
531
+ ? ` - name: Download same-run signed & notarized macOS release
532
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
533
+ with:
534
+ name: ${artifactPrefix}-signed-macos-${nodeVersion}
535
+ path: \${{ runner.temp }}/right-git-release/macos`
536
+ : "";
537
+ const publishSecretEnvironment = (chain.publishSecrets ?? [])
538
+ .map((name) => ` ${name}: \${{ secrets.${name} }}`)
539
+ .join("\n");
540
+ return `
541
+ installed-qualification:
542
+ needs: [candidate, windows-sign]
543
+ if: \${{ always() && startsWith(github.ref, 'refs/tags/v') && needs.candidate.result == 'success' }}
544
+ environment: release
545
+ runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
546
+ steps:
547
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
548
+ - name: Resolve checked-out source revision
549
+ shell: pwsh
550
+ run: '"RIGHT_GIT_SOURCE_REVISION=$(git rev-parse HEAD)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append'
551
+ - name: Fail closed when protected finalization is absent
552
+ if: \${{ needs.windows-sign.result != 'success' }}
553
+ shell: pwsh
554
+ run: throw 'installed_qualification_failed { reason: prerequisites_absent, detail: "same-run Windows signing/finalization did not succeed" }'
555
+ - name: Download same-run finalized Windows release
556
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
557
+ with:
558
+ name: ${artifactPrefix}-signed-windows-${nodeVersion}
559
+ path: \${{ runner.temp }}/right-git-release/windows
560
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
561
+ with:
562
+ node-version: ${nodeVersion}
563
+ package-manager-cache: false
564
+ ${pm.setup}
565
+ - run: ${pm.installCommand}
566
+ - name: Run installed-artifact qualification
567
+ run: ${pm.runPrefix} ${chain.installedQualificationScript}
568
+ env:
569
+ RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/right-git-release/windows
570
+ RIGHT_GIT_QUALIFICATION_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
571
+ RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
572
+ - name: Upload installed qualification evidence
573
+ uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
574
+ with:
575
+ name: ${artifactPrefix}-installed-qualification-${nodeVersion}
576
+ path: \${{ runner.temp }}/right-git-release/qualification/**
577
+ if-no-files-found: error
578
+ publish:
579
+ needs: [installed-qualification${macosDependency}]
580
+ if: \${{ startsWith(github.ref, 'refs/tags/v') && needs.installed-qualification.result == 'success'${macosSuccess} }}
581
+ environment: release
582
+ runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
583
+ permissions:
584
+ contents: write
585
+ steps:
586
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
587
+ - name: Resolve checked-out source revision
588
+ shell: pwsh
589
+ run: '"RIGHT_GIT_SOURCE_REVISION=$(git rev-parse HEAD)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append'
590
+ - name: Download same-run finalized Windows release
591
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
592
+ with:
593
+ name: ${artifactPrefix}-signed-windows-${nodeVersion}
594
+ path: \${{ runner.temp }}/right-git-release/windows
595
+ ${macosDownload}
596
+ - name: Download same-run installed qualification evidence
597
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
598
+ with:
599
+ name: ${artifactPrefix}-installed-qualification-${nodeVersion}
600
+ path: \${{ runner.temp }}/right-git-release/qualification
601
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
602
+ with:
603
+ node-version: ${nodeVersion}
604
+ package-manager-cache: false
605
+ ${pm.setup}
606
+ - run: ${pm.installCommand}
607
+ - name: Publish qualified signed release
608
+ run: ${pm.runPrefix} ${chain.publishScript}
609
+ env:
610
+ GH_TOKEN: \${{ github.token }}
611
+ RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/right-git-release/windows
612
+ RIGHT_GIT_FINALIZED_MACOS_ROOT: \${{ runner.temp }}/right-git-release/macos
613
+ RIGHT_GIT_QUALIFICATION_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
614
+ RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
615
+ ${publishSecretEnvironment}
616
+ `.trimEnd();
617
+ }
618
+
470
619
  /**
471
620
  * Derive one project name from the target repo's package.json, falling back
472
621
  * to the repo directory's basename. Never throws.
@@ -513,6 +662,7 @@ export function buildRenderContext(manifest, repoPath) {
513
662
  CANDIDATE_MATRIX: candidateMatrix(manifest),
514
663
  WINDOWS_SIGNING_JOB: windowsSigningJob(manifest, pm, artifactPrefix),
515
664
  MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
665
+ RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
516
666
  };
517
667
 
518
668
  if (manifest.publish?.npm) {
@@ -96,7 +96,24 @@
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
98
  "signWindows": { "type": "boolean" },
99
- "signMacos": { "type": "boolean" }
99
+ "signMacos": { "type": "boolean" },
100
+ "releaseChain": {
101
+ "type": "object",
102
+ "required": ["windowsFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable"],
103
+ "additionalProperties": false,
104
+ "properties": {
105
+ "windowsFinalizeScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
106
+ "macosFinalizeScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
107
+ "installedQualificationScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
108
+ "publishScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
109
+ "qualificationRunnerVariable": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" },
110
+ "publishSecrets": {
111
+ "type": "array",
112
+ "uniqueItems": true,
113
+ "items": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }
114
+ }
115
+ }
116
+ }
100
117
  }
101
118
  }
102
119
  }
package/manifest.test.mjs CHANGED
@@ -41,6 +41,25 @@ 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);
44
63
  });
45
64
 
46
65
  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.8",
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,50 @@ 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 release chain preserves signed candidate, installed qualification, then publish", () => {
134
+ const manifest = {
135
+ ...FULL_MANIFEST,
136
+ lanes: ["ci", "release-candidate"],
137
+ profile: "rust-hybrid",
138
+ gate: { path: "scripts/gate.sh" },
139
+ candidate: {
140
+ buildScript: "ci:candidate",
141
+ checkScript: "ci:candidate-check",
142
+ artifactRoot: "widgets-unsigned",
143
+ os: ["windows-2025", "macos-15"],
144
+ signWindows: true,
145
+ signMacos: true,
146
+ releaseChain: {
147
+ windowsFinalizeScript: "release:finalize:win",
148
+ macosFinalizeScript: "release:finalize:mac",
149
+ installedQualificationScript: "release:qualify:installed",
150
+ publishScript: "release:publish:qualified",
151
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
152
+ publishSecrets: ["CLOUDFLARE_API_TOKEN"],
153
+ },
154
+ },
155
+ matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
156
+ templateVersion: "1.4.0",
157
+ };
158
+ const content = renderTemplate(
159
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
160
+ buildRenderContext(manifest, "/repos/widgets"),
161
+ );
162
+ assert.match(content, /windows-sign:[\s\S]*pnpm run release:finalize:win/);
163
+ assert.match(content, /macos-sign:[\s\S]*pnpm run release:finalize:mac/);
164
+ assert.match(content, /installed-qualification:\n needs: \[candidate, windows-sign\]/);
165
+ assert.match(content, /installed_qualification_failed/);
166
+ assert.match(content, /pnpm run release:qualify:installed/);
167
+ assert.match(content, /publish:\n needs: \[installed-qualification, macos-sign\]/);
168
+ assert.match(content, /needs\.macos-sign\.result == 'success'/);
169
+ assert.match(content, /contents: write/);
170
+ assert.match(content, /pnpm run release:publish:qualified/);
171
+ assert.match(content, /CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}/);
172
+ assert.equal((content.match(/RIGHT_GIT_SOURCE_REVISION=\$\(git rev-parse HEAD\)/g) ?? []).length, 3);
173
+ assert.doesNotMatch(content, /RIGHT_GIT_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
174
+ assert.doesNotMatch(content.slice(0, content.indexOf(" windows-sign:")), /secrets\.|id-token: write|contents: write/);
175
+ });
176
+
131
177
  test("render determinism: same manifest renders byte-identical output every time", () => {
132
178
  const first = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
133
179
  const second = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
@@ -58,3 +58,4 @@ ${PACKAGE_MANAGER_SETUP}
58
58
  if-no-files-found: error
59
59
  ${WINDOWS_SIGNING_JOB}
60
60
  ${MACOS_SIGNING_JOB}
61
+ ${RELEASE_CHAIN_JOBS}