@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.
- package/README.md +35 -0
- package/adopt.mjs +96 -0
- package/cli/right-git.mjs +207 -0
- package/cli.test.mjs +68 -0
- package/drift.mjs +133 -0
- package/drift.test.mjs +98 -0
- package/guard-coverage.test.mjs +220 -0
- package/install.mjs +72 -0
- package/manifest.mjs +278 -0
- package/manifest.schema.json +85 -0
- package/manifest.test.mjs +196 -0
- package/pack-smoke.test.mjs +55 -0
- package/package.json +30 -0
- package/policy/visibility-gate.mjs +168 -0
- package/render.mjs +35 -0
- package/render.test.mjs +117 -0
- package/sync.mjs +139 -0
- package/sync.test.mjs +84 -0
- package/templates/ci.yml +47 -0
- package/templates/package-smoke.yml +37 -0
- package/templates/publish-npm.yml +52 -0
- package/templates/publish-package-managers.yml +157 -0
- package/templates/qualification.yml +82 -0
- package/templates/release-candidate.yml +48 -0
- package/test-support.mjs +33 -0
- package/uninstall.mjs +36 -0
- package/uninstall.test.mjs +77 -0
- package/visibility-gate.test.mjs +141 -0
|
@@ -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
|
+
}
|