@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/render.test.mjs CHANGED
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { renderTemplate, MANAGED_MARKER } from "./render.mjs";
7
- import { buildRenderContext, LANE_FILENAMES, KNOWN_LANES } from "./manifest.mjs";
7
+ import { buildRenderContext, LANE_FILENAMES, KNOWN_LANES, validateManifest } from "./manifest.mjs";
8
8
 
9
9
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
10
10
  const templatesDir = path.join(packageRoot, "templates");
@@ -73,6 +73,21 @@ test("rust-hybrid ci renders its pinned package manager and gate", () => {
73
73
  assert.doesNotMatch(content, /ubuntu-latest|macos-latest/);
74
74
  });
75
75
 
76
+ test("every generated workflow profile runs ownership scanning independent of optional repository roots", () => {
77
+ const roots = ["engine", "apps/client"];
78
+ const manifest = { ...FULL_MANIFEST, ownership: { roots } };
79
+ const rendered = renderAllLanes(manifest, "C:/repos/coderight");
80
+ for (const [lane, content] of Object.entries(rendered)) {
81
+ assert.match(content, /right-release ownership --app \./, lane);
82
+ assert.match(content, /protected registry roots/, lane);
83
+ }
84
+ const rust = renderTemplate(fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8"), buildRenderContext({ ...manifest, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } }, "C:/repos/coderight"));
85
+ assert.match(rust, /RightKit ownership \(protected registry roots\)/);
86
+ assert.match(rust, /pnpm exec right-release ownership --app \./);
87
+ const withoutRoots = renderAllLanes(FULL_MANIFEST, "C:/repos/widgets");
88
+ for (const [lane, content] of Object.entries(withoutRoots)) assert.match(content, /right-release ownership --app \./, lane);
89
+ });
90
+
76
91
  test("ordinary CI templates cancel superseded runs on the same ref", () => {
77
92
  const generic = renderAllLanes(FULL_MANIFEST, "/repos/widgets").ci;
78
93
  assert.match(generic, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}/);
@@ -101,7 +116,14 @@ test("rust-hybrid release candidate renders product scripts, artifact handoff, a
101
116
  lanes: ["ci", "release-candidate"],
102
117
  profile: "rust-hybrid",
103
118
  gate: { path: "scripts/gate.sh" },
104
- candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "widgets-unsigned", os: ["macos-latest", "windows-latest"] },
119
+ candidate: {
120
+ buildScript: "ci:candidate",
121
+ checkScript: "ci:candidate-check",
122
+ admissionScript: "release:admit",
123
+ stageSummaryScript: "release:stage-summary",
124
+ artifactRoot: "widgets-unsigned",
125
+ os: ["macos-latest", "windows-latest"],
126
+ },
105
127
  templateVersion: "1.2.0",
106
128
  };
107
129
  const content = renderTemplate(
@@ -110,6 +132,8 @@ test("rust-hybrid release candidate renders product scripts, artifact handoff, a
110
132
  );
111
133
  assert.match(content, /pnpm run ci:candidate/);
112
134
  assert.match(content, /pnpm run ci:candidate-check/);
135
+ assert.match(content, /pnpm run release:admit/);
136
+ assert.match(content, /pnpm run release:stage-summary/);
113
137
  assert.match(content, /Mozilla-Actions\/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba/);
114
138
  assert.match(content, /shared-key: widgets-rust/);
115
139
  assert.doesNotMatch(content, /bash \.\/scripts\/gate\.sh/);
@@ -122,6 +146,12 @@ test("rust-hybrid release candidate renders product scripts, artifact handoff, a
122
146
  assert.doesNotMatch(content, /rust-changes|RIGHT_GIT_RUST_CHANGED/);
123
147
  assert.doesNotMatch(content, /if: steps\.rust-changes/);
124
148
  assert.match(content, /uses: dtolnay\/rust-toolchain@[a-f0-9]{40}\n with:/);
149
+ // workflow_dispatch tag-last envelope: no windows-sign/macos-sign/release
150
+ // chain jobs render at all when signing is not configured.
151
+ assert.match(content, /^on:\n workflow_dispatch:/m);
152
+ assert.doesNotMatch(content, /^ {2}(?:windows-sign|macos-sign|installed-qualification|publish):\s*$/m);
153
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
154
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
125
155
  });
126
156
 
127
157
  test("rust-hybrid cache contract renders every declared Cargo workspace", () => {
@@ -151,7 +181,15 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
151
181
  lanes: ["ci", "release-candidate"],
152
182
  profile: "rust-hybrid",
153
183
  gate: { path: "scripts/gate.sh" },
154
- candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "widgets-unsigned", os: ["windows-2025"], signWindows: true },
184
+ candidate: {
185
+ buildScript: "ci:candidate",
186
+ checkScript: "ci:candidate-check",
187
+ admissionScript: "release:admit",
188
+ stageSummaryScript: "release:stage-summary",
189
+ artifactRoot: "widgets-unsigned",
190
+ os: ["windows-2025"],
191
+ signWindows: true,
192
+ },
155
193
  matrix: { os: ["ubuntu-24.04", "windows-2025"], node: ["22.23.2"] },
156
194
  templateVersion: "1.3.0",
157
195
  };
@@ -160,16 +198,26 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
160
198
  buildRenderContext(manifest, "/repos/widgets"),
161
199
  );
