@rightkit/git 0.2.1 → 0.2.2

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)) {
@@ -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.2",
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
@@ -51,6 +51,26 @@ test("rust-hybrid ci renders its pinned package manager and gate", () => {
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@v4/);
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");
@@ -0,0 +1,49 @@
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-latest
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@v5
29
+ - uses: actions/setup-node@v5
30
+ with:
31
+ node-version: ${{ matrix.node }}
32
+ package-manager-cache: false
33
+ ${PACKAGE_MANAGER_SETUP}
34
+ - run: rustup toolchain install stable --profile minimal
35
+ - run: ${INSTALL_COMMAND}
36
+ - name: Build unsigned release candidate
37
+ run: ${PM_RUN} ${CANDIDATE_BUILD_SCRIPT}
38
+ env:
39
+ RIGHT_GIT_ARTIFACT_ROOT: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}
40
+ - name: Check unsigned release candidate
41
+ run: ${PM_RUN} ${CANDIDATE_CHECK_SCRIPT}
42
+ env:
43
+ RIGHT_GIT_ARTIFACT_ROOT: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}
44
+ - name: Upload unsigned candidate handoff
45
+ uses: actions/upload-artifact@v4
46
+ with:
47
+ name: ${ARTIFACT_PREFIX}-unsigned-${{ matrix.os }}-${{ matrix.node }}
48
+ path: ${{ runner.temp }}/${CANDIDATE_ARTIFACT_ROOT}/**
49
+ if-no-files-found: error