@rightkit/git 0.2.24 → 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/cli/right-git.mjs CHANGED
File without changes
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._/-]*)$/;
@@ -264,7 +265,7 @@ export function validateManifest(manifest) {
264
265
  // above) — the full signed release chain adds evidenceVerificationScript
265
266
  // (runs before publish, verifying aggregated same-run evidence) plus the
266
267
  // finalize/qualify/publish scripts.
267
- const allowedChainKeys = ["evidenceVerificationScript", "windowsFinalizeScript", "macosFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable", "publishSecrets"];
268
+ const allowedChainKeys = ["evidenceVerificationScript", "windowsFinalizeScript", "macosFinalizeScript", "installedQualificationScript", "publishScript", "qualificationRunnerVariable", "publishSecrets", "environments"];
268
269
  if (chain === null || typeof chain !== "object" || Array.isArray(chain)) {
269
270
  errors.push("candidate.releaseChain must be an object");
270
271
  } else {
@@ -277,6 +278,19 @@ export function validateManifest(manifest) {
277
278
  }
278
279
  }
279
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
+ }
280
294
  errors.push("candidate.releaseChain.macosFinalizeScript must be one safe package script name");
281
295
  }
282
296
  if (typeof chain.qualificationRunnerVariable !== "string" || !GITHUB_VARIABLE_RE.test(chain.qualificationRunnerVariable)) {
@@ -298,6 +312,18 @@ export function validateManifest(manifest) {
298
312
  if (profile === "rust-hybrid" && lanes.includes("release-candidate") && candidate === undefined) {
299
313
  errors.push("rust-hybrid release-candidate lane requires candidate { buildScript, checkScript, artifactRoot }");
300
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
+ }
301
327
  if (profile !== "rust-hybrid" && candidate !== undefined) {
302
328
  errors.push("candidate is only valid for profile 'rust-hybrid'");
303
329
  }
@@ -464,6 +490,15 @@ const ARTIFACT_SIGNING_CLIENT_SHA256 = "74BD7D27E6CE1051409C38D9B46BC8DF0400ECD6
464
490
  const WINDOWS_SDK_BUILD_TOOLS_VERSION = "10.0.26100.4188";
465
491
  const WINDOWS_SDK_BUILD_TOOLS_SHA256 = "180DEB372659029864C10A0C04787833234D64AACD1D2C0661D2C00295D8E022";
466
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
+
467
502
  function windowsSigningJob(manifest, pm, artifactPrefix) {
468
503
  if (manifest.candidate?.signWindows !== true) return "";
469
504
  const nodeVersion = String(manifest.matrix.node[0]);
@@ -484,7 +519,7 @@ function windowsSigningJob(manifest, pm, artifactPrefix) {
484
519
  windows-sign:
485
520
  needs: [admission, candidate]
486
521
  if: \${{ needs.admission.outputs.signed_qualification == 'true' }}
487
- environment: release
522
+ environment: ${releaseEnvironment(manifest, "signing")}
488
523
  strategy:
489
524
  fail-fast: false
490
525
  matrix:
@@ -497,6 +532,7 @@ ${signingMatrix(manifest, "windows")}
497
532
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
498
533
  with:
499
534
  ref: \${{ needs.admission.outputs.source_revision }}
535
+ fetch-depth: 0
500
536
  persist-credentials: false
501
537
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
502
538
  with:
@@ -612,7 +648,7 @@ function macosSigningJob(manifest, pm, artifactPrefix) {
612
648
  macos-sign:
613
649
  needs: [admission, candidate]
614
650
  if: \${{ needs.admission.outputs.signed_qualification == 'true' }}
615
- environment: release
651
+ environment: ${releaseEnvironment(manifest, "signing")}
616
652
  strategy:
617
653
  fail-fast: false
618
654
  matrix:
@@ -624,6 +660,7 @@ ${signingMatrix(manifest, "macos")}
624
660
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
625
661
  with:
626
662
  ref: \${{ needs.admission.outputs.source_revision }}
663
+ fetch-depth: 0
627
664
  persist-credentials: false
628
665
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
629
666
  with:
@@ -789,12 +826,13 @@ function releaseChainJobs(manifest, pm, artifactPrefix) {
789
826
  installed-qualification:
790
827
  needs: [admission, candidate, windows-sign]
791
828
  if: \${{ always() && needs.admission.outputs.signed_qualification == 'true' && needs.candidate.result == 'success' }}
792
- environment: release
829
+ environment: ${releaseEnvironment(manifest, "qualification")}
793
830
  runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
794
831
  steps:
795
832
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
796
833
  with:
797
834
  ref: \${{ needs.admission.outputs.source_revision }}
835
+ fetch-depth: 0
798
836
  persist-credentials: false
799
837
  - name: Fail closed when protected finalization is absent
800
838
  if: \${{ needs.windows-sign.result != 'success' }}
@@ -862,7 +900,7 @@ ${runIdentityEnv}
862
900
  publish:
863
901
  needs: [admission, installed-qualification${macosDependency}]
864
902
  if: \${{ needs.admission.outputs.publish == 'true' && needs.installed-qualification.result == 'success'${macosSuccess} }}
865
- environment: release
903
+ environment: ${releaseEnvironment(manifest, "publication")}
866
904
  runs-on: \${{ vars.${chain.qualificationRunnerVariable} || '${windowsRunner}' }}
867
905
  permissions:
868
906
  contents: write
@@ -870,6 +908,7 @@ ${runIdentityEnv}
870
908
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
871
909
  with:
872
910
  ref: \${{ needs.admission.outputs.source_revision }}
911
+ fetch-depth: 0
873
912
  persist-credentials: false
874
913
  - name: Download same-run finalized Windows release
875
914
  uses: actions/download-artifact@${DOWNLOAD_ARTIFACT_SHA} # v4.3.0
@@ -950,6 +989,15 @@ export function deriveProjectName(repoPath) {
950
989
  * Build the flat placeholder -> value map used by render.mjs, from a
951
990
  * validated manifest plus the target repo's derived project name.
952
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
+
953
1001
  export function buildRenderContext(manifest, repoPath) {
954
1002
  const pm = parsePackageManager(manifest.packageManager);
955
1003
  const projectName = deriveProjectName(repoPath);
@@ -976,6 +1024,7 @@ export function buildRenderContext(manifest, repoPath) {
976
1024
  WINDOWS_SIGNING_JOB: windowsSigningJob(manifest, pm, artifactPrefix),
977
1025
  MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
978
1026
  RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
1027
+ OWNERSHIP_STEP: ownershipStep(manifest),
979
1028
  };
980
1029
 
981
1030
  // Every key below is populated ONLY when its source manifest section is
@@ -84,6 +84,19 @@
84
84
  "type": "string",
85
85
  "pattern": "^\\d+\\.\\d+\\.\\d+$"
86
86
  }
87
+ ,"ownership": {
88
+ "type": "object",
89
+ "required": ["roots"],
90
+ "additionalProperties": false,
91
+ "properties": {
92
+ "roots": {
93
+ "type": "array",
94
+ "minItems": 1,
95
+ "uniqueItems": true,
96
+ "items": { "type": "string", "pattern": "^(?:\\.|(?!.*\\.\\.)(?!.*\\\\)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*)$" }
97
+ }
98
+ }
99
+ }
87
100
  ,"profile": { "enum": ["node", "rust-hybrid"] },
88
101
  "rustCache": {
89
102
  "type": "object",
package/manifest.test.mjs CHANGED
@@ -22,6 +22,13 @@ test("accepts a minimal valid manifest", () => {
22
22
  assert.deepEqual(errors, []);
23
23
  });
24
24
 
25
+ test("ownership scan registration is explicit and supports nested package roots", () => {
26
+ assert.equal(validateManifest({ ...VALID_MINIMAL, ownership: { roots: ["engine", "apps/client"] } }).valid, true);
27
+ for (const roots of [[], ["../outside"], ["src\\escape"], [""], ["engine", "engine"]]) {
28
+ assert.equal(validateManifest({ ...VALID_MINIMAL, ownership: { roots } }).valid, false, JSON.stringify(roots));
29
+ }
30
+ });
31
+
25
32
  test("rust-hybrid supports ci plus a configured unsigned release-candidate lane", () => {
26
33
  const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
27
34
  assert.equal(validateManifest(valid).valid, true);
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+
3
+ /** Normalize HTTPS, SSH, and SCP GitHub remotes to one immutable repository key. */
4
+ export function normalizeRepositoryIdentity(value) {
5
+ if (typeof value !== "string" || !value.trim()) throw new Error("ownership registration requires a repository identity");
6
+ let text = value.trim();
7
+ const scp = text.match(/^(?:[^@/]+@)?([^:]+):([^/]+\/[^/]+)$/);
8
+ if (scp) text = `https://${scp[1]}/${scp[2]}`;
9
+ let url;
10
+ try { url = new URL(text); } catch { throw new Error("ownership repository identity must be a Git remote URL"); }
11
+ if (!["https:", "ssh:"].includes(url.protocol) || (url.username && !(url.protocol === "ssh:" && url.username === "git")) || url.password || url.search || url.hash) throw new Error("unsupported or credential-bearing ownership repository identity");
12
+ const host = url.hostname.toLowerCase();
13
+ const parts = url.pathname.replace(/\.git$/i, "").split("/").filter(Boolean);
14
+ if (host !== "github.com" || parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9_.-]+$/.test(part))) throw new Error("ownership repository identity must be owner/repository on github.com");
15
+ return `https://${host}/${parts[0].toLowerCase()}/${parts[1].toLowerCase()}`;
16
+ }
17
+
18
+ /** Resolve immutable OS-owned roots for one checkout; repo declarations can only narrow reporting, never authority. */
19
+ export function protectedOwnershipRoots(trust, { repositoryIdentity, scanRoot, repoRoot }) {
20
+ if (!trust || !Array.isArray(trust.repositories)) throw new Error("protected ownership repository registry is missing");
21
+ const wanted = normalizeRepositoryIdentity(repositoryIdentity);
22
+ const matches = trust.repositories.filter((entry) => normalizeRepositoryIdentity(entry.repositoryIdentity) === wanted);
23
+ if (matches.length !== 1) throw new Error(matches.length ? "protected ownership registry has duplicate repository identities" : `repository is not registered for ownership enforcement: ${wanted}`);
24
+ const entry = matches[0];
25
+ if (!["public", "private"].includes(entry.visibility) || !Array.isArray(entry.roots) || !entry.roots.length) throw new Error("protected ownership registration is malformed");
26
+ const roots = entry.roots.map((root) => {
27
+ if (typeof root !== "string" || !root || root.includes("\\") || root.startsWith("/") || (root !== "." && root.split("/").some((p) => !p || p === "." || p === "..")) || /[*?]/.test(root)) throw new Error("protected ownership roots must be exact relative paths");
28
+ return root;
29
+ });
30
+ const relative = path.relative(repoRoot, scanRoot).replaceAll("\\", "/") || ".";
31
+ if (!roots.includes(relative)) throw new Error(`scan root is not registered for ${wanted}: ${relative}`);
32
+ return { repositoryIdentity: wanted, visibility: entry.visibility, roots };
33
+ }
@@ -0,0 +1,20 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { normalizeRepositoryIdentity, protectedOwnershipRoots } from "./ownership-registration.mjs";
4
+
5
+ test("repository identity normalization collapses HTTPS, SSH, and SCP GitHub remotes", () => {
6
+ const expected = "https://github.com/bogusyogi/coderight";
7
+ for (const remote of ["https://github.com/bogusyogi/CodeRight.git", "ssh://git@github.com/bogusyogi/coderight.git", "git@github.com:bogusyogi/coderight.git"]) {
8
+ assert.equal(normalizeRepositoryIdentity(remote), expected);
9
+ }
10
+ assert.throws(() => normalizeRepositoryIdentity("https://user:secret@github.com/a/b.git"), /credential-bearing/);
11
+ });
12
+
13
+ test("protected registry is authoritative for exact nested roots and visibility", () => {
14
+ const trust = { repositories: [{ repositoryIdentity: "https://github.com/bogusyogi/coderight", roots: ["engine", "apps/ui"], visibility: "private" }] };
15
+ assert.deepEqual(protectedOwnershipRoots(trust, { repositoryIdentity: "git@github.com:bogusyogi/coderight.git", scanRoot: "C:/repo/engine", repoRoot: "C:/repo" }), {
16
+ repositoryIdentity: "https://github.com/bogusyogi/coderight", visibility: "private", roots: ["engine", "apps/ui"],
17
+ });
18
+ assert.throws(() => protectedOwnershipRoots(trust, { repositoryIdentity: "https://github.com/bogusyogi/coderight", scanRoot: "C:/repo/src", repoRoot: "C:/repo" }), /not registered/);
19
+ assert.throws(() => protectedOwnershipRoots({ repositories: [] }, { repositoryIdentity: "https://github.com/a/b", scanRoot: ".", repoRoot: "." }), /not registered/);
20
+ });
@@ -3,11 +3,11 @@
3
3
  // publish and runs the extracted bin with `node` — `node --test` run from
4
4
  // the source tree never notices a missing directory because the source
5
5
  // tree has every file regardless of what `files` declares. This test packs
6
- // for real (npm pack), extracts to a temp dir, and executes the built bin's
6
+ // for real (pnpm pack), extracts to a temp dir, and executes the built bin's
7
7
  // --help and --version from inside that extracted tree only.
8
8
  import assert from "node:assert/strict";
9
9
  import { spawnSync } from "node:child_process";
10
- import { mkdtempSync, rmSync } from "node:fs";
10
+ import { mkdtempSync, readdirSync, rmSync } from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
@@ -24,17 +24,17 @@ function run(command, args, options) {
24
24
  test("packaged tarball: right-git --help and --version run clean from the published files only", () => {
25
25
  const workDir = mkdtempSync(path.join(os.tmpdir(), "rightkit-git-pack-"));
26
26
  try {
27
- // shell:true — on Windows "npm" only resolves (to npm.cmd) through the
28
- // shell; args here are fixed literals we construct, never user input.
29
- const packResult = run("npm", ["pack", "--json", "--pack-destination", workDir], { cwd: packageRoot, shell: true });
30
- assert.equal(packResult.status, 0, `npm pack failed: ${packResult.stderr}`);
31
- const [packInfo] = JSON.parse(packResult.stdout);
27
+ // shell:true resolves pnpm.cmd on Windows; args are fixed literals.
28
+ const packResult = run("pnpm", ["pack", "--pack-destination", workDir], { cwd: packageRoot, shell: true });
29
+ assert.equal(packResult.status, 0, `pnpm pack failed: ${packResult.stderr}`);
30
+ const archive = readdirSync(workDir).find((name) => name.endsWith(".tgz"));
31
+ assert.ok(archive, `pnpm pack did not create a tarball: ${packResult.stdout}`);
32
32
 
33
33
  // Run tar FROM the temp dir on the bare filename so no absolute "C:\..."
34
34
  // path reaches tar — that colon is the only reason GNU tar's --force-local
35
35
  // was ever needed, and avoiding it keeps this portable to bsdtar (the
36
36
  // Windows built-in) and any CI host, not just a GNU-tar toolchain.
37
- const extractResult = run("tar", ["-xzf", packInfo.filename], { cwd: workDir });
37
+ const extractResult = run("tar", ["-xzf", archive], { cwd: workDir });
38
38
  assert.equal(extractResult.status, 0, `tar extract failed: ${extractResult.stderr}`);
39
39
  // npm tarballs always nest content under a top-level "package/" directory.
40
40
  const extractedPackageDir = path.join(workDir, "package");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/git",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
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",
@@ -15,9 +15,6 @@
15
15
  "*.json"
16
16
  ],
17
17
  "sideEffects": false,
18
- "scripts": {
19
- "test": "node --test *.test.mjs"
20
- },
21
18
  "publishConfig": {
22
19
  "registry": "https://registry.npmjs.org/",
23
20
  "access": "public"
@@ -27,5 +24,7 @@
27
24
  "url": "git+https://github.com/bogusyogi/claude.git",
28
25
  "directory": "tools/rightkit/packages/git"
29
26
  },
30
- "packageManager": "pnpm@11.24.0"
31
- }
27
+ "scripts": {
28
+ "test": "node --test *.test.mjs"
29
+ }
30
+ }
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 \}\}/);
@@ -196,8 +211,8 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
196
211
  // Every downstream job (admission, candidate, and windows-sign here) checks
197
212
  // out the exact admitted revision by SHA, never by resolving HEAD after a
198
213
  // ref-triggered checkout.
199
- assert.match(content, /windows-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/);
200
- assert.doesNotMatch(content, /git rev-parse HEAD/);
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/);
201
216
  assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
202
217
  assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
203
218
  assert.doesNotMatch(content, /secrets\.|client-secret|private.key/i);
@@ -235,8 +250,8 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
235
250
  assert.match(content, /temporary keychain/i);
236
251
  assert.match(content, /right-release build --platform mac --skip-checks/);
237
252
  assert.match(content, /secrets\.APPLE_API_KEY_BASE64/);
238
- assert.match(content, /macos-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/);
239
- assert.doesNotMatch(content, /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/);
240
255
  assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
241
256
  assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
242
257
  assert.match(content, /security delete-keychain/);
@@ -355,18 +370,17 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
355
370
  assert.match(content, /Verify aggregated release evidence[\s\S]*?pnpm run release:verify-evidence/);
356
371
  assert.match(content, /pnpm run release:publish:qualified/);
357
372
  assert.match(content, /CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}/);
358
- // The old ref-triggered "resolve HEAD after checkout" trick is gone
359
- // entirely for the releaseChain path — every job instead checks out the
360
- // exact admitted SHA from the admission job's own output.
361
- assert.doesNotMatch(content, /git rev-parse HEAD/);
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/);
362
376
  assert.doesNotMatch(content, /RIGHT_GIT_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
363
- // windows-sign, macos-sign, installed-qualification, publish all check out
364
- // the admitted revision directly followed by persist-credentials: false;
365
- // candidate does too but with a fetch-depth: 0 line between them (matched
366
- // separately below), and admission checks out ${{ inputs.source_revision }}
367
- // (it IS the admission job — there is no needs.admission yet).
368
- assert.equal((content.match(/ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+persist-credentials: false/g) ?? []).length, 4, "windows-sign, macos-sign, installed-qualification, publish should all pin the admitted revision");
369
- assert.match(content, /ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/, "candidate must also pin the admitted revision");
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");
370
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");
371
385
  assert.doesNotMatch(content.slice(0, content.indexOf(" windows-sign:")), /secrets\.|id-token: write|contents: write/);
372
386
  // Downstream download-artifact names must match exactly what windows-sign/
@@ -637,3 +651,172 @@ test("renderTemplate normalizes an empty final release-chain fragment to one new
637
651
  assert.equal(out, "jobs:\n");
638
652
  assert.equal((out.match(/\n/g) ?? []).length, 1);
639
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/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:
@@ -23,6 +23,11 @@ on:
23
23
  required: false
24
24
  type: boolean
25
25
  default: false
26
+ dry_run:
27
+ description: Rehearse packaging, signing & installed qualification from a branch with publication skipped
28
+ required: false
29
+ type: boolean
30
+ default: false
26
31
  concurrency:
27
32
  group: release-${{ inputs.version }}
28
33
  cancel-in-progress: false
@@ -45,13 +50,20 @@ jobs:
45
50
  source_revision: ${{ steps.admit.outputs.source_revision }}
46
51
  signed_qualification: ${{ steps.admit.outputs.signed_qualification }}
47
52
  publish: ${{ steps.admit.outputs.publish }}
53
+ dry_run: ${{ steps.admit.outputs.dry_run }}
54
+ artifact_suffix: ${{ steps.admit.outputs.artifact_suffix }}
48
55
  steps:
49
56
  - name: Validate immutable dispatch envelope
50
57
  shell: bash
51
58
  run: |
52
59
  set -euo pipefail
53
- [[ "$GITHUB_REF" == "refs/heads/main" ]]
54
- [[ "$GITHUB_RUN_ATTEMPT" == "1" ]]
60
+ if [[ "${{ inputs.dry_run }}" == "true" ]]; then
61
+ [[ "$GITHUB_REF" == refs/heads/* ]]
62
+ [[ "${{ inputs.publish }}" != "true" ]]
63
+ else
64
+ [[ "$GITHUB_REF" == "refs/heads/main" ]]
65
+ [[ "$GITHUB_RUN_ATTEMPT" == "1" ]]
66
+ fi
55
67
  [[ "${{ inputs.version }}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
56
68
  [[ "${{ inputs.source_revision }}" =~ ^[a-f0-9]{40}$ ]]
57
69
  if [[ "${{ inputs.publish }}" == "true" ]]; then [[ "${{ inputs.signed_qualification }}" == "true" ]]; fi
@@ -66,6 +78,7 @@ jobs:
66
78
  package-manager-cache: false
67
79
  ${PACKAGE_MANAGER_SETUP}
68
80
  - run: ${INSTALL_COMMAND}
81
+ ${OWNERSHIP_STEP}
69
82
  - name: Admit exact main revision & version
70
83
  id: admit
71
84
  run: ${PM_RUN} ${RELEASE_ADMISSION_SCRIPT}
@@ -75,6 +88,7 @@ ${PACKAGE_MANAGER_SETUP}
75
88
  RIGHT_GIT_SOURCE_REVISION: ${{ inputs.source_revision }}
76
89
  RIGHT_GIT_SIGNED_QUALIFICATION: ${{ inputs.signed_qualification }}
77
90
  RIGHT_GIT_PUBLISH: ${{ inputs.publish }}
91
+ RIGHT_GIT_DRY_RUN: ${{ inputs.dry_run }}
78
92
  RIGHT_GIT_WORKFLOW_REF: ${{ github.ref }}
79
93
  RIGHT_GIT_RUN_ATTEMPT: ${{ github.run_attempt }}
80
94
  candidate:
@@ -40,6 +40,7 @@ jobs:
40
40
  package-manager-cache: false
41
41
  ${PACKAGE_MANAGER_SETUP}
42
42
  - run: ${INSTALL_COMMAND}
43
+ ${OWNERSHIP_STEP}
43
44
  - run: node scripts/release/build-candidate.mjs --version "${{ github.event.release.tag_name }}" --platform current --out "release/candidates/publish"
44
45
  - run: node scripts/release/check-release.mjs release/candidates/publish
45
46
  - id: tarball
@@ -79,6 +79,7 @@ jobs:
79
79
  package-manager-cache: false
80
80
  ${PACKAGE_MANAGER_SETUP}
81
81
  - run: ${INSTALL_COMMAND}
82
+ ${OWNERSHIP_STEP}
82
83
  - run: ruby -c release/homebrew/${HOMEBREW_FORMULA_NAME}.rb
83
84
  - name: Checkout Homebrew tap
84
85
  uses: actions/checkout@v5
@@ -29,6 +29,7 @@ jobs:
29
29
  package-manager-cache: false
30
30
  ${PACKAGE_MANAGER_SETUP}
31
31
  - run: ${INSTALL_COMMAND}
32
+ ${OWNERSHIP_STEP}
32
33
  - run: ${PM_RUN} test:all
33
34
  schema:
34
35
  needs: [qualification]
@@ -35,6 +35,7 @@ jobs:
35
35
  package-manager-cache: false
36
36
  ${PACKAGE_MANAGER_SETUP}
37
37
  - run: ${INSTALL_COMMAND}
38
+ ${OWNERSHIP_STEP}
38
39
  - run: node --test tests/release-candidate.test.mjs
39
40
  - run: node scripts/release/build-candidate.mjs --platform current --out "${{ runner.temp }}/${ARTIFACT_PREFIX}-rc"
40
41
  - run: node scripts/release/check-release.mjs "${{ runner.temp }}/${ARTIFACT_PREFIX}-rc"