162
200
  assert.match(content, /environment: release/);
163
- assert.match(content, /windows-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) \|\| \(github\.event_name == 'workflow_dispatch' && inputs\.signed_qualification == true\) \}\}/);
201
+ // Tag-last contract: signing is gated on the admitted, same-run
202
+ // signed_qualification OUTPUT of the `admission` job — never on a ref/tag
203
+ // and never on the raw workflow_dispatch input directly.
204
+ assert.match(content, /windows-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
205
+ assert.doesNotMatch(content, /startsWith\(github\.ref, 'refs\/tags\/v'\)/);
164
206
  assert.match(content, /id-token: write/);
165
207
  assert.match(content, /azure\/login@[a-f0-9]{40}/);
166
208
  assert.match(content, /vars\.AZURE_CLIENT_ID/);
167
209
  assert.match(content, /right-release build --platform win --skip-checks/);
168
210
  assert.match(content, /Artifact Signing client hash mismatch/);
169
- assert.match(content, /RIGHT_GIT_SOURCE_REVISION=\$\(git rev-parse HEAD\)/);
211
+ // Every downstream job (admission, candidate, and windows-sign here) checks
212
+ // out the exact admitted revision by SHA, never by resolving HEAD after a
213
+ // ref-triggered checkout.
214
+ assert.match(content, /windows-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/);
215
+ assert.match(content, /git rev-parse HEAD.*inputs\.source_revision \|\| github\.sha/);
170
216
  assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
171
217
  assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
172
218
  assert.doesNotMatch(content, /secrets\.|client-secret|private.key/i);
219
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
220
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
173
221
  });
174
222
 
175
223
  test("rust-hybrid release candidate can opt into protected macOS signing & notarization", () => {
@@ -178,7 +226,15 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
178
226
  lanes: ["ci", "release-candidate"],
179
227
  profile: "rust-hybrid",
180
228
  gate: { path: "scripts/gate.sh" },
181
- candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "widgets-unsigned", os: ["macos-15"], signMacos: true },
229
+ candidate: {
230
+ buildScript: "ci:candidate",
231
+ checkScript: "ci:candidate-check",
232
+ admissionScript: "release:admit",
233
+ stageSummaryScript: "release:stage-summary",
234
+ artifactRoot: "widgets-unsigned",
235
+ os: ["macos-15"],
236
+ signMacos: true,
237
+ },
182
238
  matrix: { os: ["ubuntu-24.04", "macos-15"], node: ["22.23.2"] },
183
239
  templateVersion: "1.3.0",
184
240
  };
@@ -187,17 +243,21 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
187
243
  buildRenderContext(manifest, "/repos/widgets"),
188
244
  );
189
245
  assert.match(content, /macos-sign:/);
190
- assert.match(content, /macos-sign:\n needs: \[candidate\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) \|\| \(github\.event_name == 'workflow_dispatch' && inputs\.signed_qualification == true\) \}\}/);
246
+ assert.match(content, /macos-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
247
+ assert.doesNotMatch(content, /startsWith\(github\.ref, 'refs\/tags\/v'\)/);
191
248
  assert.match(content, /environment: release/);
192
249
  assert.match(content, /secrets\.APPLE_CERTIFICATE_BASE64/);
193
250
  assert.match(content, /temporary keychain/i);
194
251
  assert.match(content, /right-release build --platform mac --skip-checks/);
195
252
  assert.match(content, /secrets\.APPLE_API_KEY_BASE64/);
196
- assert.match(content, /RIGHT_GIT_SOURCE_REVISION=%s\\n.*git rev-parse HEAD/);
253
+ assert.match(content, /macos-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/);
254
+ assert.match(content, /git rev-parse HEAD.*inputs\.source_revision \|\| github\.sha/);
197
255
  assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
198
256
  assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
199
257
  assert.match(content, /security delete-keychain/);
200
258
  assert.doesNotMatch(content, /pull_request_target/);
259
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
260
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
201
261
  });
202
262
 
