@rightkit/git 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,4 +38,4 @@ Node builtins only; no runtime dependencies.
38
38
 
39
39
  ## Profiles
40
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.
41
+ `profile` defaults to `node`. `rust-hybrid` requires `ci`, accepts optional `release-candidate`, installs pinned Node plus `rustup stable`, then runs product-owned package scripts. Candidate configuration is `{ buildScript, checkScript, artifactRoot }`; RightGit passes an isolated `RIGHT_GIT_ARTIFACT_ROOT`, checks it, & uploads it as an unsigned handoff. It never signs, finalizes, publishes releases, or receives signing credentials.
package/manifest.mjs CHANGED
@@ -57,8 +57,10 @@ const TOP_LEVEL_KEYS = new Set([
57
57
  "templateVersion",
58
58
  "profile",
59
59
  "gate",
60
+ "candidate",
60
61
  ]);
61
62
  const GATE_PATH_RE = /^(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
63
+ const PACKAGE_SCRIPT_RE = /^[A-Za-z0-9][A-Za-z0-9:_-]*$/;
62
64
 
63
65
  /**
64
66
  * Validate a parsed manifest object against the right-git manifest contract.
@@ -123,7 +125,9 @@ export function validateManifest(manifest) {
123
125
  const profile = manifest.profile === undefined ? "node" : manifest.profile;
124
126
  if (profile !== "node" && profile !== "rust-hybrid") errors.push("profile must be 'node' or 'rust-hybrid'");
125
127
  if (profile === "rust-hybrid") {
126
- if (lanes.length !== 1 || lanes[0] !== "ci") errors.push("profile 'rust-hybrid' supports only lane 'ci'");
128
+ if (lanes.some((lane) => lane !== "ci" && lane !== "release-candidate") || !lanes.includes("ci")) {
129
+ errors.push("profile 'rust-hybrid' requires lane 'ci' and supports optional lane 'release-candidate'");
130
+ }
127
131
  if (!manifest.gate || typeof manifest.gate !== "object" || Array.isArray(manifest.gate) || Object.keys(manifest.gate).some((key) => key !== "path") || typeof manifest.gate.path !== "string" || !GATE_PATH_RE.test(manifest.gate.path)) {
128
132
  errors.push("profile 'rust-hybrid' requires gate.path with a safe relative path");
129
133
  }
@@ -131,6 +135,31 @@ export function validateManifest(manifest) {
131
135
  errors.push("gate is only valid for profile 'rust-hybrid'");
132
136
  }
133
137
 
138
+ const candidate = manifest.candidate;
139
+ if (candidate !== undefined) {
140
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
141
+ errors.push("candidate must be an object");
142
+ } else {
143
+ for (const key of Object.keys(candidate)) {
144
+ if (!["buildScript", "checkScript", "artifactRoot"].includes(key)) errors.push(`unknown candidate field: ${key}`);
145
+ }
146
+ for (const key of ["buildScript", "checkScript"]) {
147
+ if (typeof candidate[key] !== "string" || !PACKAGE_SCRIPT_RE.test(candidate[key])) {
148
+ errors.push(`candidate.${key} must be one safe package script name`);
149
+ }
150
+ }
151
+ if (typeof candidate.artifactRoot !== "string" || !GATE_PATH_RE.test(candidate.artifactRoot)) {
152
+ errors.push("candidate.artifactRoot must be one safe relative path");
153
+ }
154
+ }
155
+ }
156
+ if (profile === "rust-hybrid" && lanes.includes("release-candidate") && candidate === undefined) {
157
+ errors.push("rust-hybrid release-candidate lane requires candidate { buildScript, checkScript, artifactRoot }");
158
+ }
159
+ if (profile !== "rust-hybrid" && candidate !== undefined) {
160
+ errors.push("candidate is only valid for profile 'rust-hybrid'");
161
+ }
162
+
134
163
  const publish = manifest.publish;
135
164
  if (publish !== undefined) {
136
165
  if (publish === null || typeof publish !== "object" || Array.isArray(publish)) {
@@ -233,7 +262,7 @@ function parsePackageManager(spec) {
233
262
  }[name];
234
263
  const runPrefix = { pnpm: "pnpm run", npm: "npm run", yarn: "yarn run" }[name];
235
264
  const setup = {
236
- pnpm: ` - uses: pnpm/action-setup@v4\n with:\n version: ${version}`,
265
+ pnpm: ` - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10\n with:\n version: ${version}`,
237
266
  npm: "",
238
267
  yarn: ` - run: corepack enable && corepack prepare yarn@${version} --activate`,
239
268
  }[name];
@@ -283,6 +312,9 @@ export function buildRenderContext(manifest, repoPath) {
283
312
  ARTIFACT_PREFIX: artifactPrefix,
284
313
  TEMPLATE_VERSION: manifest.templateVersion,
285
314
  GATE_PATH: manifest.gate?.path,
315
+ CANDIDATE_BUILD_SCRIPT: manifest.candidate?.buildScript,
316
+ CANDIDATE_CHECK_SCRIPT: manifest.candidate?.checkScript,
317
+ CANDIDATE_ARTIFACT_ROOT: manifest.candidate?.artifactRoot,
286
318
  };
287
319
 
288
320
  if (manifest.publish?.npm) {
@@ -85,6 +85,16 @@
85
85
  "gate": {
86
86
  "type": "object", "required": ["path"], "additionalProperties": false,
87
87
  "properties": { "path": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$" } }
88
+ },
89
+ "candidate": {
90
+ "type": "object",
91
+ "required": ["buildScript", "checkScript", "artifactRoot"],
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "buildScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
95
+ "checkScript": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9:_-]*$" },
96
+ "artifactRoot": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$" }
97
+ }
88
98
  }
89
99
  }
90
100
  }
package/manifest.test.mjs CHANGED
@@ -16,13 +16,21 @@ test("accepts a minimal valid manifest", () => {
16
16
  assert.deepEqual(errors, []);
17
17
  });
18
18
 
19
- test("rust-hybrid requires only ci and a safe gate path", () => {
19
+ test("rust-hybrid supports ci plus a configured unsigned release-candidate lane", () => {
20
20
  const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
21
21
  assert.equal(validateManifest(valid).valid, true);
22
22
  for (const path of ["/tmp/gate.sh", "../gate.sh", "scripts/gate sh", "${{ secrets.X }}", "scripts/gate;sh"]) {
23
23
  assert.equal(validateManifest({ ...valid, gate: { path } }).valid, false, path);
24
24
  }
25
25
  assert.equal(validateManifest({ ...valid, lanes: ["ci", "qualification"] }).valid, false);
26
+ const candidate = {
27
+ ...valid,
28
+ lanes: ["ci", "release-candidate"],
29
+ candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "legion-unsigned" },
30
+ };
31
+ assert.equal(validateManifest(candidate).valid, true);
32
+ assert.equal(validateManifest({ ...candidate, candidate: { ...candidate.candidate, buildScript: "echo unsafe" } }).valid, false);
33
+ assert.equal(validateManifest({ ...candidate, candidate: { ...candidate.candidate, artifactRoot: "../escape" } }).valid, false);
26
34
  });
27
35
 
28
36
  test("rejects non-object input", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/git",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Public-repo-only GitHub Actions workflow layer: templates, visibility gate, sync/drift/adopt/uninstall for right-git.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
package/render.test.mjs CHANGED
@@ -45,12 +45,32 @@ test("every known lane has a template file and renders without an unresolved pla
45
45
  test("rust-hybrid ci renders its pinned package manager and gate", () => {
46
46
  const manifest = { ...FULL_MANIFEST, lanes: ["ci"], profile: "rust-hybrid", gate: { path: "scripts/gate.sh" }, templateVersion: "1.1.0" };
47
47
  const content = renderTemplate(fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8"), buildRenderContext(manifest, "/repos/widgets"));
48
- assert.match(content, /uses: pnpm\/action-setup@v4/);
48
+ assert.match(content, /uses: pnpm\/action-setup@[a-f0-9]{40}/);
49
49
  assert.match(content, /version: 11\.24\.0/);
50
- assert.match(content, /rustup toolchain install stable/);
50
+ assert.match(content, /toolchain: 1\.98\.0/);
51
51
  assert.match(content, /bash \.\/scripts\/gate\.sh/);
52
52
  });
53
53
 
54
+ test("rust-hybrid release candidate renders product scripts, artifact handoff, and no signing secret", () => {
55
+ const manifest = {
56
+ ...FULL_MANIFEST,
57
+ lanes: ["ci", "release-candidate"],
58
+ profile: "rust-hybrid",
59
+ gate: { path: "scripts/gate.sh" },
60
+ candidate: { buildScript: "ci:candidate", checkScript: "ci:candidate-check", artifactRoot: "widgets-unsigned" },
61
+ templateVersion: "1.2.0",
62
+ };
63
+ const content = renderTemplate(
64
+ fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
65
+ buildRenderContext(manifest, "/repos/widgets"),
66
+ );
67
+ assert.match(content, /pnpm run ci:candidate/);
68
+ assert.match(content, /pnpm run ci:candidate-check/);
69
+ assert.match(content, /RIGHT_GIT_ARTIFACT_ROOT/);
70
+ assert.match(content, /actions\/upload-artifact@[a-f0-9]{40}/);
71
+ assert.doesNotMatch(content, /secrets\.|AZURE_|APPLE_|certificate|private.key/i);
72
+ });
73
+
54
74
  test("render determinism: same manifest renders byte-identical output every time", () => {
55
75
  const first = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
56
76
  const second = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
@@ -75,7 +95,7 @@ test("rendered workflows never request pnpm or yarn setup-node caching before pa
75
95
  test("rendered workflows bootstrap each declared package manager safely", () => {
76
96
  const pnpm = renderAllLanes({ ...FULL_MANIFEST, packageManager: "pnpm@11.24.0" }, "/repos/widgets");
77
97
  for (const content of Object.values(pnpm)) {
78
- assert.match(content, /uses: pnpm\/action-setup@v4/);
98
+ assert.match(content, /uses: pnpm\/action-setup@[a-f0-9]{40}/);
79
99
  assert.match(content, /version: 11\.24\.0/);
80
100
  assert.doesNotMatch(content, /corepack enable/);
81
101
  }
@@ -1,12 +1,15 @@
1
1
  # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
2
  # Template: profiles/rust-hybrid/ci.yml | Template version: ${TEMPLATE_VERSION}
3
- name: ci
4
- on: [push, pull_request]
3
+ name: legion-ci
4
+ on:
5
+ pull_request:
6
+ push:
7
+ branches: [main]
5
8
  permissions:
6
9
  contents: read
7
10
  jobs:
8
11
  private-repo-guard:
9
- runs-on: ubuntu-latest
12
+ runs-on: ubuntu-24.04
10
13
  steps:
11
14
  - name: Refuse private repository
12
15
  if: ${{ github.event.repository.private }}
@@ -20,11 +23,17 @@ jobs:
20
23
  node: ${NODE_MATRIX}
21
24
  runs-on: ${{ matrix.os }}
22
25
  steps:
23
- - uses: actions/checkout@v5
24
- - uses: actions/setup-node@v5
26
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
25
28
  with:
26
29
  node-version: ${{ matrix.node }}
27
30
  package-manager-cache: false
28
31
  ${PACKAGE_MANAGER_SETUP}
29
- - run: rustup toolchain install stable --profile minimal
32
+ - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0
33
+ with:
34
+ toolchain: 1.98.0
35
+ components: rustfmt, clippy
36
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
37
+ with:
38
+ workspaces: engine
30
39
  - run: bash ./${GATE_PATH}
@@ -0,0 +1,55 @@
1
+ # Managed by right-git — do not hand-edit. Run `right-git sync` to update.
2
+ # Template: profiles/rust-hybrid/release-candidate.yml | Template version: ${TEMPLATE_VERSION}
3
+ # Public CI produces unsigned candidates only. Signing credentials, finalization,
4
+ # release publication, & stable bootstrap publication remain protected-host work.
5
+ name: release-candidate
6
+ on:
7
+ workflow_dispatch:
8
+ push:
9
+ tags: ['v*']
10
+ permissions:
11
+ contents: read
12
+ jobs:
13
+ private-repo-guard:
14
+ runs-on: ubuntu-24.04
15
+ steps:
16
+ - name: Refuse private repository
17
+ if: ${{ github.event.repository.private }}
18
+ run: exit 1
19
+ candidate:
20
+ needs: [private-repo-guard]
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ os: ${OS_MATRIX}
25
+ node: ${NODE_MATRIX}
26
+ runs-on: ${{ matrix.os }}
27
+ steps:
28
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
30
+ with:
31
+ node-version: ${{ matrix.node }}
32
+ package-manager-cache: false
33
+ ${PACKAGE_MANAGER_SETUP}
34
+ - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0
35
+ with:
36
+ toolchain: 1.98.0
37
+ components: rustfmt, clippy
38
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
39
+ with:
40
+ workspaces: engine
41
+ - run: ${INSTALL_COMMAND}
42
+ - name: Build unsigned release candidate
43
+ run: ${PM_RUN} ${CANDIDATE_BUILD_SCRIPT}
44
+ env:
45
+ RIGHT_GIT_ARTIFACT_ROOT: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}
46
+ - name: Check unsigned release candidate
47
+ run: ${PM_RUN} ${CANDIDATE_CHECK_SCRIPT}
48
+ env:
49
+ RIGHT_GIT_ARTIFACT_ROOT: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}
50
+ - name: Upload unsigned candidate handoff
51
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
52
+ with:
53
+ name: ${ARTIFACT_PREFIX}-unsigned-${{ matrix.os }}-${{ matrix.node }}
54
+ path: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}/**
55
+ if-no-files-found: error