@rightkit/git 0.1.0

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.
@@ -0,0 +1,220 @@
1
+ // F5 regression, two properties layered on top of each other:
2
+ //
3
+ // 1. Reachability (original F5): "every generated workflow's first job
4
+ // carries the private-repo guard" is not the same claim as "no job in a
5
+ // generated workflow can ever run in a private repo." The QA report
6
+ // proved the two diverge in publish-package-managers.yml: `winget-token`
7
+ // had no guard of its own and no `needs`, so it always ran (and always
8
+ // exited 0) regardless of repository visibility, and `winget` depended
9
+ // only on that unguarded job.
10
+ //
11
+ // 2. Loudness (the re-validation finding): even once every job is
12
+ // reachability-guarded, gating the guarded job itself with a job-level
13
+ // `if: !github.event.repository.private` makes GitHub SKIP that job for
14
+ // a private repo — the nested "refuse" step inside it is then
15
+ // unreachable and never runs. A skip is not a failure: the run can read
16
+ // as "nothing to do" instead of the loud, non-zero failure invariant I1
17
+ // requires. The fix is a single `private-repo-guard` job with NO
18
+ // job-level `if` (so it always executes) whose one step's OWN `if`
19
+ // fires only when private and exits 1 — that fails the job outright.
20
+ // Every real job `needs` it instead of gating itself.
21
+ //
22
+ // This test renders every lane and walks the job graph, asserting each job
23
+ // is either itself a "self-guard" job (property 2: no job-level `if`, so it
24
+ // unconditionally runs, plus a step whose own `if` matches the positive
25
+ // private check and whose body exits non-zero — i.e. it cannot SUCCEED in a
26
+ // private repo, and does not merely skip) or transitively guarded: it
27
+ // `needs` at least one (transitively) guarded job, and does not override
28
+ // the default `needs`-implies-`success()` gate with `always()` (which would
29
+ // make it run even after a guarded dependency failed/was skipped). This
30
+ // models real GitHub Actions semantics: an `if:` expression that does not
31
+ // itself call success()/always()/cancelled()/failure() is implicitly ANDed
32
+ // with `success()`, so a job whose only dependency failed is skipped too.
33
+ //
34
+ // A job gated the OLD way — job-level `if: !private` — is deliberately NOT
35
+ // recognized as guarded here: that shape is exactly the loudness bug, so a
36
+ // template that regressed to it must fail this test, not pass it.
37
+ //
38
+ // Fails against the pre-fix templates (winget-token: neither guarded nor
39
+ // depending on a guarded job; every guard job-level-`if` gated instead of
40
+ // self-failing) and passes after the fix.
41
+
42
+ import test from "node:test";
43
+ import assert from "node:assert/strict";
44
+ import fs from "node:fs";
45
+ import path from "node:path";
46
+ import { fileURLToPath } from "node:url";
47
+ import { renderTemplate } from "./render.mjs";
48
+ import { buildRenderContext, LANE_FILENAMES, KNOWN_LANES } from "./manifest.mjs";
49
+
50
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
51
+ const templatesDir = path.join(packageRoot, "templates");
52
+
53
+ const FULL_MANIFEST = {
54
+ schemaVersion: 1,
55
+ lanes: [...KNOWN_LANES],
56
+ matrix: { os: ["ubuntu-latest", "macos-latest", "windows-latest"], node: ["22.13", "24"] },
57
+ packageManager: "pnpm@11.18.0",
58
+ publish: {
59
+ npm: { oidc: true, provenance: true },
60
+ homebrew: { tap: "acme/homebrew-tap", formulaName: "widgets" },
61
+ winget: { packageIdentifier: "Acme.Widgets" },
62
+ },
63
+ templateVersion: "1.0.0",
64
+ };
65
+
66
+ const NEGATED_PRIVATE_RE = /!\s*github\.event\.repository\.private/;
67
+ const POSITIVE_PRIVATE_RE = /(?<!!\s*)github\.event\.repository\.private/;
68
+ const ALWAYS_RE = /\balways\(\)/;
69
+ const EXIT_NONZERO_RE = /exit\s+[1-9]/;
70
+
71
+ /**
72
+ * Minimal line-based parser for the one shape our templates use: top-level
73
+ * `jobs:` map, each job a ` name:` header, job-level `if:`/`needs:` at
74
+ * exactly 4-space indent, steps introduced by ` - ` at 6-space indent
75
+ * with their own `if:`/`run:` nested under that (see templates/*.yml). Good
76
+ * enough for our own generated YAML; not a general YAML parser.
77
+ */
78
+ function parseJobs(content) {
79
+ const lines = content.split("\n");
80
+ const jobsIdx = lines.findIndex((l) => /^jobs:\s*$/.test(l));
81
+ assert.ok(jobsIdx !== -1, "rendered workflow has no top-level jobs: map");
82
+
83
+ const jobStarts = [];
84
+ for (let i = jobsIdx + 1; i < lines.length; i++) {
85
+ if (/^ {2}\S.*:\s*$/.test(lines[i])) jobStarts.push(i);
86
+ }
87
+
88
+ const jobs = new Map();
89
+ for (let idx = 0; idx < jobStarts.length; idx++) {
90
+ const start = jobStarts[idx];
91
+ const end = idx + 1 < jobStarts.length ? jobStarts[idx + 1] : lines.length;
92
+ const name = /^ {2}(\S+):/.exec(lines[start])[1];
93
+ const block = lines.slice(start, end);
94
+
95
+ const ifLine = block.find((l) => /^ {4}if: /.test(l));
96
+ const needsLine = block.find((l) => /^ {4}needs: /.test(l));
97
+
98
+ let needs = [];
99
+ if (needsLine) {
100
+ const inline = /^ {4}needs:\s*\[(.*)\]\s*$/.exec(needsLine);
101
+ if (inline) {
102
+ needs = inline[1].split(",").map((s) => s.trim()).filter(Boolean);
103
+ } else {
104
+ const bare = /^ {4}needs:\s*(\S+)\s*$/.exec(needsLine);
105
+ if (bare) needs = [bare[1]];
106
+ }
107
+ }
108
+
109
+ // Self-guard detection: a STEP-level `if:` (any indent deeper than the
110
+ // job-level 4 spaces already captured above as `ifLine`) that positively
111
+ // references repository.private, with a non-zero `exit` appearing before
112
+ // the next step starts. Such a job cannot reach "success" in a private
113
+ // repo regardless of what its job-level `if` says (here, nothing — the
114
+ // fixed templates give these jobs no job-level `if` at all).
115
+ let selfGuardStep = false;
116
+ for (let i = 0; i < block.length; i++) {
117
+ const isStepIfLine = /^\s+if: /.test(block[i]) && !/^ {4}if: /.test(block[i]);
118
+ if (isStepIfLine && POSITIVE_PRIVATE_RE.test(block[i])) {
119
+ const nextStepOffset = block.slice(i + 1).findIndex((l) => /^ {6}- /.test(l));
120
+ const stepBody = nextStepOffset === -1 ? block.slice(i) : block.slice(i, i + 1 + nextStepOffset);
121
+ if (EXIT_NONZERO_RE.test(stepBody.join("\n"))) selfGuardStep = true;
122
+ }
123
+ }
124
+
125
+ jobs.set(name, {
126
+ ifExpr: ifLine ? ifLine.trim().slice("if: ".length) : null,
127
+ needs,
128
+ selfGuardStep,
129
+ });
130
+ }
131
+ return jobs;
132
+ }
133
+
134
+ /** True if `jobName` can never SUCCEED in a private repo (fails outright or skips). */
135
+ function isGuarded(jobs, jobName, visiting = new Set()) {
136
+ if (visiting.has(jobName)) return false; // cycle guard, shouldn't happen
137
+ visiting.add(jobName);
138
+
139
+ const job = jobs.get(jobName);
140
+ assert.ok(job, `needs references unknown job '${jobName}'`);
141
+
142
+ // Self-failing job: no job-level `if` gates it away (it always runs), but
143
+ // one of its own steps refuses (fails) when private. This is the LOUD
144
+ // shape — the job runs and fails, it is never silently skipped.
145
+ if (job.selfGuardStep) return true;
146
+
147
+ // The OLD shape (job-level `if: !private`) is deliberately NOT treated as
148
+ // guarded here — gating the whole job away is exactly the "skip instead
149
+ // of fail" bug this suite now guards against. A template that regresses
150
+ // to it must fail this assertion, not pass it via this branch.
151
+ if (job.ifExpr && NEGATED_PRIVATE_RE.test(job.ifExpr) && !job.selfGuardStep) {
152
+ return false;
153
+ }
154
+
155
+ // A custom `if:` that calls always() bypasses the default needs->success()
156
+ // gate, so a guarded dependency being skipped would NOT stop this job.
157
+ if (job.ifExpr && ALWAYS_RE.test(job.ifExpr)) return false;
158
+
159
+ // Default GitHub Actions semantics: an `if:` that doesn't call
160
+ // success()/always()/cancelled()/failure() is implicitly ANDed with
161
+ // success(), which requires every direct `needs` job to have succeeded.
162
+ // A failed or skipped dependency fails that check, so this job is
163
+ // transitively guarded if ANY one of its needs is guarded.
164
+ return job.needs.some((n) => isGuarded(jobs, n, visiting));
165
+ }
166
+
167
+ function renderAllLanes() {
168
+ const context = buildRenderContext(FULL_MANIFEST, "/repos/widgets");
169
+ const rendered = {};
170
+ for (const lane of FULL_MANIFEST.lanes) {
171
+ const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
172
+ const templateText = fs.readFileSync(templatePath, "utf8");
173
+ rendered[lane] = renderTemplate(templateText, context);
174
+ }
175
+ return rendered;
176
+ }
177
+
178
+ test("F5: every job in every rendered lane is private-guarded or transitively depends on a guarded job", () => {
179
+ const rendered = renderAllLanes();
180
+ for (const lane of KNOWN_LANES) {
181
+ const jobs = parseJobs(rendered[lane]);
182
+ assert.ok(jobs.size > 0, `lane ${lane} has no jobs`);
183
+ for (const jobName of jobs.keys()) {
184
+ assert.ok(
185
+ isGuarded(jobs, jobName),
186
+ `lane ${lane} job '${jobName}' is neither private-guarded nor depends (transitively) on a job that is — a private repo could execute it (F5)`,
187
+ );
188
+ }
189
+ }
190
+ });
191
+
192
+ // F5 loudness proof (the specific gap the re-validation found): a job that
193
+ // is only reachable through a job-level `if: !private` is SKIPPED, not
194
+ // FAILED, for a private repo — GitHub reports skipped jobs as neutral, and
195
+ // a workflow made entirely of skips/successes shows green, not the loud
196
+ // failure invariant I1 demands. This asserts the concrete structural
197
+ // precondition for the loud-failure behavior: the `private-repo-guard` job
198
+ // itself carries NO job-level `if` (so GitHub always schedules and runs it,
199
+ // private or not) and its own step-level `if` positively matches private
200
+ // with a non-zero `exit` — so when private, the job runs and then fails.
201
+ // Fails against the pre-fix shape (guard logic lived inside a job gated by
202
+ // `if: !private`, so the refusing step was itself skipped) and against any
203
+ // regression back to that shape.
204
+ test("F5: private-repo-guard job has no job-level `if` (always runs) and fails outright when private", () => {
205
+ const rendered = renderAllLanes();
206
+ for (const lane of KNOWN_LANES) {
207
+ const jobs = parseJobs(rendered[lane]);
208
+ const guard = jobs.get("private-repo-guard");
209
+ assert.ok(guard, `lane ${lane} has no private-repo-guard job`);
210
+ assert.equal(
211
+ guard.ifExpr,
212
+ null,
213
+ `lane ${lane} private-repo-guard carries a job-level 'if' (${guard.ifExpr}) — that would let it be SKIPPED instead of run-then-failed for a private repo`,
214
+ );
215
+ assert.ok(
216
+ guard.selfGuardStep,
217
+ `lane ${lane} private-repo-guard has no step that positively checks private and exits non-zero`,
218
+ );
219
+ }
220
+ });
package/install.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // First-time setup: write `.rightgit.json` into a target repo and render its
2
+ // workflows (DESIGN.md §4). Refuses private/internal/unknown repos (I1) and
3
+ // refuses to clobber an already-installed repo — that's what `sync` is for.
4
+ //
5
+ // Matrix/packageManager/templateVersion defaults are the exact example
6
+ // values DESIGN.md §4 documents for `.rightgit.json`, not invented policy.
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { assertPublic } from "./policy/visibility-gate.mjs";
11
+ import { MANIFEST_FILENAME, validateManifest } from "./manifest.mjs";
12
+ import { syncCommand } from "./sync.mjs";
13
+
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";
16
+ export const DEFAULT_TEMPLATE_VERSION = "1.0.0";
17
+
18
+ /**
19
+ * opts: { lanes: string[], matrix?, packageManager?, templateVersion?,
20
+ * provenance?: boolean, homebrewTap?, homebrewFormula?, wingetId? }
21
+ * Never throws. Returns a typed result: { ok, stage, errors, manifestPath?, plan? }
22
+ */
23
+ export async function installCommand(repoPath, opts = {}, deps = {}) {
24
+ const visibility = await assertPublic(repoPath, deps);
25
+ if (!visibility.allowed) {
26
+ return { ok: false, stage: "visibility", errors: [visibility.reason] };
27
+ }
28
+
29
+ const manifestPath = path.join(repoPath, MANIFEST_FILENAME);
30
+ if (fs.existsSync(manifestPath)) {
31
+ return { ok: false, stage: "exists", errors: [`${manifestPath} already exists; use 'right-git sync' to update an installed repo`] };
32
+ }
33
+
34
+ const lanes = opts.lanes;
35
+ if (!Array.isArray(lanes) || lanes.length === 0) {
36
+ return { ok: false, stage: "lanes", errors: ["--lanes is required, e.g. --lanes ci,qualification,package-smoke"] };
37
+ }
38
+
39
+ const manifest = {
40
+ schemaVersion: 1,
41
+ lanes,
42
+ matrix: opts.matrix || DEFAULT_MATRIX,
43
+ packageManager: opts.packageManager || DEFAULT_PACKAGE_MANAGER,
44
+ templateVersion: opts.templateVersion || DEFAULT_TEMPLATE_VERSION,
45
+ };
46
+
47
+ if (lanes.includes("publish-npm")) {
48
+ manifest.publish = { ...manifest.publish, npm: { oidc: true, provenance: opts.provenance !== false } };
49
+ }
50
+ if (lanes.includes("publish-package-managers")) {
51
+ if (!opts.homebrewTap || !opts.homebrewFormula || !opts.wingetId) {
52
+ return {
53
+ ok: false,
54
+ stage: "publish-package-managers-config",
55
+ errors: ["lane 'publish-package-managers' requires --homebrew-tap, --homebrew-formula, and --winget-id"],
56
+ };
57
+ }
58
+ manifest.publish = {
59
+ ...manifest.publish,
60
+ homebrew: { tap: opts.homebrewTap, formulaName: opts.homebrewFormula },
61
+ winget: { packageIdentifier: opts.wingetId },
62
+ };
63
+ }
64
+
65
+ const { valid, errors } = validateManifest(manifest);
66
+ if (!valid) return { ok: false, stage: "manifest-invalid", errors };
67
+
68
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
69
+
70
+ const plan = await syncCommand(repoPath, { dryRun: false, deps });
71
+ return { ok: plan.ok, stage: "synced", errors: plan.errors || [], manifestPath, plan };
72
+ }
package/manifest.mjs ADDED
@@ -0,0 +1,278 @@
1
+ // Loader and hand-rolled validator for the per-repo `.rightgit.json` manifest.
2
+ // Mirrors the shape documented in manifest.schema.json. No ajv dependency —
3
+ // validation is plain JS so the package stays dependency-free (node builtins only).
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ export const MANIFEST_FILENAME = ".rightgit.json";
9
+
10
+ export const KNOWN_LANES = Object.freeze([
11
+ "ci",
12
+ "qualification",
13
+ "package-smoke",
14
+ "release-candidate",
15
+ "publish-npm",
16
+ "publish-package-managers",
17
+ ]);
18
+
19
+ // Rendered filename for each lane, installed under <repo>/.github/workflows/.
20
+ export const LANE_FILENAMES = Object.freeze({
21
+ ci: "ci.yml",
22
+ qualification: "qualification.yml",
23
+ "package-smoke": "package-smoke.yml",
24
+ "release-candidate": "release-candidate.yml",
25
+ "publish-npm": "publish-npm.yml",
26
+ "publish-package-managers": "publish-package-managers.yml",
27
+ });
28
+
29
+ const PACKAGE_MANAGER_RE = /^(pnpm|npm|yarn)@\d+\.\d+\.\d+$/;
30
+ const TEMPLATE_VERSION_RE = /^\d+\.\d+\.\d+$/;
31
+
32
+ // F15 fix: every one of these fields is manifest (attacker-controlled)
33
+ // input that flows verbatim into a rendered workflow file via render.mjs's
34
+ // literal `${TOKEN}` substitution — render.mjs does no escaping, by design
35
+ // (it is a byte-deterministic template engine, not a YAML/expression-aware
36
+ // one). The re-validation proved a hostile `publish.homebrew.formulaName`
37
+ // containing GitHub Actions expression syntax (`${{ secrets.APPLE_... }}`)
38
+ // survived unmodified into rendered YAML, which GitHub Actions would then
39
+ // evaluate as a real expression — a workflow-expression injection, not just
40
+ // a cosmetic issue. The fix is at the INPUT boundary, not the render step:
41
+ // every manifest field that reaches a template must match a strict
42
+ // allowlist charset that cannot express `${{ }}`, backticks, `$()`,
43
+ // newlines, or any other shell/YAML/expression metacharacter — validated
44
+ // once here, so render.mjs can stay a dumb, deterministic string replacer.
45
+ const SAFE_MATRIX_VALUE_RE = /^[A-Za-z0-9][A-Za-z0-9._/*-]*$/;
46
+ const GITHUB_OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
47
+ const GITHUB_REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,99})$/;
48
+ const HOMEBREW_FORMULA_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9+_.-]*$/;
49
+ const WINGET_PACKAGE_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$/;
50
+
51
+ const TOP_LEVEL_KEYS = new Set([
52
+ "schemaVersion",
53
+ "lanes",
54
+ "matrix",
55
+ "packageManager",
56
+ "publish",
57
+ "templateVersion",
58
+ ]);
59
+
60
+ /**
61
+ * Validate a parsed manifest object against the right-git manifest contract.
62
+ * Never throws. Returns { valid, errors }.
63
+ */
64
+ export function validateManifest(manifest) {
65
+ const errors = [];
66
+
67
+ if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
68
+ return { valid: false, errors: ["manifest must be a JSON object"] };
69
+ }
70
+
71
+ for (const key of Object.keys(manifest)) {
72
+ if (!TOP_LEVEL_KEYS.has(key)) errors.push(`unknown top-level field: ${key}`);
73
+ }
74
+
75
+ if (manifest.schemaVersion !== 1) {
76
+ errors.push("schemaVersion must be exactly 1");
77
+ }
78
+
79
+ let lanes = [];
80
+ if (!Array.isArray(manifest.lanes) || manifest.lanes.length === 0) {
81
+ errors.push("lanes must be a non-empty array");
82
+ } else {
83
+ lanes = manifest.lanes;
84
+ const seen = new Set();
85
+ for (const lane of lanes) {
86
+ if (typeof lane !== "string" || !KNOWN_LANES.includes(lane)) {
87
+ errors.push(`unknown lane: ${JSON.stringify(lane)}`);
88
+ } else if (seen.has(lane)) {
89
+ errors.push(`duplicate lane: ${lane}`);
90
+ } else {
91
+ seen.add(lane);
92
+ }
93
+ }
94
+ }
95
+
96
+ if (manifest.matrix === null || typeof manifest.matrix !== "object" || Array.isArray(manifest.matrix)) {
97
+ errors.push("matrix must be an object with os and node arrays");
98
+ } else {
99
+ for (const field of ["os", "node"]) {
100
+ const value = manifest.matrix[field];
101
+ if (!Array.isArray(value) || value.length === 0 || !value.every((v) => typeof v === "string" && SAFE_MATRIX_VALUE_RE.test(v))) {
102
+ errors.push(
103
+ `matrix.${field} must be a non-empty array of safe runner/version labels (letters, digits, '.', '_', '-', '/', '*' only — no expression or shell syntax)`,
104
+ );
105
+ }
106
+ }
107
+ for (const key of Object.keys(manifest.matrix)) {
108
+ if (key !== "os" && key !== "node") errors.push(`unknown matrix field: ${key}`);
109
+ }
110
+ }
111
+
112
+ if (typeof manifest.packageManager !== "string" || !PACKAGE_MANAGER_RE.test(manifest.packageManager)) {
113
+ errors.push("packageManager must match '<pnpm|npm|yarn>@X.Y.Z'");
114
+ }
115
+
116
+ if (typeof manifest.templateVersion !== "string" || !TEMPLATE_VERSION_RE.test(manifest.templateVersion)) {
117
+ errors.push("templateVersion must be a semver string 'X.Y.Z'");
118
+ }
119
+
120
+ const publish = manifest.publish;
121
+ if (publish !== undefined) {
122
+ if (publish === null || typeof publish !== "object" || Array.isArray(publish)) {
123
+ errors.push("publish must be an object");
124
+ } else {
125
+ for (const key of Object.keys(publish)) {
126
+ if (!["npm", "homebrew", "winget"].includes(key)) errors.push(`unknown publish field: ${key}`);
127
+ }
128
+ if (publish.npm !== undefined) {
129
+ const npm = publish.npm;
130
+ if (npm === null || typeof npm !== "object") {
131
+ errors.push("publish.npm must be an object");
132
+ } else {
133
+ if (npm.oidc !== true) errors.push("publish.npm.oidc must be true (I2: OIDC is the only publish credential)");
134
+ if (typeof npm.provenance !== "boolean") errors.push("publish.npm.provenance must be a boolean");
135
+ }
136
+ }
137
+ if (publish.homebrew !== undefined) {
138
+ const hb = publish.homebrew;
139
+ if (hb === null || typeof hb !== "object") {
140
+ errors.push("publish.homebrew must be an object");
141
+ } else {
142
+ const tapMatch = typeof hb.tap === "string" ? /^([^/]+)\/([^/]+)$/.exec(hb.tap) : null;
143
+ if (!tapMatch || !GITHUB_OWNER_RE.test(tapMatch[1]) || !GITHUB_REPO_RE.test(tapMatch[2])) {
144
+ errors.push(
145
+ "publish.homebrew.tap must be 'owner/repo' using GitHub-safe characters only (letters, digits, '-', '_', '.' — no expression or shell syntax)",
146
+ );
147
+ }
148
+ if (typeof hb.formulaName !== "string" || !HOMEBREW_FORMULA_NAME_RE.test(hb.formulaName)) {
149
+ errors.push(
150
+ "publish.homebrew.formulaName must be a Homebrew-safe identifier (letters, digits, '+', '_', '.', '-' only — no expression or shell syntax)",
151
+ );
152
+ }
153
+ }
154
+ }
155
+ if (publish.winget !== undefined) {
156
+ const wg = publish.winget;
157
+ if (wg === null || typeof wg !== "object" || typeof wg.packageIdentifier !== "string" || !WINGET_PACKAGE_IDENTIFIER_RE.test(wg.packageIdentifier)) {
158
+ errors.push(
159
+ "publish.winget.packageIdentifier must be a dotted WinGet-safe identifier, e.g. 'Publisher.Package' (letters, digits, '-', '.' only — no expression or shell syntax)",
160
+ );
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ if (lanes.includes("publish-npm")) {
167
+ if (!publish || !publish.npm) {
168
+ errors.push("lane 'publish-npm' requires publish.npm { oidc: true, provenance }");
169
+ }
170
+ }
171
+
172
+ if (lanes.includes("publish-package-managers")) {
173
+ if (!publish || !publish.homebrew) {
174
+ errors.push("lane 'publish-package-managers' requires publish.homebrew { tap, formulaName }");
175
+ }
176
+ if (!publish || !publish.winget) {
177
+ errors.push("lane 'publish-package-managers' requires publish.winget { packageIdentifier }");
178
+ }
179
+ }
180
+
181
+ return { valid: errors.length === 0, errors };
182
+ }
183
+
184
+ /**
185
+ * Read and validate `<repoPath>/.rightgit.json`.
186
+ * Never throws — returns a typed result: { ok, manifest, errors, path }.
187
+ */
188
+ export function loadManifest(repoPath) {
189
+ const manifestPath = path.join(repoPath, MANIFEST_FILENAME);
190
+ let raw;
191
+ try {
192
+ raw = fs.readFileSync(manifestPath, "utf8");
193
+ } catch (error) {
194
+ return { ok: false, manifest: null, errors: [`cannot read ${manifestPath}: ${error.message}`], path: manifestPath };
195
+ }
196
+
197
+ let manifest;
198
+ try {
199
+ manifest = JSON.parse(raw);
200
+ } catch (error) {
201
+ return { ok: false, manifest: null, errors: [`invalid JSON in ${manifestPath}: ${error.message}`], path: manifestPath };
202
+ }
203
+
204
+ const { valid, errors } = validateManifest(manifest);
205
+ if (!valid) {
206
+ return { ok: false, manifest, errors, path: manifestPath };
207
+ }
208
+ return { ok: true, manifest, errors: [], path: manifestPath };
209
+ }
210
+
211
+ function parsePackageManager(spec) {
212
+ const match = /^(pnpm|npm|yarn)@(\d+\.\d+\.\d+)$/.exec(spec);
213
+ if (!match) throw new Error(`invalid packageManager spec: ${spec}`);
214
+ const [, name, version] = match;
215
+ const installCommand = {
216
+ pnpm: "pnpm install --frozen-lockfile",
217
+ npm: "npm ci",
218
+ yarn: "yarn install --frozen-lockfile",
219
+ }[name];
220
+ const runPrefix = { pnpm: "pnpm run", npm: "npm run", yarn: "yarn run" }[name];
221
+ return { name, version, installCommand, runPrefix };
222
+ }
223
+
224
+ function yamlFlowStringList(values) {
225
+ return `[${values.map((v) => JSON.stringify(String(v))).join(", ")}]`;
226
+ }
227
+
228
+ /**
229
+ * Derive one project name from the target repo's package.json, falling back
230
+ * to the repo directory's basename. Never throws.
231
+ */
232
+ export function deriveProjectName(repoPath) {
233
+ try {
234
+ const pkgRaw = fs.readFileSync(path.join(repoPath, "package.json"), "utf8");
235
+ const pkg = JSON.parse(pkgRaw);
236
+ if (typeof pkg.name === "string" && pkg.name.length > 0) {
237
+ return pkg.name.replace(/^@[^/]+\//, "");
238
+ }
239
+ } catch {
240
+ // fall through to directory name
241
+ }
242
+ return path.basename(path.resolve(repoPath));
243
+ }
244
+
245
+ /**
246
+ * Build the flat placeholder -> value map used by render.mjs, from a
247
+ * validated manifest plus the target repo's derived project name.
248
+ */
249
+ export function buildRenderContext(manifest, repoPath) {
250
+ const pm = parsePackageManager(manifest.packageManager);
251
+ const projectName = deriveProjectName(repoPath);
252
+ const artifactPrefix = projectName.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
253
+
254
+ const context = {
255
+ OS_MATRIX: yamlFlowStringList(manifest.matrix.os),
256
+ NODE_MATRIX: yamlFlowStringList(manifest.matrix.node),
257
+ NODE_PUBLISH_VERSION: JSON.stringify(String(manifest.matrix.node[0])),
258
+ PM_NAME: pm.name,
259
+ PM_CACHE: pm.name,
260
+ INSTALL_COMMAND: pm.installCommand,
261
+ PM_RUN: pm.runPrefix,
262
+ ARTIFACT_PREFIX: artifactPrefix,
263
+ TEMPLATE_VERSION: manifest.templateVersion,
264
+ };
265
+
266
+ if (manifest.publish?.npm) {
267
+ context.NPM_PROVENANCE_FLAG = manifest.publish.npm.provenance ? " --provenance" : "";
268
+ }
269
+ if (manifest.publish?.homebrew) {
270
+ context.HOMEBREW_TAP = manifest.publish.homebrew.tap;
271
+ context.HOMEBREW_FORMULA_NAME = manifest.publish.homebrew.formulaName;
272
+ }
273
+ if (manifest.publish?.winget) {
274
+ context.WINGET_PACKAGE_IDENTIFIER = manifest.publish.winget.packageIdentifier;
275
+ }
276
+
277
+ return context;
278
+ }
@@ -0,0 +1,85 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://rightkit.dev/schemas/rightgit-manifest-v1.json",
4
+ "title": "right-git per-repo manifest (.rightgit.json)",
5
+ "description": "Declares which right-git lanes a public repo installs and how templates should be parameterized. Hand-validated by manifest.mjs — this file documents the contract, it is not fed to a JSON Schema validator at runtime.",
6
+ "type": "object",
7
+ "required": ["schemaVersion", "lanes", "matrix", "packageManager", "templateVersion"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "const": 1
12
+ },
13
+ "lanes": {
14
+ "type": "array",
15
+ "minItems": 1,
16
+ "items": {
17
+ "enum": [
18
+ "ci",
19
+ "qualification",
20
+ "package-smoke",
21
+ "release-candidate",
22
+ "publish-npm",
23
+ "publish-package-managers"
24
+ ]
25
+ }
26
+ },
27
+ "matrix": {
28
+ "type": "object",
29
+ "required": ["os", "node"],
30
+ "additionalProperties": false,
31
+ "properties": {
32
+ "os": {
33
+ "type": "array",
34
+ "minItems": 1,
35
+ "items": { "type": "string", "minLength": 1 }
36
+ },
37
+ "node": {
38
+ "type": "array",
39
+ "minItems": 1,
40
+ "items": { "type": "string", "minLength": 1 }
41
+ }
42
+ }
43
+ },
44
+ "packageManager": {
45
+ "type": "string",
46
+ "pattern": "^(pnpm|npm|yarn)@\\d+\\.\\d+\\.\\d+$"
47
+ },
48
+ "publish": {
49
+ "type": "object",
50
+ "additionalProperties": false,
51
+ "properties": {
52
+ "npm": {
53
+ "type": "object",
54
+ "required": ["oidc", "provenance"],
55
+ "additionalProperties": false,
56
+ "properties": {
57
+ "oidc": { "type": "boolean", "const": true },
58
+ "provenance": { "type": "boolean" }
59
+ }
60
+ },
61
+ "homebrew": {
62
+ "type": "object",
63
+ "required": ["tap", "formulaName"],
64
+ "additionalProperties": false,
65
+ "properties": {
66
+ "tap": { "type": "string", "pattern": "^[^/]+/[^/]+$" },
67
+ "formulaName": { "type": "string", "minLength": 1 }
68
+ }
69
+ },
70
+ "winget": {
71
+ "type": "object",
72
+ "required": ["packageIdentifier"],
73
+ "additionalProperties": false,
74
+ "properties": {
75
+ "packageIdentifier": { "type": "string", "minLength": 1 }
76
+ }
77
+ }
78
+ }
79
+ },
80
+ "templateVersion": {
81
+ "type": "string",
82
+ "pattern": "^\\d+\\.\\d+\\.\\d+$"
83
+ }
84
+ }
85
+ }