203
263
  test("rust-hybrid multi-architecture candidates bind every native target to exact artifacts and signing", () => {
@@ -210,6 +270,8 @@ test("rust-hybrid multi-architecture candidates bind every native target to exac
210
270
  candidate: {
211
271
  buildScript: "ci:candidate",
212
272
  checkScript: "ci:candidate-check",
273
+ admissionScript: "release:admit",
274
+ stageSummaryScript: "release:stage-summary",
213
275
  artifactRoot: "widgets-unsigned",
214
276
  os: ["windows-2025", "windows-11-arm", "macos-15", "macos-15-intel"],
215
277
  targets: [
@@ -235,10 +297,15 @@ test("rust-hybrid multi-architecture candidates bind every native target to exac
235
297
  assert.doesNotMatch(content, /bash \.\/scripts\/gate\.sh/);
236
298
  assert.match(content, /windows-sign:[\s\S]*matrix:[\s\S]*windows-11-arm/);
237
299
  assert.match(content, /macos-sign:[\s\S]*matrix:[\s\S]*macos-15-intel/);
238
- assert.match(content, /name: widgets-unsigned-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
239
- assert.match(content, /name: widgets-signed-windows-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
240
- assert.match(content, /name: widgets-signed-macos-\$\{\{ matrix\.architecture \}\}-22\.23\.2/);
241
- assert.equal((content.match(/RIGHT_GIT_RELEASE_ARCHITECTURE: \$\{\{ matrix\.architecture \}\}/g) ?? []).length, 4);
300
+ // Every artifact name is run- and identity-bound: <prefix>-<run_id>-<run_attempt>-<kind>-...-<version>-<source_revision>.
301
+ assert.match(content, /name: widgets-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}-candidate-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.architecture \}\}-\$\{\{ needs\.admission\.outputs\.version \}\}-\$\{\{ needs\.admission\.outputs\.source_revision \}\}/);
302
+ assert.match(content, /name: widgets-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}-signed-windows-\$\{\{ matrix\.architecture \}\}-\$\{\{ needs\.admission\.outputs\.version \}\}-\$\{\{ needs\.admission\.outputs\.source_revision \}\}/);
303
+ assert.match(content, /name: widgets-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}-signed-macos-\$\{\{ matrix\.architecture \}\}-\$\{\{ needs\.admission\.outputs\.version \}\}-\$\{\{ needs\.admission\.outputs\.source_revision \}\}/);
304
+ // Exact-count regression guard across the candidate job's build/check/
305
+ // stage-summary steps and the windows-sign/macos-sign stage-summary steps.
306
+ assert.equal((content.match(/RIGHT_GIT_RELEASE_ARCHITECTURE: \$\{\{ matrix\.architecture \}\}/g) ?? []).length, 10);
307
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
308
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
242
309
  });
243
310
 
244
311
  test("rust-hybrid release chain preserves signed candidate, installed qualification, then publish", () => {
@@ -250,11 +317,14 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
250
317
  candidate: {
251
318
  buildScript: "ci:candidate",
252
319
  checkScript: "ci:candidate-check",
320
+ admissionScript: "release:admit",
321
+ stageSummaryScript: "release:stage-summary",
253
322
  artifactRoot: "widgets-unsigned",
254
323
  os: ["windows-2025", "macos-15"],
255
324
  signWindows: true,
256
325
  signMacos: true,
257
326
  releaseChain: {
327
+ evidenceVerificationScript: "release:verify-evidence",
258
328
  windowsFinalizeScript: "release:finalize:win",
259
329
  macosFinalizeScript: "release:finalize:mac",
260
330
  installedQualificationScript: "release:qualify:installed",
@@ -272,26 +342,201 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
272
342
  );
273
343
  assert.match(content, /windows-sign:[\s\S]*pnpm run release:finalize:win/);
274
344
  assert.match(content, /macos-sign:[\s\S]*pnpm run release:finalize:mac/);
275
- assert.match(content, /signed_qualification:\n description: Run protected signing & installed qualification without publishing/);
276
- assert.match(content, /windows-sign:[\s\S]*inputs\.signed_qualification == true/);
277
- assert.match(content, /macos-sign:[\s\S]*inputs\.signed_qualification == true/);
278
- assert.match(content, /installed-qualification:\n needs: \[candidate, windows-sign\]/);
279
- assert.match(content, /installed-qualification:[\s\S]*inputs\.signed_qualification == true/);
345
+ // Tag-last contract: every gate reads the ADMITTED, same-run outputs of
346
+ // the `admission` job — never the raw workflow_dispatch input directly,
347
+ // and never a ref/tag.
348
+ assert.match(content, /signed_qualification:\n description: Run protected signing, installed qualification, & evidence verification/);
349
+ assert.doesNotMatch(content, /inputs\.signed_qualification == true/);
350
+ assert.doesNotMatch(content, /startsWith\(github\.ref, 'refs\/tags\/v'\)/);
351
+ assert.match(content, /windows-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
352
+ assert.match(content, /macos-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
353
+ assert.match(content, /installed-qualification:\n needs: \[admission, candidate, windows-sign\]/);
354
+ assert.match(content, /installed-qualification:[\s\S]*?if: \$\{\{ always\(\) && needs\.admission\.outputs\.signed_qualification == 'true' && needs\.candidate\.result == 'success' \}\}/);
280
355
  assert.match(content, /installed_qualification_failed/);
281
356
  assert.match(content, /shell: pwsh\n run: \|\n throw 'installed_qualification_failed \{ reason: prerequisites_absent, detail: "same-run Windows signing\/finalization did not succeed" \}'/);
282
357
  assert.doesNotMatch(content, /^ run: throw 'installed_qualification_failed .*detail: /m);
283
358
  assert.match(content, /pnpm run release:qualify:installed/);
284
359
  assert.match(content, /installed-qualification:[\s\S]*GH_TOKEN: \$\{\{ github\.token \}\}/);
285
- assert.match(content, /publish:\n needs: \[installed-qualification, macos-sign\]/);
286
- assert.match(content, /publish:[\s\S]*startsWith\(github\.ref, 'refs\/tags\/v'\)/);
287
- assert.doesNotMatch(content.slice(content.indexOf(" publish:")), /inputs\.signed_qualification/);
288
- assert.match(content, /needs\.macos-sign\.result == 'success'/);
360
+ assert.match(content, /publish:\n needs: \[admission, installed-qualification, macos-sign\]/);
361
+ assert.match(content, /publish:[\s\S]*?if: \$\{\{ needs\.admission\.outputs\.publish == 'true' && needs\.installed-qualification\.result == 'success' && needs\.macos-sign\.result == 'success' \}\}/);
362
+ const publishJobIndex = content.search(/^ {2}publish:\n {4}needs:/m);
363
+ assert.ok(publishJobIndex >= 0, "publish job must be present");
364
+ assert.doesNotMatch(content.slice(publishJobIndex), /inputs\.signed_qualification|inputs\.publish/);
289
365
  assert.match(content, /contents: write/);
366
+ // Publish is the sole creator of the tag/release: it must not require a
367
+ // pre-existing tag anywhere in its gate.
368
+ assert.doesNotMatch(content.slice(publishJobIndex), /refs\/tags/);
369
+ assert.match(content, /pnpm run release:verify-evidence/);
370
+ assert.match(content, /Verify aggregated release evidence[\s\S]*?pnpm run release:verify-evidence/);
290
371
  assert.match(content, /pnpm run release:publish:qualified/);
291
372
  assert.match(content, /CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}/);
292
- assert.equal((content.match(/RIGHT_GIT_SOURCE_REVISION=\$\(git rev-parse HEAD\)/g) ?? []).length, 3);
373
+ // Ownership scan binds to the source revision input before admission emits
374
+ // that same revision for every downstream job.
375
+ assert.match(content, /git rev-parse HEAD.*inputs\.source_revision \|\| github\.sha/);
293
376
  assert.doesNotMatch(content, /RIGHT_GIT_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
377
+ // candidate, windows-sign, macos-sign, installed-qualification and publish all
378
+ // check out the admitted revision with full history; admission checks out
379
+ // ${{ inputs.source_revision }} (it IS the admission job — there is no
380
+ // needs.admission yet). Full history is load-bearing, not cosmetic: see
381
+ // "every job checking out an admitted revision fetches full history".
382
+ assert.equal((content.match(/ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/g) ?? []).length, 5, "candidate, windows-sign, macos-sign, installed-qualification & publish should all pin the admitted revision with full history");
383
+ assert.doesNotMatch(content, /ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/, "no admitted-revision checkout may be shallow");
384
+ assert.match(content, /ref: \$\{\{ inputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/, "admission must check out the exact dispatch-supplied revision");
294
385
  assert.doesNotMatch(content.slice(0, content.indexOf(" windows-sign:")), /secrets\.|id-token: write|contents: write/);
386
+ // Downstream download-artifact names must match exactly what windows-sign/
387
+ // macos-sign uploaded (see the multi-architecture test) — this is the
388
+ // regression guard for the artifact-name mismatch bug.
389
+ const windowsSignedArtifact = "widgets-${{ github.run_id }}-${{ github.run_attempt }}-signed-windows-x86_64-${{ needs.admission.outputs.version }}-${{ needs.admission.outputs.source_revision }}";
390
+ const macosSignedArtifact = "widgets-${{ github.run_id }}-${{ github.run_attempt }}-signed-macos-arm64-${{ needs.admission.outputs.version }}-${{ needs.admission.outputs.source_revision }}";
391
+ assert.equal((content.match(new RegExp(windowsSignedArtifact.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).length, 2, "windows-sign upload + installed-qualification/publish download must share one artifact name");
392
+ assert.ok(content.includes(macosSignedArtifact), "publish must download the exact macOS artifact macos-sign uploaded");
393
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
394
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
395
+ });
396
+
397
+ // Part E regression guard: every `run: <pm> run <script>` line in a
398
+ // rendered full release-chain workflow must name a script the manifest
399
+ // actually declares. This is the general form of the `pnpm run undefined`
400
+ // class of bug — it would also catch a typo'd or stale script reference
401
+ // even when it resolves to a real (but wrong) string, which the fail-closed
402
+ // undefined/null check in buildRenderContext cannot.
403
+ test("every 'run <script>' in a full release-chain workflow names a script the manifest declares", () => {
404
+ const manifest = {
405
+ ...FULL_MANIFEST,
406
+ lanes: ["ci", "release-candidate"],
407
+ profile: "rust-hybrid",
408
+ gate: { path: "scripts/gate.sh" },
409
+ candidate: {
410
+ buildScript: "ci:candidate",
411
+ checkScript: "ci:candidate-check",
412
+ admissionScript: "release:admit",
413
+ stageSummaryScript: "release:stage-summary",
414
+ artifactRoot: "widgets-unsigned",
415
+ os: ["windows-2025", "macos-15"],
416
+ signWindows: true,
417
+ signMacos: true,
418
+ releaseChain: {
419
+ evidenceVerificationScript: "release:verify-evidence",
420
+ windowsFinalizeScript: "release:finalize:win",
421
+ macosFinalizeScript: "release:finalize:mac",
422
+ installedQualificationScript: "release:qualify:installed",
423
+ publishScript: "release:publish:qualified",
424
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
425
+ publishSecrets: ["CLOUDFLARE_API_TOKEN"],
426
+ },
427
+ },
428
+ matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
429
+ templateVersion: "1.4.0",
430
+ };
431
+ const content = renderTemplate(
432
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
433
+ buildRenderContext(manifest, "/repos/widgets"),
434
+ );
435
+ const declaredScripts = new Set([
436
+ manifest.candidate.buildScript,
437
+ manifest.candidate.checkScript,
438
+ manifest.candidate.admissionScript,
439
+ manifest.candidate.stageSummaryScript,
440
+ manifest.candidate.releaseChain.evidenceVerificationScript,
441
+ manifest.candidate.releaseChain.windowsFinalizeScript,
442
+ manifest.candidate.releaseChain.macosFinalizeScript,
443
+ manifest.candidate.releaseChain.installedQualificationScript,
444
+ manifest.candidate.releaseChain.publishScript,
445
+ ]);
446
+ const runLines = content.match(/run: (?:pnpm|npm|yarn) run \S+/g) ?? [];
447
+ assert.ok(runLines.length > 0, "expected at least one package-script run line");
448
+ for (const line of runLines) {
449
+ const script = line.split(" run ").pop();
450
+ assert.ok(declaredScripts.has(script), `rendered step runs undeclared script: ${line}`);
451
+ }
452
+ });
453
+
454
+ // Downstream-consumer gap fix: the evidence verifier can only prove a stage
455
+ // ran and succeeded if that stage produces a stage summary AND the publish
456
+ // job actually downloads it. This test proves all four authoritative
457
+ // stages (candidate, windows-sign, macos-sign, installed-qualification)
458
+ // produce a summary, that installed-qualification wraps itself in
459
+ // init/finalize like every other stage, that publish downloads the
460
+ // complete same-run set into distinct subdirectories (no collisions
461
+ // between candidate's two platform legs), and that evidence is bound to
462
+ // this exact run (not merely version+source_revision) via RIGHT_GIT_RUN_ID/
463
+ // RIGHT_GIT_RUN_ATTEMPT on both the verification and publish steps.
464
+ test("release chain: installed-qualification has a stage summary and publish downloads the complete same-run evidence set", () => {
465
+ const manifest = {
466
+ ...FULL_MANIFEST,
467
+ lanes: ["ci", "release-candidate"],
468
+ profile: "rust-hybrid",
469
+ gate: { path: "scripts/gate.sh" },
470
+ candidate: {
471
+ buildScript: "ci:candidate",
472
+ checkScript: "ci:candidate-check",
473
+ admissionScript: "release:admit",
474
+ stageSummaryScript: "release:stage-summary",
475
+ artifactRoot: "widgets-unsigned",
476
+ os: ["windows-2025", "macos-15"],
477
+ signWindows: true,
478
+ signMacos: true,
479
+ releaseChain: {
480
+ evidenceVerificationScript: "release:verify-evidence",
481
+ windowsFinalizeScript: "release:finalize:win",
482
+ macosFinalizeScript: "release:finalize:mac",
483
+ installedQualificationScript: "release:qualify:installed",
484
+ publishScript: "release:publish:qualified",
485
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
486
+ publishSecrets: ["CLOUDFLARE_API_TOKEN"],
487
+ },
488
+ },
489
+ matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
490
+ templateVersion: "1.4.0",
491
+ };
492
+ const content = renderTemplate(
493
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
494
+ buildRenderContext(manifest, "/repos/widgets"),
495
+ );
496
+ const installedQualificationJob = content.slice(
497
+ content.search(/^ {2}installed-qualification:\n {4}needs:/m),
498
+ content.search(/^ {2}publish:\n {4}needs:/m),
499
+ );
500
+
501
+ // Point 1: installed-qualification wraps itself in stage-summary
502
+ // init/finalize like candidate/windows-sign/macos-sign, with the correct
503
+ // authoritative stage identifier, and finalize runs unconditionally.
504
+ assert.match(installedQualificationJob, /name: Initialize installed-qualification stage summary\n\s+run: pnpm run release:stage-summary\n\s+env:\n\s+RIGHT_GIT_STAGE_ACTION: init/);
505
+ assert.match(installedQualificationJob, /RIGHT_GIT_STAGE: installed-qualification/);
506
+ assert.match(installedQualificationJob, /name: Finalize installed-qualification stage summary\n\s+if: \$\{\{ always\(\) \}\}\n\s+run: pnpm run release:stage-summary\n\s+env:\n\s+RIGHT_GIT_STAGE_ACTION: finalize/);
507
+ assert.equal((installedQualificationJob.match(/RIGHT_GIT_STAGE: installed-qualification/g) ?? []).length, 2, "both init and finalize must carry the installed-qualification stage id");
508
+ assert.match(installedQualificationJob, /name: Upload installed-qualification stage summary\n\s+if: \$\{\{ always\(\) \}\}/);
509
+
510
+ const publishJob = content.slice(content.search(/^ {2}publish:\n {4}needs:/m));
511
+
512
+ // Point 2: publish downloads the complete same-run evidence set — both
513
+ // candidate legs (windows + macos) in distinct subdirectories, plus
514
+ // windows-sign, macos-sign, and installed-qualification summaries.
515
+ assert.match(publishJob, /Download same-run candidate \(Windows\) stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/candidate-windows/);
516
+ assert.match(publishJob, /Download same-run candidate \(macOS\) stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/candidate-macos/);
517
+ assert.match(publishJob, /Download same-run Windows signing stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/windows-sign/);
518
+ assert.match(publishJob, /Download same-run macOS signing stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/macos-sign/);
519
+ assert.match(publishJob, /Download same-run installed-qualification stage summary[\s\S]*?path: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/installed-qualification/);
520
+ // Distinct subdirectories: no two downloads share a path.
521
+ const downloadPaths = [...publishJob.matchAll(/path: (\$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries\/\S+)/g)].map((m) => m[1]);
522
+ assert.equal(downloadPaths.length, 5);
523
+ assert.equal(new Set(downloadPaths).size, 5, "every stage-summary download must use a distinct subdirectory");
524
+
525
+ // Point 3: both the evidence-verification step and the publish step carry
526
+ // the full run-identity contract (RIGHT_GIT_STAGE_SUMMARY_ROOT, run id,
527
+ // run attempt) alongside version/source_revision — evidence is bound to
528
+ // THIS run, not merely reusable across runs of the same version+SHA.
529
+ const verifyStep = publishJob.slice(publishJob.indexOf("Verify aggregated release evidence"), publishJob.indexOf("Publish qualified signed release"));
530
+ assert.match(verifyStep, /RIGHT_GIT_STAGE_SUMMARY_ROOT: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries/);
531
+ assert.match(verifyStep, /RIGHT_GIT_RUN_ID: \$\{\{ github\.run_id \}\}/);
532
+ assert.match(verifyStep, /RIGHT_GIT_RUN_ATTEMPT: \$\{\{ github\.run_attempt \}\}/);
533
+ const publishStep = publishJob.slice(publishJob.indexOf("Publish qualified signed release"));
534
+ assert.match(publishStep, /RIGHT_GIT_STAGE_SUMMARY_ROOT: \$\{\{ runner\.temp \}\}\/right-git-release\/stage-summaries/);
535
+ assert.match(publishStep, /RIGHT_GIT_RUN_ID: \$\{\{ github\.run_id \}\}/);
536
+ assert.match(publishStep, /RIGHT_GIT_RUN_ATTEMPT: \$\{\{ github\.run_attempt \}\}/);
537
+
538
+ assert.doesNotMatch(content, /run: (?:pnpm|npm|yarn) run undefined\b/);
539
+ assert.doesNotMatch(content, /\$\{\{\s*github\.output\s*\}\}/);
295
540
  });
296
541
 
297
542
  test("render determinism: same manifest renders byte-identical output every time", () => {
@@ -406,3 +651,172 @@ test("renderTemplate normalizes an empty final release-chain fragment to one new
406
651
  assert.equal(out, "jobs:\n");
407
652
  assert.equal((out.match(/\n/g) ?? []).length, 1);
408
653
  });
654
+
655
+ // Tag-last has no tag to check out, so every stage checks out one exact admitted
656
+ // commit SHA and HEAD is always detached. @rightkit/release only exempts a
657
+ // detached HEAD when it can prove the commit is reachable from the dispatched
658
+ // remote branch — and proving reachability needs history plus the
659
+ // remote-tracking ref. A default shallow checkout has neither, so the signing,
660
+ // qualification, and publish jobs would be refused. Every job that checks out an
661
+ // admitted revision must therefore ask for full history.
662
+ test("every job checking out an admitted revision fetches full history", () => {
663
+ const manifest = {
664
+ ...FULL_MANIFEST,
665
+ lanes: ["ci", "release-candidate"],
666
+ profile: "rust-hybrid",
667
+ gate: { path: "scripts/gate.sh" },
668
+ candidate: {
669
+ buildScript: "ci:candidate",
670
+ checkScript: "ci:candidate-check",
671
+ admissionScript: "release:admission",
672
+ stageSummaryScript: "release:stage-summary",
673
+ artifactRoot: "widgets-unsigned",
674
+ os: ["windows-2025", "macos-15"],
675
+ signWindows: true,
676
+ signMacos: true,
677
+ releaseChain: {
678
+ evidenceVerificationScript: "release:verify:evidence",
679
+ windowsFinalizeScript: "release:finalize:win",
680
+ macosFinalizeScript: "release:finalize:mac",
681
+ installedQualificationScript: "release:qualify:installed",
682
+ publishScript: "release:publish:qualified",
683
+ qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL",
684
+ publishSecrets: ["CLOUDFLARE_API_TOKEN"],
685
+ },
686
+ },
687
+ matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
688
+ templateVersion: "1.9.0",
689
+ };
690
+ const content = renderTemplate(
691
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
692
+ buildRenderContext(manifest, "/repos/widgets"),
693
+ );
694
+ const checkouts = content
695
+ .split(/^ {6}- uses: actions\/checkout@/m)
696
+ .slice(1)
697
+ .map((step) => step.split(/^ {6}- /m)[0]);
698
+ assert.ok(checkouts.length >= 6, `expected every stage to check out; found ${checkouts.length}`);
699
+ for (const step of checkouts) {
700
+ if (!/ref: \$\{\{ (needs\.admission\.outputs\.source_revision|inputs\.source_revision) \}\}/.test(step)) continue;
701
+ assert.match(step, /fetch-depth: 0/, `a checkout of an admitted revision must fetch full history:\n${step}`);
702
+ }
703
+ // The four downstream stages specifically — these were the ones shipping shallow.
704
+ for (const job of ["windows-sign", "macos-sign", "installed-qualification", "publish"]) {
705
+ const body = content.split(new RegExp(`^ ${job}:$`, "m"))[1] ?? "";
706
+ const checkout = body.split(/^ {6}- uses: actions\/checkout@/m)[1]?.split(/^ {6}- /m)[0] ?? "";
707
+ assert.match(checkout, /fetch-depth: 0/, `${job} must check out with full history`);
708
+ }
709
+ });
710
+
711
+ // Rule 3 (release workflows have a dry run): a boolean dry_run input, runnable
712
+ // from a branch, must thread through admission so packaging/signing/qualification
713
+ // run while publication is skipped and artifacts carry a dry-run suffix.
714
+ test("release-candidate template exposes a dry_run input threaded through admission", () => {
715
+ const manifest = {
716
+ ...FULL_MANIFEST,
717
+ lanes: ["ci", "release-candidate"],
718
+ profile: "rust-hybrid",
719
+ gate: { path: "scripts/gate.sh" },
720
+ candidate: {
721
+ buildScript: "ci:candidate",
722
+ checkScript: "ci:candidate-check",
723
+ admissionScript: "release:admission",
724
+ stageSummaryScript: "release:stage-summary",
725
+ artifactRoot: "widgets-unsigned",
726
+ os: ["windows-2025", "macos-15"],
727
+ signWindows: true,
728
+ signMacos: true,
729
+ },
730
+ matrix: { os: ["ubuntu-24.04", "windows-2025", "macos-15"], node: ["22.23.2"] },
731
+ templateVersion: "1.9.0",
732
+ };
733
+ const content = renderTemplate(
734
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
735
+ buildRenderContext(manifest, "/repos/widgets"),
736
+ );
737
+ assert.match(content, /^ dry_run:\n description: .*\n required: false\n type: boolean\n default: false$/m);
738
+ assert.match(content, /dry_run: \$\{\{ steps\.admit\.outputs\.dry_run \}\}/);
739
+ assert.match(content, /artifact_suffix: \$\{\{ steps\.admit\.outputs\.artifact_suffix \}\}/);
740
+ assert.match(content, /RIGHT_GIT_DRY_RUN: \$\{\{ inputs\.dry_run \}\}/);
741
+ // The envelope only pins refs/heads/main and first-attempt for a real dispatch.
742
+ assert.match(content, /if \[\[ "\$\{\{ inputs\.dry_run \}\}" == "true" \]\]; then\n \[\[ "\$GITHUB_REF" == refs\/heads\/\* \]\]/);
743
+ });
744
+
745
+ // Correction 11: Windows signing, macOS signing, installed qualification and
746
+ // publication all shared one `release` environment, so no signing/publication
747
+ // grant separation existed. The split is opt-in per product because the secrets
748
+ // live in the existing environment until an operator moves them.
749
+ function environmentManifest(environments) {
750
+ const manifest = {
751
+ schemaVersion: 1,
752
+ compile: "github-actions-only",
753
+ lanes: ["ci", "release-candidate"],
754
+ packageManager: "pnpm@11.24.0",
755
+ profile: "rust-hybrid",
756
+ gate: { path: "scripts/gate.sh" },
757
+ candidate: {
758
+ buildScript: "ci:candidate",
759
+ checkScript: "ci:candidate-check",
760
+ admissionScript: "release:admit",
761
+ stageSummaryScript: "release:stage-summary",
762
+ artifactRoot: "widgets-unsigned",
763
+ os: ["windows-2025", "macos-15"],
764
+ targets: [
765
+ { os: "windows-2025", platform: "windows", architecture: "x86_64" },
766
+ { os: "macos-15", platform: "macos", architecture: "arm64" },
767
+ ],
768
+ signWindows: true,
769
+ signMacos: true,
770
+ releaseChain: {
771
+ windowsFinalizeScript: "release:finalize-windows",
772
+ macosFinalizeScript: "release:finalize-macos",
773
+ installedQualificationScript: "release:qualify-installed",
774
+ publishScript: "release:publish-qualified",
775
+ qualificationRunnerVariable: "WIDGETS_QUALIFICATION_RUNNER",
776
+ },
777
+ },
778
+ matrix: { os: ["ubuntu-24.04", "windows-2025"], node: ["22.23.2"] },
779
+ templateVersion: "1.8.0",
780
+ };
781
+ if (environments) manifest.candidate.releaseChain.environments = environments;
782
+ return manifest;
783
+ }
784
+
785
+ function renderRelease(manifest) {
786
+ return renderTemplate(
787
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
788
+ buildRenderContext(manifest, "/repos/widgets"),
789
+ );
790
+ }
791
+
792
+ test("release environments default to the single release environment", () => {
793
+ const content = renderRelease(environmentManifest(null));
794
+ assert.ok((content.match(/environment: release$/gm) ?? []).length >= 2);
795
+ assert.doesNotMatch(content, /environment: signing-authorized/);
796
+ });
797
+
798
+ test("signing and publication split into distinct protected environments", () => {
799
+ const content = renderRelease(environmentManifest({
800
+ signing: "signing-authorized",
801
+ publication: "release-authorized",
802
+ qualification: "qualification",
803
+ }));
804
+ assert.equal((content.match(/environment: signing-authorized$/gm) ?? []).length, 2);
805
+ assert.equal((content.match(/environment: release-authorized$/gm) ?? []).length, 1);
806
+ // A job that may sign must never also be the job that publishes.
807
+ assert.doesNotMatch(content, /environment: release$/m);
808
+ });
809
+
810
+ test("an unsafe or unknown release environment is rejected", () => {
811
+ assert.equal(validateManifest(environmentManifest({ signing: "bad name!" })).valid, false);
812
+ assert.equal(validateManifest(environmentManifest({ promotion: "x" })).valid, false);
813
+ });
814
+
815
+ test("ci.yml ownership check cannot be removed by deleting optional repo metadata", () => {
816
+ const manifest = { ...structuredClone(FULL_MANIFEST), ownership: { roots: [".", "engine"] } };
817
+ const template = fs.readFileSync(path.join(templatesDir, "ci.yml"), "utf8");
818
+ const withStep = renderTemplate(template, buildRenderContext(manifest, "/repos/coderight"));
819
+ assert.match(withStep, /RightKit ownership \(protected registry roots\)/);
820
+ const without = renderTemplate(template, buildRenderContext(structuredClone(FULL_MANIFEST), "/repos/widgets"));
821
+ assert.match(without, /RightKit ownership \(protected registry roots\)/);
822
+ });
package/sync.test.mjs CHANGED
@@ -67,11 +67,14 @@ test("sync rejects a Membrane-like release chain without root @rightkit/release"
67
67
  candidate: {
68
68
  buildScript: "release:candidate:build",
69
69
  checkScript: "release:candidate:check",
70
+ admissionScript: "release:admit",
71
+ stageSummaryScript: "release:stage-summary",
70
72
  artifactRoot: "membrane-release-candidate",
71
73
  os: ["windows-2025", "macos-15"],
72
74
  signWindows: true,
73
75
  signMacos: true,
74
76
  releaseChain: {
77
+ evidenceVerificationScript: "release:verify-evidence",
75
78
  windowsFinalizeScript: "release:finalize:windows",
76
79
  macosFinalizeScript: "release:finalize:macos",
77
80
  installedQualificationScript: "release:qualify:installed",
package/templates/ci.yml CHANGED
@@ -47,4 +47,4 @@ ${PACKAGE_MANAGER_SETUP}
47
47
  - run: ${PM_RUN} test:random
48
48
  - run: ${PM_RUN} test:all
49
49
  - run: node scripts/ci/check-generated.mjs
50
- - run: node scripts/ci/check-network-boundary.mjs
50
+ - run: node scripts/ci/check-network-boundary.mjs${OWNERSHIP_STEP}
@@ -34,4 +34,5 @@ jobs:
34
34
  package-manager-cache: false
35
35
  ${PACKAGE_MANAGER_SETUP}
36
36
  - run: ${INSTALL_COMMAND}
37
+ ${OWNERSHIP_STEP}
37
38
  - run: node scripts/test-package.mjs
@@ -64,6 +64,8 @@ jobs:
64
64
  node-version: ${{ matrix.node }}
65
65
  package-manager-cache: false
66
66
  ${PACKAGE_MANAGER_SETUP}
67
+ - run: ${INSTALL_COMMAND}
68
+ ${OWNERSHIP_STEP}
67
69
  - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0
68
70
  if: steps.rust-changes.outputs.required == 'true'
69
71
  with: