@rightkit/git 0.1.0 → 0.2.1
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 +6 -0
- package/cli/right-git.mjs +4 -2
- package/guard-coverage.test.mjs +16 -1
- package/install.mjs +15 -7
- package/manifest.mjs +23 -1
- package/manifest.schema.json +5 -0
- package/manifest.test.mjs +10 -1
- package/package.json +7 -7
- package/render.test.mjs +43 -1
- package/sync.mjs +28 -8
- package/sync.test.mjs +22 -1
- package/templates/ci.yml +2 -2
- package/templates/package-smoke.yml +2 -2
- package/templates/profiles/rust-hybrid/ci.yml +30 -0
- package/templates/publish-npm.yml +3 -4
- package/templates/publish-package-managers.yml +2 -2
- package/templates/qualification.yml +22 -11
- package/templates/release-candidate.yml +2 -2
- package/test-support.mjs +1 -1
- package/uninstall.test.mjs +10 -8
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).
|
|
@@ -33,3 +35,7 @@ npm install -g @rightkit/git # or: npx @rightkit/git --help
|
|
|
33
35
|
```
|
|
34
36
|
|
|
35
37
|
Node builtins only; no runtime dependencies.
|
|
38
|
+
|
|
39
|
+
## Profiles
|
|
40
|
+
|
|
41
|
+
`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.
|
package/cli/right-git.mjs
CHANGED
|
@@ -79,7 +79,7 @@ async function runInstall(argv) {
|
|
|
79
79
|
const { positional, flags } = parseFlags(argv);
|
|
80
80
|
const repoPath = positional[0];
|
|
81
81
|
if (!repoPath) {
|
|
82
|
-
console.error("right-git install <repo-path> --lanes ci,qualification,...");
|
|
82
|
+
console.error("right-git install <repo-path> --lanes ci,qualification,... [--profile node|rust-hybrid --gate-path scripts/gate.sh]");
|
|
83
83
|
return 2;
|
|
84
84
|
}
|
|
85
85
|
const opts = {
|
|
@@ -88,6 +88,8 @@ async function runInstall(argv) {
|
|
|
88
88
|
homebrewFormula: flags["homebrew-formula"],
|
|
89
89
|
wingetId: flags["winget-id"],
|
|
90
90
|
provenance: flags["no-provenance"] ? false : undefined,
|
|
91
|
+
profile: flags.profile,
|
|
92
|
+
gatePath: flags["gate-path"],
|
|
91
93
|
};
|
|
92
94
|
const result = await installCommand(path.resolve(repoPath), opts);
|
|
93
95
|
if (!result.ok) {
|
|
@@ -196,7 +198,7 @@ function printHelp() {
|
|
|
196
198
|
console.log(`right-git <command> [options]
|
|
197
199
|
|
|
198
200
|
Commands:
|
|
199
|
-
install <repo-path> --lanes ci
|
|
201
|
+
install <repo-path> --lanes ci,... [--profile rust-hybrid --gate-path scripts/gate.sh] Write .rightgit.json and render workflows; refuses private repos
|
|
200
202
|
sync <repo-path> [--dry-run] Re-render templates from .rightgit.json, show diff, apply
|
|
201
203
|
drift <repo-path> CI-safe check: installed workflows == rendered templates?
|
|
202
204
|
drift --all [workspace-root] Drift-check + re-check visibility for every managed repo found
|
package/guard-coverage.test.mjs
CHANGED
|
@@ -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.
|
|
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" },
|
|
@@ -218,3 +218,18 @@ test("F5: private-repo-guard job has no job-level `if` (always runs) and fails o
|
|
|
218
218
|
);
|
|
219
219
|
}
|
|
220
220
|
});
|
|
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
|
+
|
|
229
|
+
test("rust-hybrid ci keeps its work behind the public-repository guard", () => {
|
|
230
|
+
const manifest = { ...FULL_MANIFEST, lanes: ["ci"], profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
|
|
231
|
+
const template = fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8");
|
|
232
|
+
const jobs = parseJobs(renderTemplate(template, buildRenderContext(manifest, "/repos/widgets")));
|
|
233
|
+
assert.ok(isGuarded(jobs, "private-repo-guard"));
|
|
234
|
+
assert.ok(isGuarded(jobs, "ci"));
|
|
235
|
+
});
|
package/install.mjs
CHANGED
|
@@ -9,11 +9,11 @@ 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 {
|
|
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.
|
|
16
|
-
export const DEFAULT_TEMPLATE_VERSION = "1.
|
|
15
|
+
export const DEFAULT_PACKAGE_MANAGER = "pnpm@11.24.0";
|
|
16
|
+
export const DEFAULT_TEMPLATE_VERSION = "1.1.0";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* opts: { lanes: string[], matrix?, packageManager?, templateVersion?,
|
|
@@ -43,6 +43,8 @@ export async function installCommand(repoPath, opts = {}, deps = {}) {
|
|
|
43
43
|
packageManager: opts.packageManager || DEFAULT_PACKAGE_MANAGER,
|
|
44
44
|
templateVersion: opts.templateVersion || DEFAULT_TEMPLATE_VERSION,
|
|
45
45
|
};
|
|
46
|
+
if (opts.profile) manifest.profile = opts.profile;
|
|
47
|
+
if (opts.gatePath) manifest.gate = { path: opts.gatePath };
|
|
46
48
|
|
|
47
49
|
if (lanes.includes("publish-npm")) {
|
|
48
50
|
manifest.publish = { ...manifest.publish, npm: { oidc: true, provenance: opts.provenance !== false } };
|
|
@@ -65,8 +67,14 @@ export async function installCommand(repoPath, opts = {}, deps = {}) {
|
|
|
65
67
|
const { valid, errors } = validateManifest(manifest);
|
|
66
68
|
if (!valid) return { ok: false, stage: "manifest-invalid", errors };
|
|
67
69
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 };
|
|
72
80
|
}
|
package/manifest.mjs
CHANGED
|
@@ -55,7 +55,10 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
55
55
|
"packageManager",
|
|
56
56
|
"publish",
|
|
57
57
|
"templateVersion",
|
|
58
|
+
"profile",
|
|
59
|
+
"gate",
|
|
58
60
|
]);
|
|
61
|
+
const GATE_PATH_RE = /^(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
64
|
* Validate a parsed manifest object against the right-git manifest contract.
|
|
@@ -117,6 +120,17 @@ export function validateManifest(manifest) {
|
|
|
117
120
|
errors.push("templateVersion must be a semver string 'X.Y.Z'");
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
const profile = manifest.profile === undefined ? "node" : manifest.profile;
|
|
124
|
+
if (profile !== "node" && profile !== "rust-hybrid") errors.push("profile must be 'node' or 'rust-hybrid'");
|
|
125
|
+
if (profile === "rust-hybrid") {
|
|
126
|
+
if (lanes.length !== 1 || lanes[0] !== "ci") errors.push("profile 'rust-hybrid' supports only lane 'ci'");
|
|
127
|
+
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
|
+
errors.push("profile 'rust-hybrid' requires gate.path with a safe relative path");
|
|
129
|
+
}
|
|
130
|
+
} else if (manifest.gate !== undefined) {
|
|
131
|
+
errors.push("gate is only valid for profile 'rust-hybrid'");
|
|
132
|
+
}
|
|
133
|
+
|
|
120
134
|
const publish = manifest.publish;
|
|
121
135
|
if (publish !== undefined) {
|
|
122
136
|
if (publish === null || typeof publish !== "object" || Array.isArray(publish)) {
|
|
@@ -218,7 +232,12 @@ function parsePackageManager(spec) {
|
|
|
218
232
|
yarn: "yarn install --frozen-lockfile",
|
|
219
233
|
}[name];
|
|
220
234
|
const runPrefix = { pnpm: "pnpm run", npm: "npm run", yarn: "yarn run" }[name];
|
|
221
|
-
|
|
235
|
+
const setup = {
|
|
236
|
+
pnpm: ` - uses: pnpm/action-setup@v4\n with:\n version: ${version}`,
|
|
237
|
+
npm: "",
|
|
238
|
+
yarn: ` - run: corepack enable && corepack prepare yarn@${version} --activate`,
|
|
239
|
+
}[name];
|
|
240
|
+
return { name, version, installCommand, runPrefix, setup };
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
function yamlFlowStringList(values) {
|
|
@@ -256,11 +275,14 @@ export function buildRenderContext(manifest, repoPath) {
|
|
|
256
275
|
NODE_MATRIX: yamlFlowStringList(manifest.matrix.node),
|
|
257
276
|
NODE_PUBLISH_VERSION: JSON.stringify(String(manifest.matrix.node[0])),
|
|
258
277
|
PM_NAME: pm.name,
|
|
278
|
+
PM_VERSION: pm.version,
|
|
259
279
|
PM_CACHE: pm.name,
|
|
260
280
|
INSTALL_COMMAND: pm.installCommand,
|
|
261
281
|
PM_RUN: pm.runPrefix,
|
|
282
|
+
PACKAGE_MANAGER_SETUP: pm.setup,
|
|
262
283
|
ARTIFACT_PREFIX: artifactPrefix,
|
|
263
284
|
TEMPLATE_VERSION: manifest.templateVersion,
|
|
285
|
+
GATE_PATH: manifest.gate?.path,
|
|
264
286
|
};
|
|
265
287
|
|
|
266
288
|
if (manifest.publish?.npm) {
|
package/manifest.schema.json
CHANGED
|
@@ -81,5 +81,10 @@
|
|
|
81
81
|
"type": "string",
|
|
82
82
|
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
|
83
83
|
}
|
|
84
|
+
,"profile": { "enum": ["node", "rust-hybrid"] },
|
|
85
|
+
"gate": {
|
|
86
|
+
"type": "object", "required": ["path"], "additionalProperties": false,
|
|
87
|
+
"properties": { "path": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$" } }
|
|
88
|
+
}
|
|
84
89
|
}
|
|
85
90
|
}
|
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.
|
|
9
|
+
packageManager: "pnpm@11.24.0",
|
|
10
10
|
templateVersion: "1.0.0",
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -16,6 +16,15 @@ 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", () => {
|
|
20
|
+
const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
|
|
21
|
+
assert.equal(validateManifest(valid).valid, true);
|
|
22
|
+
for (const path of ["/tmp/gate.sh", "../gate.sh", "scripts/gate sh", "${{ secrets.X }}", "scripts/gate;sh"]) {
|
|
23
|
+
assert.equal(validateManifest({ ...valid, gate: { path } }).valid, false, path);
|
|
24
|
+
}
|
|
25
|
+
assert.equal(validateManifest({ ...valid, lanes: ["ci", "qualification"] }).valid, false);
|
|
26
|
+
});
|
|
27
|
+
|
|
19
28
|
test("rejects non-object input", () => {
|
|
20
29
|
for (const bad of [null, undefined, "not an object", 42, ["array"]]) {
|
|
21
30
|
const { valid, errors } = validateManifest(bad);
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/git",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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"
|
|
@@ -14,17 +15,16 @@
|
|
|
14
15
|
"*.json"
|
|
15
16
|
],
|
|
16
17
|
"sideEffects": false,
|
|
17
|
-
"scripts": {
|
|
18
|
-
"test": "node --test *.test.mjs"
|
|
19
|
-
},
|
|
20
18
|
"publishConfig": {
|
|
21
19
|
"registry": "https://registry.npmjs.org/",
|
|
22
20
|
"access": "public"
|
|
23
21
|
},
|
|
24
22
|
"repository": {
|
|
25
23
|
"type": "git",
|
|
26
|
-
"url": "git+https://github.com/
|
|
24
|
+
"url": "git+https://github.com/bogusyogi/claude.git",
|
|
27
25
|
"directory": "tools/rightkit/packages/git"
|
|
28
26
|
},
|
|
29
|
-
"
|
|
30
|
-
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test *.test.mjs"
|
|
29
|
+
}
|
|
30
|
+
}
|
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.
|
|
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" },
|
|
@@ -42,6 +42,15 @@ test("every known lane has a template file and renders without an unresolved pla
|
|
|
42
42
|
assert.equal(Object.keys(rendered).length, KNOWN_LANES.length);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test("rust-hybrid ci renders its pinned package manager and gate", () => {
|
|
46
|
+
const manifest = { ...FULL_MANIFEST, lanes: ["ci"], profile: "rust-hybrid", gate: { path: "scripts/gate.sh" }, templateVersion: "1.1.0" };
|
|
47
|
+
const content = renderTemplate(fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8"), buildRenderContext(manifest, "/repos/widgets"));
|
|
48
|
+
assert.match(content, /uses: pnpm\/action-setup@v4/);
|
|
49
|
+
assert.match(content, /version: 11\.24\.0/);
|
|
50
|
+
assert.match(content, /rustup toolchain install stable/);
|
|
51
|
+
assert.match(content, /bash \.\/scripts\/gate\.sh/);
|
|
52
|
+
});
|
|
53
|
+
|
|
45
54
|
test("render determinism: same manifest renders byte-identical output every time", () => {
|
|
46
55
|
const first = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
|
|
47
56
|
const second = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
|
|
@@ -50,6 +59,39 @@ test("render determinism: same manifest renders byte-identical output every time
|
|
|
50
59
|
}
|
|
51
60
|
});
|
|
52
61
|
|
|
62
|
+
test("rendered workflows never request pnpm or yarn setup-node caching before package-manager activation", () => {
|
|
63
|
+
for (const packageManager of ["pnpm@11.24.0", "yarn@4.6.0"]) {
|
|
64
|
+
const rendered = renderAllLanes({ ...FULL_MANIFEST, packageManager }, "/repos/widgets");
|
|
65
|
+
for (const [lane, content] of Object.entries(rendered)) {
|
|
66
|
+
assert.doesNotMatch(
|
|
67
|
+
content,
|
|
68
|
+
/^\s*cache:\s*(?:pnpm|yarn)\s*$/m,
|
|
69
|
+
`lane ${lane} must not ask actions/setup-node for ${packageManager.split("@")[0]} caching before corepack activation`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("rendered workflows bootstrap each declared package manager safely", () => {
|
|
76
|
+
const pnpm = renderAllLanes({ ...FULL_MANIFEST, packageManager: "pnpm@11.24.0" }, "/repos/widgets");
|
|
77
|
+
for (const content of Object.values(pnpm)) {
|
|
78
|
+
assert.match(content, /uses: pnpm\/action-setup@v4/);
|
|
79
|
+
assert.match(content, /version: 11\.24\.0/);
|
|
80
|
+
assert.doesNotMatch(content, /corepack enable/);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const npm = renderAllLanes({ ...FULL_MANIFEST, packageManager: "npm@11.0.0" }, "/repos/widgets");
|
|
84
|
+
for (const content of Object.values(npm)) {
|
|
85
|
+
assert.doesNotMatch(content, /pnpm\/action-setup|corepack enable/);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const yarn = renderAllLanes({ ...FULL_MANIFEST, packageManager: "yarn@4.6.0" }, "/repos/widgets");
|
|
89
|
+
for (const content of Object.values(yarn)) {
|
|
90
|
+
assert.match(content, /corepack prepare yarn@4\.6\.0 --activate/);
|
|
91
|
+
assert.doesNotMatch(content, /pnpm\/action-setup/);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
53
95
|
// F5 fix: a job-level `if: !private` on the real work job means GitHub
|
|
54
96
|
// SKIPS that job entirely for a private repo — the nested "refuse" step
|
|
55
97
|
// inside it is then unreachable and never runs, so nothing fails and the
|
package/sync.mjs
CHANGED
|
@@ -18,7 +18,9 @@ export function renderLanes(manifest, repoPath) {
|
|
|
18
18
|
const context = buildRenderContext(manifest, repoPath);
|
|
19
19
|
return manifest.lanes.map((lane) => {
|
|
20
20
|
const filename = LANE_FILENAMES[lane];
|
|
21
|
-
const templatePath =
|
|
21
|
+
const templatePath = manifest.profile === "rust-hybrid"
|
|
22
|
+
? path.join(TEMPLATES_DIR, "profiles", "rust-hybrid", filename)
|
|
23
|
+
: path.join(TEMPLATES_DIR, filename);
|
|
22
24
|
const templateText = fs.readFileSync(templatePath, "utf8");
|
|
23
25
|
return { lane, filename, content: renderTemplate(templateText, context) };
|
|
24
26
|
});
|
|
@@ -68,13 +70,15 @@ export function lineDiff(oldText, newText) {
|
|
|
68
70
|
* managed file whose lane fell out of the manifest.
|
|
69
71
|
* Returns a typed result — never throws.
|
|
70
72
|
*/
|
|
71
|
-
export async function planSync(repoPath, deps = {}) {
|
|
73
|
+
export async function planSync(repoPath, deps = {}, manifestOverride = null) {
|
|
72
74
|
const visibility = await assertPublic(repoPath, deps);
|
|
73
75
|
if (!visibility.allowed) {
|
|
74
76
|
return { ok: false, stage: "visibility", errors: [visibility.reason], changes: [] };
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
const loaded =
|
|
79
|
+
const loaded = manifestOverride === null
|
|
80
|
+
? loadManifest(repoPath)
|
|
81
|
+
: { ok: true, manifest: manifestOverride };
|
|
78
82
|
if (!loaded.ok) {
|
|
79
83
|
return { ok: false, stage: "manifest", errors: loaded.errors, changes: [] };
|
|
80
84
|
}
|
|
@@ -92,24 +96,38 @@ export async function planSync(repoPath, deps = {}) {
|
|
|
92
96
|
// not installed yet
|
|
93
97
|
}
|
|
94
98
|
if (existing === content) {
|
|
95
|
-
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: [] });
|
|
96
102
|
} else {
|
|
97
|
-
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 });
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
for (const managedFilename of listManagedFiles(repoPath)) {
|
|
102
108
|
if (!wantedFilenames.has(managedFilename)) {
|
|
103
|
-
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: [] });
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
|
|
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 };
|
|
108
117
|
}
|
|
109
118
|
|
|
110
119
|
/** Apply a plan produced by planSync: write/update/remove files on disk. */
|
|
111
120
|
export function applyPlan(repoPath, plan) {
|
|
112
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
|
+
}
|
|
113
131
|
fs.mkdirSync(dir, { recursive: true });
|
|
114
132
|
for (const change of plan.changes) {
|
|
115
133
|
const target = path.join(dir, change.filename);
|
|
@@ -119,6 +137,7 @@ export function applyPlan(repoPath, plan) {
|
|
|
119
137
|
fs.rmSync(target, { force: true });
|
|
120
138
|
}
|
|
121
139
|
}
|
|
140
|
+
return { ok: true, stage: "applied", errors: [] };
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
/** Full sync: plan, print diff, apply unless dryRun. Returns the plan. */
|
|
@@ -133,7 +152,8 @@ export async function syncCommand(repoPath, { dryRun = false, deps = {} } = {})
|
|
|
133
152
|
}
|
|
134
153
|
|
|
135
154
|
if (!dryRun) {
|
|
136
|
-
applyPlan(repoPath, plan);
|
|
155
|
+
const applied = applyPlan(repoPath, plan);
|
|
156
|
+
if (!applied.ok) return { ...plan, ...applied };
|
|
137
157
|
}
|
|
138
158
|
return plan;
|
|
139
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,8 +37,8 @@ jobs:
|
|
|
37
37
|
- uses: actions/setup-node@v5
|
|
38
38
|
with:
|
|
39
39
|
node-version: ${{ matrix.node }}
|
|
40
|
-
cache:
|
|
41
|
-
|
|
40
|
+
package-manager-cache: false
|
|
41
|
+
${PACKAGE_MANAGER_SETUP}
|
|
42
42
|
- run: ${INSTALL_COMMAND}
|
|
43
43
|
- run: ${PM_RUN} test
|
|
44
44
|
- run: ${PM_RUN} test:random
|
|
@@ -31,7 +31,7 @@ jobs:
|
|
|
31
31
|
- uses: actions/setup-node@v5
|
|
32
32
|
with:
|
|
33
33
|
node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
|
|
34
|
-
cache:
|
|
35
|
-
|
|
34
|
+
package-manager-cache: false
|
|
35
|
+
${PACKAGE_MANAGER_SETUP}
|
|
36
36
|
- run: ${INSTALL_COMMAND}
|
|
37
37
|
- run: node scripts/test-package.mjs
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Managed by right-git — do not hand-edit. Run `right-git sync` to update.
|
|
2
|
+
# Template: profiles/rust-hybrid/ci.yml | Template version: ${TEMPLATE_VERSION}
|
|
3
|
+
name: ci
|
|
4
|
+
on: [push, pull_request]
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
jobs:
|
|
8
|
+
private-repo-guard:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- name: Refuse private repository
|
|
12
|
+
if: ${{ github.event.repository.private }}
|
|
13
|
+
run: exit 1
|
|
14
|
+
ci:
|
|
15
|
+
needs: [private-repo-guard]
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
os: ${OS_MATRIX}
|
|
20
|
+
node: ${NODE_MATRIX}
|
|
21
|
+
runs-on: ${{ matrix.os }}
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v5
|
|
24
|
+
- uses: actions/setup-node@v5
|
|
25
|
+
with:
|
|
26
|
+
node-version: ${{ matrix.node }}
|
|
27
|
+
package-manager-cache: false
|
|
28
|
+
${PACKAGE_MANAGER_SETUP}
|
|
29
|
+
- run: rustup toolchain install stable --profile minimal
|
|
30
|
+
- run: bash ./${GATE_PATH}
|
|
@@ -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
|
-
#
|
|
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
|
|
@@ -37,9 +36,9 @@ jobs:
|
|
|
37
36
|
- uses: actions/setup-node@v5
|
|
38
37
|
with:
|
|
39
38
|
node-version: ${NODE_PUBLISH_VERSION}
|
|
40
|
-
cache: ${PM_CACHE}
|
|
41
39
|
registry-url: https://registry.npmjs.org
|
|
42
|
-
|
|
40
|
+
package-manager-cache: false
|
|
41
|
+
${PACKAGE_MANAGER_SETUP}
|
|
43
42
|
- run: ${INSTALL_COMMAND}
|
|
44
43
|
- run: node scripts/release/build-candidate.mjs --version "${{ github.event.release.tag_name }}" --platform current --out "release/candidates/publish"
|
|
45
44
|
- run: node scripts/release/check-release.mjs release/candidates/publish
|
|
@@ -76,8 +76,8 @@ jobs:
|
|
|
76
76
|
- uses: actions/setup-node@v5
|
|
77
77
|
with:
|
|
78
78
|
node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
|
|
79
|
-
cache:
|
|
80
|
-
|
|
79
|
+
package-manager-cache: false
|
|
80
|
+
${PACKAGE_MANAGER_SETUP}
|
|
81
81
|
- run: ${INSTALL_COMMAND}
|
|
82
82
|
- run: ruby -c release/homebrew/${HOMEBREW_FORMULA_NAME}.rb
|
|
83
83
|
- name: Checkout Homebrew tap
|
|
@@ -26,8 +26,8 @@ jobs:
|
|
|
26
26
|
- uses: actions/setup-node@v5
|
|
27
27
|
with:
|
|
28
28
|
node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
|
|
29
|
-
cache:
|
|
30
|
-
|
|
29
|
+
package-manager-cache: false
|
|
30
|
+
${PACKAGE_MANAGER_SETUP}
|
|
31
31
|
- run: ${INSTALL_COMMAND}
|
|
32
32
|
- run: ${PM_RUN} test:all
|
|
33
33
|
schema:
|
|
@@ -38,10 +38,21 @@ jobs:
|
|
|
38
38
|
- uses: actions/setup-node@v5
|
|
39
39
|
with:
|
|
40
40
|
node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
|
|
41
|
-
cache:
|
|
42
|
-
|
|
41
|
+
package-manager-cache: false
|
|
42
|
+
${PACKAGE_MANAGER_SETUP}
|
|
43
43
|
- run: ${INSTALL_COMMAND}
|
|
44
|
-
-
|
|
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
|
|
45
56
|
security:
|
|
46
57
|
needs: [qualification]
|
|
47
58
|
runs-on: ubuntu-latest
|
|
@@ -50,8 +61,8 @@ jobs:
|
|
|
50
61
|
- uses: actions/setup-node@v5
|
|
51
62
|
with:
|
|
52
63
|
node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
|
|
53
|
-
cache:
|
|
54
|
-
|
|
64
|
+
package-manager-cache: false
|
|
65
|
+
${PACKAGE_MANAGER_SETUP}
|
|
55
66
|
- run: ${INSTALL_COMMAND}
|
|
56
67
|
- run: node scripts/ci/check-network-boundary.mjs
|
|
57
68
|
- name: Hostile-repository and secret-egress suite
|
|
@@ -64,8 +75,8 @@ jobs:
|
|
|
64
75
|
- uses: actions/setup-node@v5
|
|
65
76
|
with:
|
|
66
77
|
node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
|
|
67
|
-
cache:
|
|
68
|
-
|
|
78
|
+
package-manager-cache: false
|
|
79
|
+
${PACKAGE_MANAGER_SETUP}
|
|
69
80
|
- run: ${INSTALL_COMMAND}
|
|
70
81
|
- run: ${PM_RUN} test:hardening
|
|
71
82
|
conformance:
|
|
@@ -76,7 +87,7 @@ jobs:
|
|
|
76
87
|
- uses: actions/setup-node@v5
|
|
77
88
|
with:
|
|
78
89
|
node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
|
|
79
|
-
cache:
|
|
80
|
-
|
|
90
|
+
package-manager-cache: false
|
|
91
|
+
${PACKAGE_MANAGER_SETUP}
|
|
81
92
|
- run: ${INSTALL_COMMAND}
|
|
82
93
|
- run: ${PM_RUN} test:conformance
|
|
@@ -32,8 +32,8 @@ jobs:
|
|
|
32
32
|
- uses: actions/setup-node@v5
|
|
33
33
|
with:
|
|
34
34
|
node-version: ${{ fromJSON('${NODE_MATRIX}')[0] }}
|
|
35
|
-
cache:
|
|
36
|
-
|
|
35
|
+
package-manager-cache: false
|
|
36
|
+
${PACKAGE_MANAGER_SETUP}
|
|
37
37
|
- run: ${INSTALL_COMMAND}
|
|
38
38
|
- run: node --test tests/release-candidate.test.mjs
|
|
39
39
|
- run: node scripts/release/build-candidate.mjs --platform current --out "${{ runner.temp }}/${ARTIFACT_PREFIX}-rc"
|
package/test-support.mjs
CHANGED
package/uninstall.test.mjs
CHANGED
|
@@ -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("
|
|
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, "
|
|
51
|
-
assert.
|
|
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
|
+
});
|