@rightkit/git 0.2.6 → 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:
@@ -333,6 +376,9 @@ function windowsSigningJob(manifest, pm, artifactPrefix) {
333
376
  id-token: write
334
377
  steps:
335
378
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
379
+ - name: Resolve checked-out source revision
380
+ shell: pwsh
381
+ run: '"RIGHT_GIT_SOURCE_REVISION=$(git rev-parse HEAD)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append'
336
382
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
337
383
  with:
338
384
  node-version: ${nodeVersion}
@@ -370,10 +416,9 @@ ${pm.setup}
370
416
  "AZURE_ARTIFACT_SIGNING_DLIB_PATH=$tools\\client\\bin\\x64\\Azure.CodeSigning.Dlib.dll" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
371
417
  "AZURE_SIGNTOOL_PATH=$tools\\sdk\\bin\\10.0.26100.0\\x64\\signtool.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
372
418
  - name: Sign & finalize Windows candidate
373
- run: pnpm exec right-release build --platform win --skip-checks
419
+ run: ${finalizeCommand}
374
420
  env:
375
- LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
376
- LEGION_SOURCE_REVISION: \${{ github.sha }}
421
+ ${finalizeEnvironment}
377
422
  AZURE_ARTIFACT_SIGNING_ENDPOINT: \${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }}
378
423
  AZURE_ARTIFACT_SIGNING_ACCOUNT: \${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT }}
379
424
  AZURE_ARTIFACT_SIGNING_PROFILE: \${{ vars.AZURE_ARTIFACT_SIGNING_PROFILE }}
@@ -382,8 +427,7 @@ ${pm.setup}
382
427
  with:
383
428
  name: ${artifactPrefix}-signed-windows-${nodeVersion}
384
429
  path: |
385
- dist/releases/windows/**
386
- .right-release/receipts/windows-*.json
430
+ ${uploadPath}
387
431
  if-no-files-found: error
388
432
  `.trimEnd();
389
433
  }
@@ -392,15 +436,32 @@ function macosSigningJob(manifest, pm, artifactPrefix) {
392
436
  if (manifest.candidate?.signMacos !== true) return "";
393
437
  const macosRunner = manifest.candidate.os.find((value) => /^macos-/i.test(value));
394
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";
395
452
  return `
396
453
  macos-sign:
397
454
  needs: [candidate]
455
+ if: \${{ startsWith(github.ref, 'refs/tags/v') }}
398
456
  environment: release
399
457
  runs-on: ${macosRunner}
400
458
  permissions:
401
459
  contents: read
402
460
  steps:
403
461
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
462
+ - name: Resolve checked-out source revision
463
+ shell: bash
464
+ run: printf 'RIGHT_GIT_SOURCE_REVISION=%s\\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV"
404
465
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
405
466
  with:
406
467
  node-version: ${nodeVersion}
@@ -437,10 +498,9 @@ ${pm.setup}
437
498
  security list-keychain -d user -s "$keychain_path"
438
499
  printf 'APPLE_API_KEY_PATH=%s\\n' "$key_path" >> "$GITHUB_ENV"
439
500
  - name: Sign, notarize & finalize macOS candidate
440
- run: pnpm exec right-release build --platform mac --skip-checks
501
+ run: ${finalizeCommand}
441
502
  env:
442
- LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
443
- LEGION_SOURCE_REVISION: \${{ github.sha }}
503
+ ${finalizeEnvironment}
444
504
  APPLE_DEVELOPER_ID: \${{ vars.APPLE_DEVELOPER_ID }}
445
505
  APPLE_API_KEY: \${{ secrets.APPLE_API_KEY }}
446
506
  APPLE_API_ISSUER: \${{ secrets.APPLE_API_ISSUER }}
@@ -449,8 +509,7 @@ ${pm.setup}
449
509
  with:
450
510
  name: ${artifactPrefix}-signed-macos-${nodeVersion}
451
511
  path: |
452
- dist/releases/mac/**
453
- .right-release/receipts/macos-*.json
512
+ ${uploadPath}
454
513
  if-no-files-found: error
455
514
  - name: Remove Apple signing material
456
515
  if: \${{ always() }}
@@ -461,6 +520,102 @@ ${pm.setup}
461
520
  `.trimEnd();
462
521
  }
463
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
+
464
619
  /**
465
620
  * Derive one project name from the target repo's package.json, falling back
466
621
  * to the repo directory's basename. Never throws.
@@ -507,6 +662,7 @@ export function buildRenderContext(manifest, repoPath) {
507
662
  CANDIDATE_MATRIX: candidateMatrix(manifest),
508
663
  WINDOWS_SIGNING_JOB: windowsSigningJob(manifest, pm, artifactPrefix),
509
664
  MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
665
+ RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
510
666
  };
511
667
 
512
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.6",
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,11 +90,15 @@ 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/);
96
97
  assert.match(content, /right-release build --platform win --skip-checks/);
97
98
  assert.match(content, /Artifact Signing client hash mismatch/);
99
+ assert.match(content, /RIGHT_GIT_SOURCE_REVISION=\$\(git rev-parse HEAD\)/);
100
+ assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
101
+ assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
98
102
  assert.doesNotMatch(content, /secrets\.|client-secret|private.key/i);
99
103
  });
100
104
 
@@ -113,15 +117,63 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
113
117
  buildRenderContext(manifest, "/repos/widgets"),
114
118
  );
115
119
  assert.match(content, /macos-sign:/);
120
+ assert.match(content, /macos-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) \}\}/);
116
121
  assert.match(content, /environment: release/);
117
122
  assert.match(content, /secrets\.APPLE_CERTIFICATE_BASE64/);
118
123
  assert.match(content, /temporary keychain/i);
119
124
  assert.match(content, /right-release build --platform mac --skip-checks/);
120
125
  assert.match(content, /secrets\.APPLE_API_KEY_BASE64/);
126
+ assert.match(content, /RIGHT_GIT_SOURCE_REVISION=%s\\n.*git rev-parse HEAD/);
127
+ assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
128
+ assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
121
129
  assert.match(content, /security delete-keychain/);
122
130
  assert.doesNotMatch(content, /pull_request_target/);
123
131
  });
124
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
+
125
177
  test("render determinism: same manifest renders byte-identical output every time", () => {
126
178
  const first = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
127
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}