@rightkit/git 0.2.23 → 0.2.25

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/manifest.mjs CHANGED
@@ -60,6 +60,7 @@ const TOP_LEVEL_KEYS = new Set([
60
60
  "gate",
61
61
  "rustCache",
62
62
  "candidate",
63
+ "ownership",
63
64
  ]);
64
65
  const GATE_PATH_RE = /^(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
65
66
  const RUST_WORKSPACE_PATH_RE = /^(?:\.|(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*)$/;
@@ -188,9 +189,17 @@ export function validateManifest(manifest) {
188
189
  errors.push("candidate must be an object");
189
190
  } else {
190
191
  for (const key of Object.keys(candidate)) {
191
- if (!["buildScript", "checkScript", "artifactRoot", "os", "targets", "signWindows", "signMacos", "releaseChain"].includes(key)) errors.push(`unknown candidate field: ${key}`);
192
+ if (!["buildScript", "checkScript", "admissionScript", "stageSummaryScript", "artifactRoot", "os", "targets", "signWindows", "signMacos", "releaseChain"].includes(key)) errors.push(`unknown candidate field: ${key}`);
192
193
  }
193
- for (const key of ["buildScript", "checkScript"]) {
194
+ // admissionScript & stageSummaryScript are required for EVERY release-candidate
195
+ // lane, not just signed/full-releaseChain ones: the tag-last workflow's
196
+ // `admission` job (dispatch-envelope validation) and the `candidate` job's
197
+ // stage-summary init/finalize steps are unconditional parts of the rendered
198
+ // template, independent of candidate.signWindows/signMacos/releaseChain. If
199
+ // either script were only required inside the optional releaseChain object,
200
+ // an unsigned release-candidate manifest (no releaseChain at all) would still
201
+ // render those unconditional steps with an unresolved script name.
202
+ for (const key of ["buildScript", "checkScript", "admissionScript", "stageSummaryScript"]) {
194
203
  if (typeof candidate[key] !== "string" || !PACKAGE_SCRIPT_RE.test(candidate[key])) {
195
204
  errors.push(`candidate.${key} must be one safe package script name`);
196
205
  }
@@ -252,19 +261,36 @@ export function validateManifest(manifest) {
252
261
  }
253
262
  if (candidate.releaseChain !== undefined) {
254
263
  const chain = candidate.releaseChain;
255
- const allowedChainKeys = ["windowsFinalizeScript", "macosFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable", "publishSecrets"];
264
+ // admissionScript & stageSummaryScript live on candidate directly (see
265
+ // above) — the full signed release chain adds evidenceVerificationScript
266
+ // (runs before publish, verifying aggregated same-run evidence) plus the
267
+ // finalize/qualify/publish scripts.
268
+ const allowedChainKeys = ["evidenceVerificationScript", "windowsFinalizeScript", "macosFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable", "publishSecrets", "environments"];
256
269
  if (chain === null || typeof chain !== "object" || Array.isArray(chain)) {
257
270
  errors.push("candidate.releaseChain must be an object");
258
271
  } else {
259
272
  for (const key of Object.keys(chain)) {
260
273
  if (!allowedChainKeys.includes(key)) errors.push(`unknown candidate.releaseChain field: ${key}`);
261
274
  }
262
- for (const key of ["windowsFinalizeScript", "installedQualificationScript", "publishScript"]) {
275
+ for (const key of ["evidenceVerificationScript", "windowsFinalizeScript", "installedQualificationScript", "publishScript"]) {
263
276
  if (typeof chain[key] !== "string" || !PACKAGE_SCRIPT_RE.test(chain[key])) {
264
277
  errors.push(`candidate.releaseChain.${key} must be one safe package script name`);
265
278
  }
266
279
  }
267
280
  if (chain.macosFinalizeScript !== undefined && (typeof chain.macosFinalizeScript !== "string" || !PACKAGE_SCRIPT_RE.test(chain.macosFinalizeScript))) {
281
+ if (chain.environments !== undefined) {
282
+ const environments = chain.environments;
283
+ if (environments === null || typeof environments !== "object" || Array.isArray(environments)) {
284
+ errors.push("candidate.releaseChain.environments must be an object");
285
+ } else {
286
+ for (const key of Object.keys(environments)) {
287
+ if (!["signing", "qualification", "publication"].includes(key)) errors.push(`unknown candidate.releaseChain.environments field: ${key}`);
288
+ else if (typeof environments[key] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(environments[key])) {
289
+ errors.push(`candidate.releaseChain.environments.${key} must be one safe GitHub environment name`);
290
+ }
291
+ }
292
+ }
293
+ }
268
294
  errors.push("candidate.releaseChain.macosFinalizeScript must be one safe package script name");
269
295
  }
270
296
  if (typeof chain.qualificationRunnerVariable !== "string" || !GITHUB_VARIABLE_RE.test(chain.qualificationRunnerVariable)) {
@@ -286,6 +312,18 @@ export function validateManifest(manifest) {
286
312
  if (profile === "rust-hybrid" && lanes.includes("release-candidate") && candidate === undefined) {
287
313
  errors.push("rust-hybrid release-candidate lane requires candidate { buildScript, checkScript, artifactRoot }");
288
314
  }
315
+
316
+ if (manifest.ownership !== undefined) {
317
+ if (!manifest.ownership || typeof manifest.ownership !== "object" || Array.isArray(manifest.ownership)
318
+ || Object.keys(manifest.ownership).some((key) => key !== "roots")) {
319
+ errors.push("ownership must be an object containing only roots");
320
+ } else if (!Array.isArray(manifest.ownership.roots) || manifest.ownership.roots.length === 0
321
+ || manifest.ownership.roots.some((root) => typeof root !== "string" || (root !== "." && !/^(?!.*\.\.)(?!.*\\)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(root)) || root.startsWith("/"))) {
322
+ errors.push("ownership.roots must contain safe, non-empty repository-relative paths");
323
+ } else if (new Set(manifest.ownership.roots).size !== manifest.ownership.roots.length) {
324
+ errors.push("ownership.roots must not contain duplicates");
325
+ }
326
+ }
289
327
  if (profile !== "rust-hybrid" && candidate !== undefined) {
290
328
  errors.push("candidate is only valid for profile 'rust-hybrid'");
291
329
  }
@@ -452,6 +490,15 @@ const ARTIFACT_SIGNING_CLIENT_SHA256 = "74BD7D27E6CE1051409C38D9B46BC8DF0400ECD6
452
490
  const WINDOWS_SDK_BUILD_TOOLS_VERSION = "10.0.26100.4188";
453
491
  const WINDOWS_SDK_BUILD_TOOLS_SHA256 = "180DEB372659029864C10A0C04787833234D64AACD1D2C0661D2C00295D8E022";
454
492
 
493
+ // Signing and publication credentials must be reachable behind distinct
494
+ // protected environments, so a job that may sign cannot also publish. Products
495
+ // opt in per environment; absent configuration keeps the single `release`
496
+ // environment they use today, so enabling the split cannot strand secrets.
497
+ function releaseEnvironment(manifest, role) {
498
+ const configured = manifest?.candidate?.releaseChain?.environments?.[role];
499
+ return typeof configured === "string" && configured.length > 0 ? configured : "release";
500
+ }
501
+
455
502
  function windowsSigningJob(manifest, pm, artifactPrefix) {
456
503
  if (manifest.candidate?.signWindows !== true) return "";
457
504
  const nodeVersion = String(manifest.matrix.node[0]);
@@ -462,7 +509,7 @@ function windowsSigningJob(manifest, pm, artifactPrefix) {
462
509
  const finalizeEnvironment = releaseChain
463
510
  ? ` RIGHT_GIT_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
464
511
  RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/finalized-windows
465
- RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`
512
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}`
466
513
  : ` LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-windows-candidate
467
514
  LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`;
468
515
  const uploadPath = releaseChain
@@ -470,9 +517,9 @@ function windowsSigningJob(manifest, pm, artifactPrefix) {
470
517
  : " dist/releases/windows/**\n .right-release/receipts/windows-*.json";
471
518
  return `
472
519
  windows-sign:
473
- needs: [candidate]
474
- if: \${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.signed_qualification == true) }}
475
- environment: release
520
+ needs: [admission, candidate]
521
+ if: \${{ needs.admission.outputs.signed_qualification == 'true' }}
522
+ environment: ${releaseEnvironment(manifest, "signing")}
476
523
  strategy:
477
524
  fail-fast: false
478
525
  matrix:
@@ -483,9 +530,10 @@ ${signingMatrix(manifest, "windows")}
483
530
  id-token: write
484
531
  steps:
485
532
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
486
- - name: Resolve checked-out source revision
487
- shell: pwsh
488
- run: '"RIGHT_GIT_SOURCE_REVISION=$(git rev-parse HEAD)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append'
533
+ with:
534
+ ref: \${{ needs.admission.outputs.source_revision }}
535
+ fetch-depth: 0
536
+ persist-credentials: false
489
537
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
490
538
  with:
491
539
  node-version: ${nodeVersion}
@@ -495,10 +543,24 @@ ${pm.setup}
495
543
  with:
496
544
  targets: \${{ matrix.targetTriple }}
497
545
  - run: ${pm.installCommand}
546
+ - name: Initialize Windows signing stage summary
547
+ run: ${pm.runPrefix} ${manifest.candidate.stageSummaryScript}
548
+ env:
549
+ RIGHT_GIT_STAGE_ACTION: init
550
+ RIGHT_GIT_STAGE_ROOT: \${{ runner.temp }}/right-git-stage-summary
551
+ RIGHT_GIT_STAGE_EVIDENCE_ROOT: \${{ runner.temp }}/finalized-windows
552
+ RIGHT_GIT_STAGE: windows-sign
553
+ RIGHT_GIT_STAGE_PRODUCER: windows-sign-\${{ matrix.architecture }}
554
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
555
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
556
+ RIGHT_GIT_RELEASE_PLATFORM: \${{ matrix.platform }}
557
+ RIGHT_GIT_RELEASE_ARCHITECTURE: \${{ matrix.architecture }}
558
+ RIGHT_GIT_RUN_ID: \${{ github.run_id }}
559
+ RIGHT_GIT_RUN_ATTEMPT: \${{ github.run_attempt }}
498
560
  - name: Download exact unsigned Windows candidate
499
561
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
500
562
  with:
501
- name: ${artifactPrefix}-unsigned-\${{ matrix.platform }}-\${{ matrix.architecture }}-${nodeVersion}
563
+ name: ${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-candidate-\${{ matrix.platform }}-\${{ matrix.architecture }}-\${{ needs.admission.outputs.version }}-\${{ needs.admission.outputs.source_revision }}
502
564
  path: \${{ runner.temp }}/unsigned-windows-candidate
503
565
  - name: Authenticate to Azure with GitHub OIDC
504
566
  uses: azure/login@${AZURE_LOGIN_SHA} # v3
@@ -531,13 +593,38 @@ ${finalizeEnvironment}
531
593
  AZURE_ARTIFACT_SIGNING_ENDPOINT: \${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }}
532
594
  AZURE_ARTIFACT_SIGNING_ACCOUNT: \${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT }}
533
595
  AZURE_ARTIFACT_SIGNING_PROFILE: \${{ vars.AZURE_ARTIFACT_SIGNING_PROFILE }}
596
+ - name: Finalize Windows signing stage summary
597
+ if: \${{ always() }}
598
+ run: ${pm.runPrefix} ${manifest.candidate.stageSummaryScript}
599
+ env:
600
+ RIGHT_GIT_STAGE_ACTION: finalize
601
+ RIGHT_GIT_STAGE_ROOT: \${{ runner.temp }}/right-git-stage-summary
602
+ RIGHT_GIT_STAGE_EVIDENCE_ROOT: \${{ runner.temp }}/finalized-windows
603
+ RIGHT_GIT_STAGE: windows-sign
604
+ RIGHT_GIT_STAGE_PRODUCER: windows-sign-\${{ matrix.architecture }}
605
+ RIGHT_GIT_STAGE_STATUS: \${{ job.status == 'success' && 'SUCCEEDED' || 'FAILED' }}
606
+ RIGHT_GIT_STAGE_EXIT_CODE: \${{ job.status == 'success' && '0' || '1' }}
607
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
608
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
609
+ RIGHT_GIT_RELEASE_PLATFORM: \${{ matrix.platform }}
610
+ RIGHT_GIT_RELEASE_ARCHITECTURE: \${{ matrix.architecture }}
611
+ RIGHT_GIT_RUN_ID: \${{ github.run_id }}
612
+ RIGHT_GIT_RUN_ATTEMPT: \${{ github.run_attempt }}
534
613
  - name: Upload signed Windows candidate
614
+ if: \${{ always() }}
535
615
  uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
536
616
  with:
537
- name: ${artifactPrefix}-signed-windows-\${{ matrix.architecture }}-${nodeVersion}
617
+ name: ${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-signed-windows-\${{ matrix.architecture }}-\${{ needs.admission.outputs.version }}-\${{ needs.admission.outputs.source_revision }}
538
618
  path: |
539
619
  ${uploadPath}
540
620
  if-no-files-found: error
621
+ - name: Upload Windows signing stage summary
622
+ if: \${{ always() }}
623
+ uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
624
+ with:
625
+ name: ${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-summary-windows-sign-\${{ matrix.architecture }}-\${{ needs.admission.outputs.version }}-\${{ needs.admission.outputs.source_revision }}
626
+ path: \${{ runner.temp }}/right-git-stage-summary/stage-summary.json
627
+ if-no-files-found: error
541
628
  `.trimEnd();
542
629
  }
543
630
 
@@ -551,7 +638,7 @@ function macosSigningJob(manifest, pm, artifactPrefix) {
551
638
  const finalizeEnvironment = releaseChain
552
639
  ? ` RIGHT_GIT_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
553
640
  RIGHT_GIT_FINALIZED_MACOS_ROOT: \${{ runner.temp }}/finalized-macos
554
- RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`
641
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}`
555
642
  : ` LEGION_UNSIGNED_CANDIDATE_ROOT: \${{ runner.temp }}/unsigned-macos-candidate
556
643
  LEGION_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}`;
557
644
  const uploadPath = releaseChain
@@ -559,9 +646,9 @@ function macosSigningJob(manifest, pm, artifactPrefix) {
559
646
  : " dist/releases/mac/**\n .right-release/receipts/macos-*.json";
560
647
  return `
561
648
  macos-sign:
562
- needs: [candidate]
563
- if: \${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.signed_qualification == true) }}
564
- environment: release
649
+ needs: [admission, candidate]
650
+ if: \${{ needs.admission.outputs.signed_qualification == 'true' }}
651
+ environment: ${releaseEnvironment(manifest, "signing")}
565
652
  strategy:
566
653
  fail-fast: false
567
654
  matrix:
@@ -571,9 +658,10 @@ ${signingMatrix(manifest, "macos")}
571
658
  contents: read
572
659
  steps:
573
660
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
574
- - name: Resolve checked-out source revision
575
- shell: bash
576
- run: printf 'RIGHT_GIT_SOURCE_REVISION=%s\\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV"
661
+ with:
662
+ ref: \${{ needs.admission.outputs.source_revision }}
663
+ fetch-depth: 0
664
+ persist-credentials: false
577
665
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
578
666
  with:
579
667
  node-version: ${nodeVersion}
@@ -583,10 +671,24 @@ ${pm.setup}
583
671
  with:
584
672
  targets: \${{ matrix.targetTriple }}
585
673
  - run: ${pm.installCommand}
674
+ - name: Initialize macOS signing stage summary
675
+ run: ${pm.runPrefix} ${manifest.candidate.stageSummaryScript}
676
+ env:
677
+ RIGHT_GIT_STAGE_ACTION: init
678
+ RIGHT_GIT_STAGE_ROOT: \${{ runner.temp }}/right-git-stage-summary
679
+ RIGHT_GIT_STAGE_EVIDENCE_ROOT: \${{ runner.temp }}/finalized-macos
680
+ RIGHT_GIT_STAGE: macos-sign
681
+ RIGHT_GIT_STAGE_PRODUCER: macos-sign-\${{ matrix.architecture }}
682
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
683
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
684
+ RIGHT_GIT_RELEASE_PLATFORM: \${{ matrix.platform }}
685
+ RIGHT_GIT_RELEASE_ARCHITECTURE: \${{ matrix.architecture }}
686
+ RIGHT_GIT_RUN_ID: \${{ github.run_id }}
687
+ RIGHT_GIT_RUN_ATTEMPT: \${{ github.run_attempt }}
586
688
  - name: Download exact unsigned macOS candidate
587
689
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
588
690
  with:
589
- name: ${artifactPrefix}-unsigned-\${{ matrix.platform }}-\${{ matrix.architecture }}-${nodeVersion}
691
+ name: ${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-candidate-\${{ matrix.platform }}-\${{ matrix.architecture }}-\${{ needs.admission.outputs.version }}-\${{ needs.admission.outputs.source_revision }}
590
692
  path: \${{ runner.temp }}/unsigned-macos-candidate
591
693
  - name: Install Apple signing identity in temporary keychain
592
694
  shell: bash
@@ -618,13 +720,38 @@ ${finalizeEnvironment}
618
720
  APPLE_DEVELOPER_ID: \${{ vars.APPLE_DEVELOPER_ID }}
619
721
  APPLE_API_KEY: \${{ secrets.APPLE_API_KEY }}
620
722
  APPLE_API_ISSUER: \${{ secrets.APPLE_API_ISSUER }}
723
+ - name: Finalize macOS signing stage summary
724
+ if: \${{ always() }}
725
+ run: ${pm.runPrefix} ${manifest.candidate.stageSummaryScript}
726
+ env:
727
+ RIGHT_GIT_STAGE_ACTION: finalize
728
+ RIGHT_GIT_STAGE_ROOT: \${{ runner.temp }}/right-git-stage-summary
729
+ RIGHT_GIT_STAGE_EVIDENCE_ROOT: \${{ runner.temp }}/finalized-macos
730
+ RIGHT_GIT_STAGE: macos-sign
731
+ RIGHT_GIT_STAGE_PRODUCER: macos-sign-\${{ matrix.architecture }}
732
+ RIGHT_GIT_STAGE_STATUS: \${{ job.status == 'success' && 'SUCCEEDED' || 'FAILED' }}
733
+ RIGHT_GIT_STAGE_EXIT_CODE: \${{ job.status == 'success' && '0' || '1' }}
734
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
735
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
736
+ RIGHT_GIT_RELEASE_PLATFORM: \${{ matrix.platform }}
737
+ RIGHT_GIT_RELEASE_ARCHITECTURE: \${{ matrix.architecture }}
738
+ RIGHT_GIT_RUN_ID: \${{ github.run_id }}
739
+ RIGHT_GIT_RUN_ATTEMPT: \${{ github.run_attempt }}
621
740
  - name: Upload signed & notarized macOS candidate
741
+ if: \${{ always() }}
622
742
  uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
623
743
  with:
624
- name: ${artifactPrefix}-signed-macos-\${{ matrix.architecture }}-${nodeVersion}
744
+ name: ${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-signed-macos-\${{ matrix.architecture }}-\${{ needs.admission.outputs.version }}-\${{ needs.admission.outputs.source_revision }}
625
745
  path: |
626
746
  ${uploadPath}
627
747
  if-no-files-found: error
748
+ - name: Upload macOS signing stage summary
749
+ if: \${{ always() }}
750
+ uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
751
+ with:
752
+ name: ${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-summary-macos-sign-\${{ matrix.architecture }}-\${{ needs.admission.outputs.version }}-\${{ needs.admission.outputs.source_revision }}
753
+ path: \${{ runner.temp }}/right-git-stage-summary/stage-summary.json
754
+ if-no-files-found: error
628
755
  - name: Remove Apple signing material
629
756
  if: \${{ always() }}
630
757
  shell: bash
@@ -641,30 +768,72 @@ function releaseChainJobs(manifest, pm, artifactPrefix) {
641
768
  const windowsRunner = windowsTarget.os;
642
769
  const windowsArchitecture = windowsTarget.architecture;
643
770
  const macosTarget = candidateTargets(manifest.candidate).find((target) => target.platform === "macos");
644
- const nodeVersion = String(manifest.matrix.node[0]);
645
- const macosDependency = manifest.candidate.signMacos === true ? ", macos-sign" : "";
646
- const macosSuccess = manifest.candidate.signMacos === true ? " && needs.macos-sign.result == 'success'" : "";
647
- const macosDownload = manifest.candidate.signMacos === true
771
+ const signMacos = manifest.candidate.signMacos === true;
772
+ const macosDependency = signMacos ? ", macos-sign" : "";
773
+ const macosSuccess = signMacos ? " && needs.macos-sign.result == 'success'" : "";
774
+ // Run- and identity-bound: matches exactly what windows-sign/macos-sign
775
+ // uploaded (see windowsSigningJob/macosSigningJob) — no matrix context is
776
+ // available in these non-matrixed jobs, so the platform architecture is a
777
+ // literal derived from the manifest's single windows/macos target.
778
+ const VERSION = "${{ needs.admission.outputs.version }}";
779
+ const SOURCE_REVISION = "${{ needs.admission.outputs.source_revision }}";
780
+ const windowsSignedArtifact = `${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-signed-windows-${windowsArchitecture}-${VERSION}-${SOURCE_REVISION}`;
781
+ const windowsSummaryArtifact = `${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-summary-windows-sign-${windowsArchitecture}-${VERSION}-${SOURCE_REVISION}`;
782
+ const macosSignedArtifact = signMacos ? `${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-signed-macos-${macosTarget.architecture}-${VERSION}-${SOURCE_REVISION}` : "";
783
+ const macosSummaryArtifact = signMacos ? `${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-summary-macos-sign-${macosTarget.architecture}-${VERSION}-${SOURCE_REVISION}` : "";
784
+ // Candidate stage summaries are per (platform, architecture) — see the
785
+ // `Upload candidate stage summary` step in the template. releaseChain
786
+ // requires at most one target per signing platform (validateManifest), so
787
+ // exactly one candidate-windows summary and at most one candidate-macos
788
+ // summary exist for this manifest.
789
+ const candidateWindowsSummaryArtifact = `${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-summary-candidate-windows-${windowsArchitecture}-${VERSION}-${SOURCE_REVISION}`;
790
+ const candidateMacosSummaryArtifact = signMacos ? `${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-summary-candidate-macos-${macosTarget.architecture}-${VERSION}-${SOURCE_REVISION}` : "";
791
+ const qualificationArtifact = `${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-installed-qualification-${VERSION}-${SOURCE_REVISION}`;
792
+ const qualificationSummaryArtifact = `${artifactPrefix}-\${{ github.run_id }}-\${{ github.run_attempt }}-summary-installed-qualification-${VERSION}-${SOURCE_REVISION}`;
793
+ const macosDownload = signMacos
648
794
  ? ` - name: Download same-run signed & notarized macOS release
649
795
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
650
796
  with:
651
- name: ${artifactPrefix}-signed-macos-${macosTarget.architecture}-${nodeVersion}
797
+ name: ${macosSignedArtifact}
652
798
  path: \${{ runner.temp }}/right-git-release/macos`
653
799
  : "";
800
+ const macosSummaryDownload = signMacos
801
+ ? ` - name: Download same-run macOS signing stage summary
802
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
803
+ with:
804
+ name: ${macosSummaryArtifact}
805
+ path: \${{ runner.temp }}/right-git-release/stage-summaries/macos-sign`
806
+ : "";
807
+ const candidateMacosSummaryDownload = signMacos
808
+ ? ` - name: Download same-run candidate (macOS) stage summary
809
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
810
+ with:
811
+ name: ${candidateMacosSummaryArtifact}
812
+ path: \${{ runner.temp }}/right-git-release/stage-summaries/candidate-macos`
813
+ : "";
814
+ const finalizedMacosEnv = signMacos ? `\n RIGHT_GIT_FINALIZED_MACOS_ROOT: \${{ runner.temp }}/right-git-release/macos` : "";
815
+ // Evidence must be bound to this exact run — not merely this
816
+ // version+source_revision — so a stale artifact from a different run
817
+ // cannot satisfy verification. Every job/step from installed-qualification
818
+ // onward carries RIGHT_GIT_RUN_ID/RIGHT_GIT_RUN_ATTEMPT alongside version
819
+ // and source_revision.
820
+ const runIdentityEnv = ` RIGHT_GIT_RUN_ID: \${{ github.run_id }}
821
+ RIGHT_GIT_RUN_ATTEMPT: \${{ github.run_attempt }}`;
654
822
  const publishSecretEnvironment = (chain.publishSecrets ?? [])
655
823
  .map((name) => ` ${name}: \${{ secrets.${name} }}`)
656
824
  .join("\n");
657
825
  return `
658
826
  installed-qualification:
659
- needs: [candidate, windows-sign]
660
- if: \${{ always() && (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.signed_qualification == true)) && needs.candidate.result == 'success' }}
661
- environment: release
827
+ needs: [admission, candidate, windows-sign]
828
+ if: \${{ always() && needs.admission.outputs.signed_qualification == 'true' && needs.candidate.result == 'success' }}
829
+ environment: ${releaseEnvironment(manifest, "qualification")}
662
830
  runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
663
831
  steps:
664
832
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
665
- - name: Resolve checked-out source revision
666
- shell: pwsh
667
- run: '"RIGHT_GIT_SOURCE_REVISION=$(git rev-parse HEAD)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append'
833
+ with:
834
+ ref: \${{ needs.admission.outputs.source_revision }}
835
+ fetch-depth: 0
836
+ persist-credentials: false
668
837
  - name: Fail closed when protected finalization is absent
669
838
  if: \${{ needs.windows-sign.result != 'success' }}
670
839
  shell: pwsh
@@ -673,64 +842,128 @@ function releaseChainJobs(manifest, pm, artifactPrefix) {
673
842
  - name: Download same-run finalized Windows release
674
843
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
675
844
  with:
676
- name: ${artifactPrefix}-signed-windows-${windowsArchitecture}-${nodeVersion}
845
+ name: ${windowsSignedArtifact}
677
846
  path: \${{ runner.temp }}/right-git-release/windows
678
847
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
679
848
  with:
680
- node-version: ${nodeVersion}
849
+ node-version: ${String(manifest.matrix.node[0])}
681
850
  package-manager-cache: false
682
851
  ${pm.setup}
683
852
  - run: ${pm.installCommand}
853
+ - name: Initialize installed-qualification stage summary
854
+ run: ${pm.runPrefix} ${manifest.candidate.stageSummaryScript}
855
+ env:
856
+ RIGHT_GIT_STAGE_ACTION: init
857
+ RIGHT_GIT_STAGE_ROOT: \${{ runner.temp }}/right-git-stage-summary
858
+ RIGHT_GIT_STAGE_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
859
+ RIGHT_GIT_STAGE: installed-qualification
860
+ RIGHT_GIT_STAGE_PRODUCER: installed-qualification
861
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
862
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
863
+ ${runIdentityEnv}
684
864
  - name: Run installed-artifact qualification
685
865
  run: ${pm.runPrefix} ${chain.installedQualificationScript}
686
866
  env:
687
867
  GH_TOKEN: \${{ github.token }}
688
868
  RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/right-git-release/windows
689
869
  RIGHT_GIT_QUALIFICATION_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
690
- RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
870
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
871
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
872
+ - name: Finalize installed-qualification stage summary
873
+ if: \${{ always() }}
874
+ run: ${pm.runPrefix} ${manifest.candidate.stageSummaryScript}
875
+ env:
876
+ RIGHT_GIT_STAGE_ACTION: finalize
877
+ RIGHT_GIT_STAGE_ROOT: \${{ runner.temp }}/right-git-stage-summary
878
+ RIGHT_GIT_STAGE_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
879
+ RIGHT_GIT_STAGE: installed-qualification
880
+ RIGHT_GIT_STAGE_PRODUCER: installed-qualification
881
+ RIGHT_GIT_STAGE_STATUS: \${{ job.status == 'success' && 'SUCCEEDED' || 'FAILED' }}
882
+ RIGHT_GIT_STAGE_EXIT_CODE: \${{ job.status == 'success' && '0' || '1' }}
883
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
884
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
885
+ ${runIdentityEnv}
691
886
  - name: Upload installed qualification evidence
887
+ if: \${{ always() }}
692
888
  uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
693
889
  with:
694
- name: ${artifactPrefix}-installed-qualification-${nodeVersion}
890
+ name: ${qualificationArtifact}
695
891
  path: \${{ runner.temp }}/right-git-release/qualification/**
696
892
  if-no-files-found: error
893
+ - name: Upload installed-qualification stage summary
894
+ if: \${{ always() }}
895
+ uses: actions/upload-artifact@${UPLOAD_ARTIFACT_SHA} # v4
896
+ with:
897
+ name: ${qualificationSummaryArtifact}
898
+ path: \${{ runner.temp }}/right-git-stage-summary/stage-summary.json
899
+ if-no-files-found: error
697
900
  publish:
698
- needs: [installed-qualification${macosDependency}]
699
- if: \${{ startsWith(github.ref, 'refs/tags/v') && needs.installed-qualification.result == 'success'${macosSuccess} }}
700
- environment: release
901
+ needs: [admission, installed-qualification${macosDependency}]
902
+ if: \${{ needs.admission.outputs.publish == 'true' && needs.installed-qualification.result == 'success'${macosSuccess} }}
903
+ environment: ${releaseEnvironment(manifest, "publication")}
701
904
  runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
702
905
  permissions:
703
906
  contents: write
704
907
  steps:
705
908
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
706
- - name: Resolve checked-out source revision
707
- shell: pwsh
708
- run: '"RIGHT_GIT_SOURCE_REVISION=$(git rev-parse HEAD)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append'
909
+ with:
910
+ ref: \${{ needs.admission.outputs.source_revision }}
911
+ fetch-depth: 0
912
+ persist-credentials: false
709
913
  - name: Download same-run finalized Windows release
710
914
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
711
915
  with:
712
- name: ${artifactPrefix}-signed-windows-${windowsArchitecture}-${nodeVersion}
916
+ name: ${windowsSignedArtifact}
713
917
  path: \${{ runner.temp }}/right-git-release/windows
714
918
  ${macosDownload}
715
919
  - name: Download same-run installed qualification evidence
716
920
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
717
921
  with:
718
- name: ${artifactPrefix}-installed-qualification-${nodeVersion}
922
+ name: ${qualificationArtifact}
719
923
  path: \${{ runner.temp }}/right-git-release/qualification
924
+ - name: Download same-run candidate (Windows) stage summary
925
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
926
+ with:
927
+ name: ${candidateWindowsSummaryArtifact}
928
+ path: \${{ runner.temp }}/right-git-release/stage-summaries/candidate-windows
929
+ ${candidateMacosSummaryDownload}
930
+ - name: Download same-run Windows signing stage summary
931
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
932
+ with:
933
+ name: ${windowsSummaryArtifact}
934
+ path: \${{ runner.temp }}/right-git-release/stage-summaries/windows-sign
935
+ ${macosSummaryDownload}
936
+ - name: Download same-run installed-qualification stage summary
937
+ uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
938
+ with:
939
+ name: ${qualificationSummaryArtifact}
940
+ path: \${{ runner.temp }}/right-git-release/stage-summaries/installed-qualification
720
941
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
721
942
  with:
722
- node-version: ${nodeVersion}
943
+ node-version: ${String(manifest.matrix.node[0])}
723
944
  package-manager-cache: false
724
945
  ${pm.setup}
725
946
  - run: ${pm.installCommand}
947
+ - name: Verify aggregated release evidence
948
+ run: ${pm.runPrefix} ${chain.evidenceVerificationScript}
949
+ env:
950
+ GH_TOKEN: \${{ github.token }}
951
+ RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/right-git-release/windows${finalizedMacosEnv}
952
+ RIGHT_GIT_QUALIFICATION_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
953
+ RIGHT_GIT_STAGE_SUMMARY_ROOT: \${{ runner.temp }}/right-git-release/stage-summaries
954
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
955
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
956
+ ${runIdentityEnv}
726
957
  - name: Publish qualified signed release
727
958
  run: ${pm.runPrefix} ${chain.publishScript}
728
959
  env:
729
960
  GH_TOKEN: \${{ github.token }}
730
- RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/right-git-release/windows
731
- RIGHT_GIT_FINALIZED_MACOS_ROOT: \${{ runner.temp }}/right-git-release/macos
961
+ RIGHT_GIT_FINALIZED_WINDOWS_ROOT: \${{ runner.temp }}/right-git-release/windows${finalizedMacosEnv}
732
962
  RIGHT_GIT_QUALIFICATION_EVIDENCE_ROOT: \${{ runner.temp }}/right-git-release/qualification
733
- RIGHT_GIT_SOURCE_REVISION: \${{ env.RIGHT_GIT_SOURCE_REVISION }}
963
+ RIGHT_GIT_STAGE_SUMMARY_ROOT: \${{ runner.temp }}/right-git-release/stage-summaries
964
+ RIGHT_GIT_RELEASE_VERSION: \${{ needs.admission.outputs.version }}
965
+ RIGHT_GIT_SOURCE_REVISION: \${{ needs.admission.outputs.source_revision }}
966
+ ${runIdentityEnv}
734
967
  ${publishSecretEnvironment}
735
968
  `.trimEnd();
736
969
  }
@@ -756,6 +989,15 @@ export function deriveProjectName(repoPath) {
756
989
  * Build the flat placeholder -> value map used by render.mjs, from a
757
990
  * validated manifest plus the target repo's derived project name.
758
991
  */
992
+ /** Every managed public workflow runs ownership scanning; repository metadata cannot opt it out. */
993
+ export function ownershipStep(manifest) {
994
+ const { name } = parsePackageManager(manifest.packageManager);
995
+ const command = name === "pnpm" ? "pnpm exec right-release ownership --app ."
996
+ : name === "npm" ? "npm exec -- right-release ownership --app ."
997
+ : "yarn exec right-release ownership --app .";
998
+ return `\n - name: RightKit ownership (protected registry roots)\n shell: bash\n run: test "$(git rev-parse HEAD)" = "\${{ github.event_name == 'workflow_dispatch' && inputs.source_revision || github.sha }}" && ${command}`;
999
+ }
1000
+
759
1001
  export function buildRenderContext(manifest, repoPath) {
760
1002
  const pm = parsePackageManager(manifest.packageManager);
761
1003
  const projectName = deriveProjectName(repoPath);
@@ -777,19 +1019,40 @@ export function buildRenderContext(manifest, repoPath) {
777
1019
  PACKAGE_MANAGER_SETUP: pm.setup,
778
1020
  ARTIFACT_PREFIX: artifactPrefix,
779
1021
  TEMPLATE_VERSION: manifest.templateVersion,
780
- GATE_PATH: manifest.gate?.path,
781
1022
  RUST_CACHE_WORKSPACES: rustWorkspaces.map((workspace) => ` ${workspace} -> target`).join("\n"),
782
1023
  RUST_REQUIRED_FOR_GATE: manifest.rustCache?.requiredForGate === true ? "true" : "false",
783
- CANDIDATE_BUILD_SCRIPT: manifest.candidate?.buildScript,
784
- CANDIDATE_CHECK_SCRIPT: manifest.candidate?.checkScript,
785
- CANDIDATE_ARTIFACT_ROOT: manifest.candidate?.artifactRoot,
786
- CANDIDATE_OS_MATRIX: yamlFlowStringList(manifest.candidate?.os ?? manifest.matrix.os),
787
- CANDIDATE_MATRIX: candidateMatrix(manifest),
788
1024
  WINDOWS_SIGNING_JOB: windowsSigningJob(manifest, pm, artifactPrefix),
789
1025
  MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
790
1026
  RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
1027
+ OWNERSHIP_STEP: ownershipStep(manifest),
791
1028
  };
792
1029
 
1030
+ // Every key below is populated ONLY when its source manifest section is
1031
+ // actually present — mirroring the publish.npm/homebrew/winget pattern.
1032
+ // This keeps the fail-closed check just below honest: a key that is
1033
+ // legitimately inapplicable to this manifest (e.g. CANDIDATE_* for a
1034
+ // non-rust-hybrid manifest, GATE_PATH for a non-rust-hybrid profile) is
1035
+ // simply ABSENT from the context rather than present-with-undefined, so
1036
+ // render.mjs's own `Object.hasOwn` check does the right thing if a
1037
+ // template ever references a token this manifest didn't opt into.
1038
+ if (manifest.gate) {
1039
+ context.GATE_PATH = manifest.gate.path;
1040
+ }
1041
+ if (manifest.candidate) {
1042
+ context.CANDIDATE_BUILD_SCRIPT = manifest.candidate.buildScript;
1043
+ context.CANDIDATE_CHECK_SCRIPT = manifest.candidate.checkScript;
1044
+ context.CANDIDATE_ARTIFACT_ROOT = manifest.candidate.artifactRoot;
1045
+ context.CANDIDATE_OS_MATRIX = yamlFlowStringList(manifest.candidate.os ?? manifest.matrix.os);
1046
+ context.CANDIDATE_MATRIX = candidateMatrix(manifest);
1047
+ // admissionScript & stageSummaryScript are required candidate fields
1048
+ // (see validateManifest) whenever release-candidate is configured at
1049
+ // all — the admission job and the candidate job's stage-summary steps
1050
+ // are unconditional parts of the rendered template, independent of
1051
+ // signing/releaseChain.
1052
+ context.RELEASE_ADMISSION_SCRIPT = manifest.candidate.admissionScript;
1053
+ context.RELEASE_STAGE_SUMMARY_SCRIPT = manifest.candidate.stageSummaryScript;
1054
+ }
1055
+
793
1056
  if (manifest.publish?.npm) {
794
1057
  context.NPM_PROVENANCE_FLAG = manifest.publish.npm.provenance ? " --provenance" : "";
795
1058
  }
@@ -801,5 +1064,21 @@ export function buildRenderContext(manifest, repoPath) {
801
1064
  context.WINGET_PACKAGE_IDENTIFIER = manifest.publish.winget.packageIdentifier;
802
1065
  }
803
1066
 
1067
+ // F-undefined-script fix: render.mjs's `Object.hasOwn` check only proves a
1068
+ // key EXISTS in the context — it does not prove the key resolved to a real
1069
+ // value. Every field above that is sourced from an optional manifest path
1070
+ // (candidate.*, candidate.releaseChain.* via the job-builder functions,
1071
+ // gate.path) must resolve to a real string once its guarding `if` above
1072
+ // decided the section applies; a bug in one of those guards (or a
1073
+ // mis-nested manifest field) must fail the render loudly here rather than
1074
+ // silently emit a literal "undefined" into a workflow file that GitHub
1075
+ // Actions will then try to execute as a real step (e.g. `run: pnpm run
1076
+ // undefined`).
1077
+ for (const [key, value] of Object.entries(context)) {
1078
+ if (value === undefined || value === null) {
1079
+ throw new Error(`buildRenderContext: placeholder \${${key}} resolved to ${value} — check the manifest's candidate/releaseChain/gate/publish configuration`);
1080
+ }
1081
+ }
1082
+
804
1083
  return context;
805
1084
  }