@rightkit/git 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,8 @@ GitHub Actions minutes are free and unlimited on public repos, and billed on pri
19
19
 
20
20
  Workflows are thin callers over the repo's own scripts, so a private repo running the same gates locally executes identical checks.
21
21
 
22
+ Sync records exact prior bytes, refuses unmanaged same-name workflows, & preflights every target before any write. Qualification preserves schema logs with a unique required artifact.
23
+
22
24
  ## Credential posture (read before enabling package-manager publishing)
23
25
 
24
26
  - **No code-signing credential ever reaches CI.** Signing and notarization stay local in `@rightkit/release`. The only publish credential in the npm lane is short-lived OIDC (trusted publishing, no long-lived token).
@@ -36,4 +38,4 @@ Node builtins only; no runtime dependencies.
36
38
 
37
39
  ## Profiles
38
40
 
39
- `profile` defaults to `node`. `rust-hybrid` accepts only `ci`, requires safe relative `gate.path`, installs pinned Node plus `rustup stable`, then runs that gate. It never adds release, signing, publishing, or approval steps.
41
+ `profile` defaults to `node`. `rust-hybrid` requires `ci`, accepts optional `release-candidate`, installs pinned Node plus `rustup stable`, then runs product-owned package scripts. Candidate configuration is `{ buildScript, checkScript, artifactRoot }`; RightGit passes an isolated `RIGHT_GIT_ARTIFACT_ROOT`, checks it, & uploads it as an unsigned handoff. It never signs, finalizes, publishes releases, or receives signing credentials.
@@ -54,7 +54,7 @@ const FULL_MANIFEST = {
54
54
  schemaVersion: 1,
55
55
  lanes: [...KNOWN_LANES],
56
56
  matrix: { os: ["ubuntu-latest", "macos-latest", "windows-latest"], node: ["22.13", "24"] },
57
- packageManager: "pnpm@11.18.0",
57
+ packageManager: "pnpm@11.24.0",
58
58
  publish: {
59
59
  npm: { oidc: true, provenance: true },
60
60
  homebrew: { tap: "acme/homebrew-tap", formulaName: "widgets" },
@@ -219,6 +219,13 @@ test("F5: private-repo-guard job has no job-level `if` (always runs) and fails o
219
219
  }
220
220
  });
221
221
 
222
+ test("qualification preserves failing schema evidence as a unique required artifact", () => {
223
+ const template = fs.readFileSync(path.join(templatesDir, "qualification.yml"), "utf8");
224
+ assert.doesNotMatch(template, /\|\| true/);
225
+ assert.match(template, /set -o pipefail[\s\S]*tee qualification-schema\.log/);
226
+ assert.match(template, /uses: actions\/upload-artifact@v4[\s\S]*github\.run_id[\s\S]*github\.run_attempt[\s\S]*if-no-files-found: error/);
227
+ });
228
+
222
229
  test("rust-hybrid ci keeps its work behind the public-repository guard", () => {
223
230
  const manifest = { ...FULL_MANIFEST, lanes: ["ci"], profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
224
231
  const template = fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8");
package/install.mjs CHANGED
@@ -9,10 +9,10 @@ import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { assertPublic } from "./policy/visibility-gate.mjs";
11
11
  import { MANIFEST_FILENAME, validateManifest } from "./manifest.mjs";
12
- import { syncCommand } from "./sync.mjs";
12
+ import { applyPlan, planSync } from "./sync.mjs";
13
13
 
14
14
  export const DEFAULT_MATRIX = { os: ["ubuntu-latest", "macos-latest", "windows-latest"], node: ["22.13", "24"] };
15
- export const DEFAULT_PACKAGE_MANAGER = "pnpm@11.18.0";
15
+ export const DEFAULT_PACKAGE_MANAGER = "pnpm@11.24.0";
16
16
  export const DEFAULT_TEMPLATE_VERSION = "1.1.0";
17
17
 
18
18
  /**
@@ -67,8 +67,14 @@ export async function installCommand(repoPath, opts = {}, deps = {}) {
67
67
  const { valid, errors } = validateManifest(manifest);
68
68
  if (!valid) return { ok: false, stage: "manifest-invalid", errors };
69
69
 
70
- fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
71
-
72
- const plan = await syncCommand(repoPath, { dryRun: false, deps });
73
- return { ok: plan.ok, stage: "synced", errors: plan.errors || [], manifestPath, plan };
70
+ const manifestBytes = `${JSON.stringify(manifest, null, 2)}\n`;
71
+ const plan = await planSync(repoPath, deps, manifest);
72
+ if (!plan.ok) return { ok: false, stage: plan.stage, errors: plan.errors, plan };
73
+ fs.writeFileSync(manifestPath, manifestBytes, "utf8");
74
+ const applied = applyPlan(repoPath, plan);
75
+ if (!applied.ok) {
76
+ if (fs.readFileSync(manifestPath, "utf8") === manifestBytes) fs.rmSync(manifestPath);
77
+ return { ok: false, stage: applied.stage, errors: applied.errors, plan };
78
+ }
79
+ return { ok: true, stage: "synced", errors: [], manifestPath, plan };
74
80
  }
package/manifest.mjs CHANGED
@@ -57,8 +57,10 @@ const TOP_LEVEL_KEYS = new Set([
57
57
  "templateVersion",
58
58
  "profile",
59
59
  "gate",
60
+ "candidate",
60
61
  ]);
61
62
  const GATE_PATH_RE = /^(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
63
+ const PACKAGE_SCRIPT_RE = /^[A-Za-z0-9][A-Za-z0-9:_-]*$/;
62
64
 
63
65
  /**
64
66
  * Validate a parsed manifest object against the right-git manifest contract.
@@ -123,7 +125,9 @@ export function validateManifest(manifest) {
123
125
  const profile = manifest.profile === undefined ? "node" : manifest.profile;
124
126
  if (profile !== "node" && profile !== "rust-hybrid") errors.push("profile must be 'node' or 'rust-hybrid'");
125
127
  if (profile === "rust-hybrid") {
126
- if (lanes.length !== 1 || lanes[0] !== "ci") errors.push("profile 'rust-hybrid' supports only lane 'ci'");
128
+ if (lanes.some((lane) => lane !== "ci" && lane !== "release-candidate") || !lanes.includes("ci")) {
129
+ errors.push("profile 'rust-hybrid' requires lane 'ci' and supports optional lane 'release-candidate'");
130
+ }
127
131
  if (!manifest.gate || typeof manifest.gate !== "object" || Array.isArray(manifest.gate) || Object.keys(manifest.gate).some((key) => key !== "path") || typeof manifest.gate.path !== "string" || !GATE_PATH_RE.test(manifest.gate.path)) {
128
132
  errors.push("profile 'rust-hybrid' requires gate.path with a safe relative path");
129
133
  }
@@ -131,6 +135,31 @@ export function validateManifest(manifest) {
131
135
  errors.push("gate is only valid for profile 'rust-hybrid'");
132
136
  }
133
137
 
138
+ const candidate = manifest.candidate;
139
+ if (candidate !== undefined) {
140
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
141
+ errors.push("candidate must be an object");
142
+ } else {
143
+ for (const key of Object.keys(candidate)) {
144
+ if (!["buildScript", "checkScript", "artifactRoot"].includes(key)) errors.push(`unknown candidate field: ${key}`);
145
+ }
146
+ for (const key of ["buildScript", "checkScript"]) {
147
+ if (typeof candidate[key] !== "string" || !PACKAGE_SCRIPT_RE.test(candidate[key])) {
148
+ errors.push(`candidate.${key} must be one safe package script name`);
149
+ }
150
+ }
151
+ if (typeof candidate.artifactRoot !== "string" || !GATE_PATH_RE.test(candidate.artifactRoot)) {
152
+ errors.push("candidate.artifactRoot must be one safe relative path");
153
+ }
154
+ }
155
+ }
156
+ if (profile === "rust-hybrid" && lanes.includes("release-candidate") && candidate === undefined) {
157
+ errors.push("rust-hybrid release-candidate lane requires candidate { buildScript, checkScript, artifactRoot }");
158
+ }
159
+ if (profile !== "rust-hybrid" && candidate !== undefined) {
160
+ errors.push("candidate is only valid for profile 'rust-hybrid'");
161
+ }
162
+
134
163
  const publish = manifest.publish;
135
164
  if (publish !== undefined) {
136
165
  if (publish === null || typeof publish !== "object" || Array.isArray(publish)) {
@@ -232,7 +261,12 @@ function parsePackageManager(spec) {
232
261
  yarn: "yarn install --frozen-lockfile",
233
262
  }[name];
234
263
  const runPrefix = { pnpm: "pnpm run", npm: "npm run", yarn: "yarn run" }[name];
235
- return { name, version, installCommand, runPrefix };
264
+ const setup = {
265
+ pnpm: ` - uses: pnpm/action-setup@v4\n with:\n version: ${version}`,
266
+ npm: "",
267
+ yarn: ` - run: corepack enable && corepack prepare yarn@${version} --activate`,
268
+ }[name];
269
+ return { name, version, installCommand, runPrefix, setup };
236
270
  }
237
271
 
238
272
  function yamlFlowStringList(values) {
@@ -274,9 +308,13 @@ export function buildRenderContext(manifest, repoPath) {
274
308
  PM_CACHE: pm.name,
275
309
  INSTALL_COMMAND: pm.installCommand,
276
310
  PM_RUN: pm.runPrefix,
311
+ PACKAGE_MANAGER_SETUP: pm.setup,
277
312
  ARTIFACT_PREFIX: artifactPrefix,
278
313
  TEMPLATE_VERSION: manifest.templateVersion,
279
314
  GATE_PATH: manifest.gate?.path,
315
+ CANDIDATE_BUILD_SCRIPT: manifest.candidate?.buildScript,
316
+ CANDIDATE_CHECK_SCRIPT: manifest.candidate?.checkScript,
317
+ CANDIDATE_ARTIFACT_ROOT: manifest.candidate?.artifactRoot,
280
318
  };
281
319
 
282
320
  if (manifest.publish?.npm) {
@@ -85,6 +85,16 @@
85
85
  "gate": {
86
86
  "type": "object", "required": ["path"], "additionalProperties": false,
87
87
  "properties": { "path": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$" } }
88
+ },
89
+ "candidate": {
90
+ "type": "object",
91
+ "required": ["buildScript", "checkScript", "artifactRoot"],
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "buildScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
95
+ "checkScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
96
+ "artifactRoot": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$" }
97
+ }
88
98
  }
89
99
  }
90
100
  }
package/manifest.test.mjs CHANGED
@@ -6,7 +6,7 @@ const VALID_MINIMAL = {
6
6
  schemaVersion: 1,
7
7
  lanes: ["ci"],
8
8
  matrix: { os: ["ubuntu-latest"], node: ["22.13"] },
9
- packageManager: "pnpm@11.18.0",
9
+ packageManager: "pnpm@11.24.0",
10
10
  templateVersion: "1.0.0",
11
11
  };
12
12
 
@@ -16,13 +16,21 @@ test("accepts a minimal valid manifest", () => {
16
16
  assert.deepEqual(errors, []);
17
17
  });
18
18
 
19
- test("rust-hybrid requires only ci and a safe gate path", () => {
19
+ test("rust-hybrid supports ci plus a configured unsigned release-candidate lane", () => {
20
20
  const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
21
21
  assert.equal(validateManifest(valid).valid, true);
22
22
  for (const path of ["/tmp/gate.sh", "../gate.sh", "scripts/gate sh", "${{ secrets.X }}", "scripts/gate;sh"]) {
23
23
  assert.equal(validateManifest({ ...valid, gate: { path } }).valid, false, path);
24
24
  }
25
25
  assert.equal(validateManifest({ ...valid, lanes: ["ci", "qualification"] }).valid, false);
26
+ const candidate = {
27
+ ...valid,
28
+ lanes: ["ci", "release-candidate"],
29
+ candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "legion-unsigned" },
30
+ };
31
+ assert.equal(validateManifest(candidate).valid, true);
32
+ assert.equal(validateManifest({ ...candidate, candidate: { ...candidate.candidate, buildScript: "echo unsafe" } }).valid, false);
33
+ assert.equal(validateManifest({ ...candidate, candidate: { ...candidate.candidate, artifactRoot: "../escape" } }).valid, false);
26
34
  });
27
35
 
28
36
  test("rejects non-object input", () => {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@rightkit/git",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Public-repo-only GitHub Actions workflow layer: templates, visibility gate, sync/drift/adopt/uninstall for right-git.",
5
+ "license": "MIT OR Apache-2.0",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "right-git": "cli/right-git.mjs"
@@ -20,7 +21,7 @@
20
21
  },
21
22
  "repository": {
22
23
  "type": "git",
23
- "url": "git+https://github.com/adrdsouza/claude.git",
24
+ "url": "git+https://github.com/bogusyogi/claude.git",
24
25
  "directory": "tools/rightkit/packages/git"
25
26
  },
26
27
  "scripts": {
package/render.test.mjs CHANGED
@@ -13,7 +13,7 @@ const FULL_MANIFEST = {
13
13
  schemaVersion: 1,
14
14
  lanes: [...KNOWN_LANES],
15
15
  matrix: { os: ["ubuntu-latest", "macos-latest", "windows-latest"], node: ["22.13", "24"] },
16
- packageManager: "pnpm@11.18.0",
16
+ packageManager: "pnpm@11.24.0",
17
17
  publish: {
18
18
  npm: { oidc: true, provenance: true },
19
19
  homebrew: { tap: "acme/homebrew-tap", formulaName: "widgets" },
@@ -45,11 +45,32 @@ test("every known lane has a template file and renders without an unresolved pla
45
45
  test("rust-hybrid ci renders its pinned package manager and gate", () => {
46
46
  const manifest = { ...FULL_MANIFEST, lanes: ["ci"], profile: "rust-hybrid", gate: { path: "scripts/gate.sh" }, templateVersion: "1.1.0" };
47
47
  const content = renderTemplate(fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8"), buildRenderContext(manifest, "/repos/widgets"));
48
- assert.match(content, /corepack prepare pnpm@11\.18\.0 --activate/);
48
+ assert.match(content, /uses: pnpm\/action-setup@v4/);
49
+ assert.match(content, /version: 11\.24\.0/);
49
50
  assert.match(content, /rustup toolchain install stable/);
50
51
  assert.match(content, /bash \.\/scripts\/gate\.sh/);
51
52
  });
52
53
 
54
+ test("rust-hybrid release candidate renders product scripts, artifact handoff, and no signing secret", () => {
55
+ const manifest = {
56
+ ...FULL_MANIFEST,
57
+ lanes: ["ci", "release-candidate"],
58
+ profile: "rust-hybrid",
59
+ gate: { path: "scripts/gate.sh" },
60
+ candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "widgets-unsigned" },
61
+ templateVersion: "1.2.0",
62
+ };
63
+ const content = renderTemplate(
64
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
65
+ buildRenderContext(manifest, "/repos/widgets"),
66
+ );
67
+ assert.match(content, /pnpm run ci:candidate/);
68
+ assert.match(content, /pnpm run ci:candidate-check/);
69
+ assert.match(content, /RIGHT_GIT_ARTIFACT_ROOT/);
70
+ assert.match(content, /actions\/upload-artifact@v4/);
71
+ assert.doesNotMatch(content, /secrets\.|AZURE_|APPLE_|certificate|private.key/i);
72
+ });
73
+
53
74
  test("render determinism: same manifest renders byte-identical output every time", () => {
54
75
  const first = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
55
76
  const second = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
@@ -59,7 +80,7 @@ test("render determinism: same manifest renders byte-identical output every time
59
80
  });
60
81
 
61
82
  test("rendered workflows never request pnpm or yarn setup-node caching before package-manager activation", () => {
62
- for (const packageManager of ["pnpm@11.18.0", "yarn@4.6.0"]) {
83
+ for (const packageManager of ["pnpm@11.24.0", "yarn@4.6.0"]) {
63
84
  const rendered = renderAllLanes({ ...FULL_MANIFEST, packageManager }, "/repos/widgets");
64
85
  for (const [lane, content] of Object.entries(rendered)) {
65
86
  assert.doesNotMatch(
@@ -71,6 +92,26 @@ test("rendered workflows never request pnpm or yarn setup-node caching before pa
71
92
  }
72
93
  });
73
94
 
95
+ test("rendered workflows bootstrap each declared package manager safely", () => {
96
+ const pnpm = renderAllLanes({ ...FULL_MANIFEST, packageManager: "pnpm@11.24.0" }, "/repos/widgets");
97
+ for (const content of Object.values(pnpm)) {
98
+ assert.match(content, /uses: pnpm\/action-setup@v4/);
99
+ assert.match(content, /version: 11\.24\.0/);
100
+ assert.doesNotMatch(content, /corepack enable/);
101
+ }
102
+
103
+ const npm = renderAllLanes({ ...FULL_MANIFEST, packageManager: "npm@11.0.0" }, "/repos/widgets");
104
+ for (const content of Object.values(npm)) {
105
+ assert.doesNotMatch(content, /pnpm\/action-setup|corepack enable/);
106
+ }
107
+
108
+ const yarn = renderAllLanes({ ...FULL_MANIFEST, packageManager: "yarn@4.6.0" }, "/repos/widgets");
109
+ for (const content of Object.values(yarn)) {
110
+ assert.match(content, /corepack prepare yarn@4\.6\.0 --activate/);
111
+ assert.doesNotMatch(content, /pnpm\/action-setup/);
112
+ }
113
+ });
114
+
74
115
  // F5 fix: a job-level `if: !private` on the real work job means GitHub
75
116
  // SKIPS that job entirely for a private repo — the nested "refuse" step
76
117
  // inside it is then unreachable and never runs, so nothing fails and the
package/sync.mjs CHANGED
@@ -70,13 +70,15 @@ export function lineDiff(oldText, newText) {
70
70
  * managed file whose lane fell out of the manifest.
71
71
  * Returns a typed result — never throws.
72
72
  */
73
- export async function planSync(repoPath, deps = {}) {
73
+ export async function planSync(repoPath, deps = {}, manifestOverride = null) {
74
74
  const visibility = await assertPublic(repoPath, deps);
75
75
  if (!visibility.allowed) {
76
76
  return { ok: false, stage: "visibility", errors: [visibility.reason], changes: [] };
77
77
  }
78
78
 
79
- const loaded = loadManifest(repoPath);
79
+ const loaded = manifestOverride === null
80
+ ? loadManifest(repoPath)
81
+ : { ok: true, manifest: manifestOverride };
80
82
  if (!loaded.ok) {
81
83
  return { ok: false, stage: "manifest", errors: loaded.errors, changes: [] };
82
84
  }
@@ -94,24 +96,38 @@ export async function planSync(repoPath, deps = {}) {
94
96
  // not installed yet
95
97
  }
96
98
  if (existing === content) {
97
- changes.push({ lane, filename, action: "unchanged", diff: [] });
99
+ changes.push({ lane, filename, action: "unchanged", before: existing, diff: [] });
100
+ } else if (existing !== null && !existing.startsWith(MANAGED_MARKER)) {
101
+ changes.push({ lane, filename, action: "managed-conflict", before: existing, diff: [] });
98
102
  } else {
99
- changes.push({ lane, filename, action: existing === null ? "create" : "update", diff: lineDiff(existing, content), content });
103
+ changes.push({ lane, filename, action: existing === null ? "create" : "update", before: existing, diff: lineDiff(existing, content), content });
100
104
  }
101
105
  }
102
106
 
103
107
  for (const managedFilename of listManagedFiles(repoPath)) {
104
108
  if (!wantedFilenames.has(managedFilename)) {
105
- changes.push({ lane: null, filename: managedFilename, action: "remove", diff: [] });
109
+ changes.push({ lane: null, filename: managedFilename, action: "remove", before: fs.readFileSync(path.join(repoPath, WORKFLOWS_SUBDIR, managedFilename), "utf8"), diff: [] });
106
110
  }
107
111
  }
108
112
 
109
- return { ok: true, stage: "planned", errors: [], changes };
113
+ const conflicts = changes.filter((change) => change.action === "managed-conflict");
114
+ return conflicts.length
115
+ ? { ok: false, stage: "managed-conflict", errors: conflicts.map((change) => `${change.filename} is unmanaged`), changes }
116
+ : { ok: true, stage: "planned", errors: [], changes };
110
117
  }
111
118
 
112
119
  /** Apply a plan produced by planSync: write/update/remove files on disk. */
113
120
  export function applyPlan(repoPath, plan) {
114
121
  const dir = path.join(repoPath, WORKFLOWS_SUBDIR);
122
+ for (const change of plan.changes) {
123
+ if (change.action === "unchanged") continue;
124
+ const target = path.join(dir, change.filename);
125
+ let current = null;
126
+ try { current = fs.readFileSync(target, "utf8"); } catch {}
127
+ if (current !== change.before) {
128
+ return { ok: false, stage: "apply-conflict", errors: [`${change.filename} changed after planning`] };
129
+ }
130
+ }
115
131
  fs.mkdirSync(dir, { recursive: true });
116
132
  for (const change of plan.changes) {
117
133
  const target = path.join(dir, change.filename);
@@ -121,6 +137,7 @@ export function applyPlan(repoPath, plan) {
121
137
  fs.rmSync(target, { force: true });
122
138
  }
123
139
  }
140
+ return { ok: true, stage: "applied", errors: [] };
124
141
  }
125
142
 
126
143
  /** Full sync: plan, print diff, apply unless dryRun. Returns the plan. */
@@ -135,7 +152,8 @@ export async function syncCommand(repoPath, { dryRun = false, deps = {} } = {})
135
152
  }
136
153
 
137
154
  if (!dryRun) {
138
- applyPlan(repoPath, plan);
155
+ const applied = applyPlan(repoPath, plan);
156
+ if (!applied.ok) return { ...plan, ...applied };
139
157
  }
140
158
  return plan;
141
159
  }
package/sync.test.mjs CHANGED
@@ -2,7 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import { syncCommand } from "./sync.mjs";
5
+ import { applyPlan, planSync, syncCommand } from "./sync.mjs";
6
6
  import { makeTempRepo, PUBLIC_DEPS, PRIVATE_DEPS, SAMPLE_MANIFEST } from "./test-support.mjs";
7
7
 
8
8
  test("sync refuses a private repo and writes nothing", async () => {
@@ -54,6 +54,27 @@ test("sync reports manifest errors instead of writing files", async () => {
54
54
  assert.equal(result.stage, "manifest");
55
55
  });
56
56
 
57
+ test("sync refuses an unmanaged same-name workflow", async () => {
58
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
59
+ const target = path.join(dir, ".github", "workflows", "ci.yml");
60
+ fs.mkdirSync(path.dirname(target), { recursive: true });
61
+ fs.writeFileSync(target, "name: handwritten\n", "utf8");
62
+ const result = await syncCommand(dir, { deps: PUBLIC_DEPS });
63
+ assert.equal(result.stage, "managed-conflict");
64
+ assert.equal(fs.readFileSync(target, "utf8"), "name: handwritten\n");
65
+ });
66
+
67
+ test("apply preflights every planned byte before writing", async () => {
68
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
69
+ const plan = await planSync(dir, PUBLIC_DEPS);
70
+ const target = path.join(dir, ".github", "workflows", "ci.yml");
71
+ fs.mkdirSync(path.dirname(target), { recursive: true });
72
+ fs.writeFileSync(target, "raced\n", "utf8");
73
+ const applied = applyPlan(dir, plan);
74
+ assert.equal(applied.stage, "apply-conflict");
75
+ assert.equal(fs.existsSync(path.join(dir, ".github", "workflows", "package-smoke.yml")), false);
76
+ });
77
+
57
78
  // F15 end-to-end: a hostile `.rightgit.json` on disk — the real attack
58
79
  // surface, not just the in-memory validateManifest() unit — must be refused
59
80
  // at the manifest stage, before renderLanes() (and therefore render.mjs's
package/templates/ci.yml CHANGED
@@ -37,7 +37,8 @@ jobs:
37
37
  - uses: actions/setup-node@v5
38
38
  with:
39
39
  node-version: ${{ matrix.node }}
40
- - run: corepack enable
40
+ package-manager-cache: false
41
+ ${PACKAGE_MANAGER_SETUP}
41
42
  - run: ${INSTALL_COMMAND}
42
43
  - run: ${PM_RUN} test
43
44
  - run: ${PM_RUN} test:random
@@ -31,6 +31,7 @@ jobs:
31
31
  - uses: actions/setup-node@v5
32
32
  with:
33
33
  node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
34
- - run: corepack enable
34
+ package-manager-cache: false
35
+ ${PACKAGE_MANAGER_SETUP}
35
36
  - run: ${INSTALL_COMMAND}
36
37
  - run: node scripts/test-package.mjs
@@ -24,6 +24,7 @@ jobs:
24
24
  - uses: actions/setup-node@v5
25
25
  with:
26
26
  node-version: ${{ matrix.node }}
27
- - run: corepack enable && corepack prepare ${PM_NAME}@${PM_VERSION} --activate
27
+ package-manager-cache: false
28
+ ${PACKAGE_MANAGER_SETUP}
28
29
  - run: rustup toolchain install stable --profile minimal
29
30
  - run: bash ./${GATE_PATH}
@@ -0,0 +1,49 @@
1
+ # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
+ # Template: profiles/rust-hybrid/release-candidate.yml | Template version: ${TEMPLATE_VERSION}
3
+ # Public CI produces unsigned candidates only. Signing credentials, finalization,
4
+ # release publication, & stable bootstrap publication remain protected-host work.
5
+ name: release-candidate
6
+ on:
7
+ workflow_dispatch:
8
+ push:
9
+ tags: ['v*']
10
+ permissions:
11
+ contents: read
12
+ jobs:
13
+ private-repo-guard:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Refuse private repository
17
+ if: ${{ github.event.repository.private }}
18
+ run: exit 1
19
+ candidate:
20
+ needs: [private-repo-guard]
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ os: ${OS_MATRIX}
25
+ node: ${NODE_MATRIX}
26
+ runs-on: ${{ matrix.os }}
27
+ steps:
28
+ - uses: actions/checkout@v5
29
+ - uses: actions/setup-node@v5
30
+ with:
31
+ node-version: ${{ matrix.node }}
32
+ package-manager-cache: false
33
+ ${PACKAGE_MANAGER_SETUP}
34
+ - run: rustup toolchain install stable --profile minimal
35
+ - run: ${INSTALL_COMMAND}
36
+ - name: Build unsigned release candidate
37
+ run: ${PM_RUN} ${CANDIDATE_BUILD_SCRIPT}
38
+ env:
39
+ RIGHT_GIT_ARTIFACT_ROOT: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}
40
+ - name: Check unsigned release candidate
41
+ run: ${PM_RUN} ${CANDIDATE_CHECK_SCRIPT}
42
+ env:
43
+ RIGHT_GIT_ARTIFACT_ROOT: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}
44
+ - name: Upload unsigned candidate handoff
45
+ uses: actions/upload-artifact@v4
46
+ with:
47
+ name: ${ARTIFACT_PREFIX}-unsigned-${{ matrix.os }}-${{ matrix.node }}
48
+ path: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}/**
49
+ if-no-files-found: error
@@ -1,7 +1,6 @@
1
1
  # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
2
  # Template: publish-npm.yml | Template version: ${TEMPLATE_VERSION}
3
- # Extracted from Cortex's immutable-release.yml publish-npm job only
4
- # (DESIGN.md §6). OIDC trusted publishing is the sole publish credential —
3
+ # OIDC trusted publishing is the sole publish credential —
5
4
  # no long-lived npm token, no signing material (invariant I2). Runs after
6
5
  # right-release has already built/signed/notarized locally and attached
7
6
  # artifacts to the GitHub Release (DESIGN.md §7); this job publishes the
@@ -38,7 +37,8 @@ jobs:
38
37
  with:
39
38
  node-version: ${NODE_PUBLISH_VERSION}
40
39
  registry-url: https://registry.npmjs.org
41
- - run: corepack enable
40
+ package-manager-cache: false
41
+ ${PACKAGE_MANAGER_SETUP}
42
42
  - run: ${INSTALL_COMMAND}
43
43
  - run: node scripts/release/build-candidate.mjs --version "${{ github.event.release.tag_name }}" --platform current --out "release/candidates/publish"
44
44
  - run: node scripts/release/check-release.mjs release/candidates/publish
@@ -76,7 +76,8 @@ jobs:
76
76
  - uses: actions/setup-node@v5
77
77
  with:
78
78
  node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
79
- - run: corepack enable
79
+ package-manager-cache: false
80
+ ${PACKAGE_MANAGER_SETUP}
80
81
  - run: ${INSTALL_COMMAND}
81
82
  - run: ruby -c release/homebrew/${HOMEBREW_FORMULA_NAME}.rb
82
83
  - name: Checkout Homebrew tap
@@ -26,7 +26,8 @@ jobs:
26
26
  - uses: actions/setup-node@v5
27
27
  with:
28
28
  node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
29
- - run: corepack enable
29
+ package-manager-cache: false
30
+ ${PACKAGE_MANAGER_SETUP}
30
31
  - run: ${INSTALL_COMMAND}
31
32
  - run: ${PM_RUN} test:all
32
33
  schema:
@@ -37,9 +38,21 @@ jobs:
37
38
  - uses: actions/setup-node@v5
38
39
  with:
39
40
  node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
40
- - run: corepack enable
41
+ package-manager-cache: false
42
+ ${PACKAGE_MANAGER_SETUP}
41
43
  - run: ${INSTALL_COMMAND}
42
- - run: node scripts/ci/check-schema.mjs || true
44
+ - name: Check schema with evidence log
45
+ shell: bash
46
+ run: |
47
+ set -o pipefail
48
+ node scripts/ci/check-schema.mjs 2>&1 | tee qualification-schema.log
49
+ - name: Upload schema evidence
50
+ if: ${{ always() }}
51
+ uses: actions/upload-artifact@v4
52
+ with:
53
+ name: qualification-schema-${{ github.run_id }}-${{ github.run_attempt }}
54
+ path: qualification-schema.log
55
+ if-no-files-found: error
43
56
  security:
44
57
  needs: [qualification]
45
58
  runs-on: ubuntu-latest
@@ -48,7 +61,8 @@ jobs:
48
61
  - uses: actions/setup-node@v5
49
62
  with:
50
63
  node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
51
- - run: corepack enable
64
+ package-manager-cache: false
65
+ ${PACKAGE_MANAGER_SETUP}
52
66
  - run: ${INSTALL_COMMAND}
53
67
  - run: node scripts/ci/check-network-boundary.mjs
54
68
  - name: Hostile-repository and secret-egress suite
@@ -61,7 +75,8 @@ jobs:
61
75
  - uses: actions/setup-node@v5
62
76
  with:
63
77
  node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
64
- - run: corepack enable
78
+ package-manager-cache: false
79
+ ${PACKAGE_MANAGER_SETUP}
65
80
  - run: ${INSTALL_COMMAND}
66
81
  - run: ${PM_RUN} test:hardening
67
82
  conformance:
@@ -72,6 +87,7 @@ jobs:
72
87
  - uses: actions/setup-node@v5
73
88
  with:
74
89
  node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
75
- - run: corepack enable
90
+ package-manager-cache: false
91
+ ${PACKAGE_MANAGER_SETUP}
76
92
  - run: ${INSTALL_COMMAND}
77
93
  - run: ${PM_RUN} test:conformance
@@ -32,7 +32,8 @@ jobs:
32
32
  - uses: actions/setup-node@v5
33
33
  with:
34
34
  node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
35
- - run: corepack enable
35
+ package-manager-cache: false
36
+ ${PACKAGE_MANAGER_SETUP}
36
37
  - run: ${INSTALL_COMMAND}
37
38
  - run: node --test tests/release-candidate.test.mjs
38
39
  - run: node scripts/release/build-candidate.mjs --platform current --out "${{ runner.temp }}/${ARTIFACT_PREFIX}-rc"
package/test-support.mjs CHANGED
@@ -28,6 +28,6 @@ export const SAMPLE_MANIFEST = {
28
28
  schemaVersion: 1,
29
29
  lanes: ["ci", "package-smoke"],
30
30
  matrix: { os: ["ubuntu-latest"], node: ["22.13"] },
31
- packageManager: "pnpm@11.18.0",
31
+ packageManager: "pnpm@11.24.0",
32
32
  templateVersion: "1.0.0",
33
33
  };
@@ -33,7 +33,7 @@ test("uninstall removes every managed file, leaving zero behind — including th
33
33
  // only ever looked at marker-bearing workflow files, so it reported success
34
34
  // while leaving the manifest behind. Reproduces the QA report's
35
35
  // "interruptedInstall" probe.
36
- test("F6: uninstall removes the manifest left behind by an install interrupted after the visibility check", async () => {
36
+ test("install rolls back precisely when visibility changes after preflight", async () => {
37
37
  const dir = makeTempRepo(null);
38
38
  let calls = 0;
39
39
  const flakyDeps = {
@@ -47,13 +47,8 @@ test("F6: uninstall removes the manifest left behind by an install interrupted a
47
47
 
48
48
  const installResult = await installCommand(dir, { lanes: ["ci"] }, flakyDeps);
49
49
  assert.equal(installResult.ok, false);
50
- assert.equal(installResult.stage, "synced");
51
- assert.ok(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), "setup: interrupted install must still leave the manifest on disk");
52
-
53
- const uninstallResult = uninstallCommand(dir);
54
- assert.equal(uninstallResult.ok, true);
55
- assert.ok(uninstallResult.removed.includes(MANIFEST_FILENAME));
56
- assert.equal(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), false, "F6: manifest must not survive uninstall after an interrupted install");
50
+ assert.equal(installResult.stage, "visibility");
51
+ assert.equal(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), false);
57
52
  });
58
53
 
59
54
  test("uninstall never touches an unmanaged file in the same directory", async () => {
@@ -75,3 +70,10 @@ test("uninstall on a repo with no managed files is a no-op, not an error", () =>
75
70
  assert.equal(result.ok, true);
76
71
  assert.deepEqual(result.removed, []);
77
72
  });
73
+
74
+ test("install validates before it creates a manifest", async () => {
75
+ const dir = makeTempRepo(null);
76
+ const result = await installCommand(dir, { lanes: ["not-a-lane"] }, PUBLIC_DEPS);
77
+ assert.equal(result.stage, "manifest-invalid");
78
+ assert.equal(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), false);
79
+ });