@rightkit/git 0.2.25 → 0.2.27

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
@@ -1,43 +1,59 @@
1
1
  # @rightkit/git
2
2
 
3
- Public-repo-only GitHub Actions workflow templates for the Right/Orthic product suite. One CLI, `right-git`, that renders and installs CI lanes into a repository — and refuses to touch a private one.
3
+ Public-repository-only GitHub Actions workflow layer for Right Suite products. The CLI `right-git` renders and installs CI lanes into `.github/workflows/` from a `.rightgit.json` manifest.
4
4
 
5
- ## Why public-only
5
+ | Item | Value |
6
+ |---|---|
7
+ | Where it runs | Build and release tooling (repository CI setup) |
8
+ | Rust pair | None |
9
+ | Version | see INDEX.md |
10
+ | License | MIT OR Apache-2.0 |
6
11
 
7
- GitHub Actions minutes are free and unlimited on public repos, and billed on private ones. `@rightkit/git` enforces that boundary in code, not by convention:
12
+ ## Commands
8
13
 
9
- 1. **Install-time refusal** — `right-git install`/`sync` resolves repo visibility and refuses any private or internal repo, with no override flag. Visibility fails closed: an unconfirmed repo is treated as not-public.
10
- 2. **Run-time guard** — every rendered workflow carries an unconditional `private-repo-guard` job that fails loudly (`exit 1`) if the repo is ever flipped private after install; every other job depends on it.
11
- 3. **Drift audit** — `right-git drift --all` re-checks visibility across managed repos.
14
+ - `right-git install <repo> --lanes ci,...` writes `.rightgit.json` and renders workflows.
15
+ - `right-git sync` re-renders installed workflows from the manifest.
16
+ - `right-git drift --all` checks installed workflows against templates and re-checks visibility.
17
+ - `right-git adopt` imports existing workflows into the template library.
18
+ - `right-git uninstall` removes every managed file.
12
19
 
13
- ## What it does
20
+ `install` and `sync` refuse private, internal and unconfirmed-visibility repositories. There is no override flag.
14
21
 
15
- - **`right-git install|sync`** — render CI lanes (ci, qualification, package-smoke, release-candidate, publish-npm, publish-package-managers) from a `.rightgit.json` manifest into `.github/workflows/`.
16
- - **`right-git drift`** — detect divergence between installed workflows and templates, and orphaned managed workflows.
17
- - **`right-git adopt`** — import existing workflows into the template library.
18
- - **`right-git uninstall`** — remove every managed file, leaving zero behind.
22
+ ## Optional QA evidence upload (rust-hybrid)
19
23
 
20
- Workflows are thin callers over the repo's own scripts, so a private repo running the same gates locally executes identical checks.
24
+ Set `qaEvidencePath` in `.rightgit.json` to upload the gate's QA output as a workflow artifact. The path is relative to `$RUNNER_TEMP`; the rendered step uses `${{ runner.temp }}/<path>`. Absolute paths and expression syntax are rejected.
21
25
 
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.
26
+ ```json
27
+ {
28
+ "profile": "rust-hybrid",
29
+ "gate": { "path": "scripts/gate.sh" },
30
+ "qaEvidencePath": "pulse-hub-qa",
31
+ "qaEvidenceRetentionDays": 14
32
+ }
33
+ ```
23
34
 
24
- ## Credential posture (read before enabling package-manager publishing)
35
+ - The step runs after the gate with `if: always()` and `if-no-files-found: ignore`.
36
+ - Artifact name: `qa-evidence-${{ matrix.os || runner.os }}-${{ github.run_attempt }}`.
37
+ - `qaEvidenceRetentionDays` is an integer from 1 to 90 and defaults to 7. It requires `qaEvidencePath`.
38
+ - Omit the key and the rendered `ci.yml` is unchanged.
25
39
 
26
- - **Signing credentials stay environment-scoped.** Opt-in Windows candidates use GitHub OIDC with Azure Artifact Signing. Opt-in macOS candidates use a temporary runner keychain plus Apple API-key notarization. Signing jobs run only for `v*` tag refs; manual dispatch produces unsigned candidates only. npm publishing uses short-lived OIDC.
27
- - **Exception, disclosed:** the optional, opt-in `publish-package-managers` lane *does* require two owner-provisioned, long-lived tokens in GitHub Actions `secrets.*` (`HOMEBREW_TAP_TOKEN`, `WINGET_CREATE_GITHUB_TOKEN`) to open tap/manifest PRs. These are real bearer tokens, public-repo-scoped by the visibility gate but not zero-exposure. The "no signing credential in CI" invariant is specifically about code-signing material; it does not mean "no credential of any kind."
40
+ ## Entry points
28
41
 
29
- Manifest input is treated as untrusted: every field flowing into rendered YAML is charset-allowlisted, so a manifest cannot inject `${{ secrets.* }}` or shell/expression syntax.
42
+ No importable API. The package has no `main` and no `exports` map. The supported surface is the `right-git` bin.
30
43
 
31
- ## Install
44
+ ## Peer dependencies
32
45
 
33
- ```sh
34
- npm install -g @rightkit/git # or: npx @rightkit/git --help
35
- ```
46
+ None. No runtime dependencies (Node builtins only).
36
47
 
37
- Node builtins only; no runtime dependencies.
48
+ ## Example
38
49
 
39
- ## Profiles
50
+ ```sh
51
+ pnpm add -D @rightkit/git
52
+ pnpm exec right-git install . --lanes ci
53
+ pnpm exec right-git drift --all
54
+ ```
40
55
 
41
- `profile` defaults to `node`. `rust-hybrid` requires `ci`, accepts optional `release-candidate`, installs pinned Node plus Rust, then runs product-owned package scripts. `rustCache.workspaces` declares every independent Cargo workspace whose `target` directory participates in dependency caching. Rust-hybrid workflows also enable source-aware sccache compilation caching, reuse one stable cache namespace across CI & candidate jobs, & keep workspace-crate artifacts out of rust-cache to prevent stale exact hits. Candidate configuration is `{ buildScript, checkScript, artifactRoot, os, targets?, signWindows?, signMacos?, releaseChain? }`. Legacy `os` remains supported & derives one target per runner; explicit `targets` is `{ os, platform, architecture }[]`, independent of main CI matrix. Each target maps to native runner, Rust target triple, exact unsigned artifact, & platform signing matrix. Supported platform values are `windows`, `macos`, `linux`; architectures are `x86_64`, `arm64`. RightGit always uploads an unsigned handoff. `signWindows: true` adds Azure OIDC signing. `signMacos: true` adds Developer ID signing plus notarization from protected environment secrets.
56
+ ## Known issues
42
57
 
43
- `releaseChain` lets RightGit own full protected DAG while product scripts own artifact semantics: `{ windowsFinalizeScript, macosFinalizeScript?, installedQualificationScript, publishScript, qualificationRunnerVariable, publishSecrets? }`. Generated ordering is unsigned candidate → platform signing/finalization → installed qualification → publication. It deliberately rejects more than one Windows target or more than one macOS target because its installed-qualification/publish handoff has one canonical signed artifact per platform. Every protected job is tag-gated, publication needs successful installed qualification plus macOS finalization when enabled, & only publish job receives declared publication secrets plus `contents: write`.
58
+ - The optional `publish-package-managers` lane needs two long-lived tokens in GitHub Actions secrets. Their names and scope are in the package templates, and they are not zero-exposure. Read `docs/rules/github-access.md` in the workspace before enabling it.
59
+ - Manifest fields are charset-checked before they reach workflow YAML.
@@ -166,12 +166,11 @@ function isGuarded(jobs, jobName, visiting = new Set()) {
166
166
  }
167
167
 
168
168
  function renderAllLanes() {
169
- const context = buildRenderContext(FULL_MANIFEST, "/repos/widgets");
170
169
  const rendered = {};
171
170
  for (const lane of FULL_MANIFEST.lanes) {
172
171
  const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
173
172
  const templateText = fs.readFileSync(templatePath, "utf8");
174
- rendered[lane] = renderTemplate(templateText, context);
173
+ rendered[lane] = renderTemplate(templateText, buildRenderContext(FULL_MANIFEST, "/repos/widgets", lane));
175
174
  }
176
175
  return rendered;
177
176
  }
package/manifest.mjs CHANGED
@@ -59,9 +59,17 @@ const TOP_LEVEL_KEYS = new Set([
59
59
  "profile",
60
60
  "gate",
61
61
  "rustCache",
62
+ "qaEvidencePath",
63
+ "qaEvidenceRetentionDays",
62
64
  "candidate",
63
65
  "ownership",
64
66
  ]);
67
+ // Optional QA evidence upload (rust-hybrid ci only). qaEvidencePath is a safe
68
+ // relative path under $RUNNER_TEMP; it renders as `${{ runner.temp }}/<path>`.
69
+ const QA_EVIDENCE_DEFAULT_RETENTION_DAYS = 7;
70
+ const QA_EVIDENCE_MAX_RETENTION_DAYS = 90;
71
+ // Pinned to the same upload-artifact release the rust-hybrid release-candidate template uses.
72
+ const UPLOAD_ARTIFACT_PIN = "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4";
65
73
  const GATE_PATH_RE = /^(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
66
74
  const RUST_WORKSPACE_PATH_RE = /^(?:\.|(?!.*\.\.)(?!.*\s)[A-Za-z0-9][A-Za-z0-9._/-]*)$/;
67
75
  const PACKAGE_SCRIPT_RE = /^[A-Za-z0-9][A-Za-z0-9:_-]*$/;
@@ -160,6 +168,21 @@ export function validateManifest(manifest) {
160
168
  errors.push("gate is only valid for profile 'rust-hybrid'");
161
169
  }
162
170
 
171
+ if (manifest.qaEvidencePath !== undefined) {
172
+ if (profile !== "rust-hybrid") {
173
+ errors.push("qaEvidencePath is only valid for profile 'rust-hybrid'");
174
+ } else if (typeof manifest.qaEvidencePath !== "string" || !GATE_PATH_RE.test(manifest.qaEvidencePath)) {
175
+ errors.push("qaEvidencePath must be a safe relative path under $RUNNER_TEMP (no '..', whitespace, leading '/', or expression syntax)");
176
+ }
177
+ }
178
+ if (manifest.qaEvidenceRetentionDays !== undefined) {
179
+ if (manifest.qaEvidencePath === undefined) {
180
+ errors.push("qaEvidenceRetentionDays requires qaEvidencePath");
181
+ } else if (!Number.isInteger(manifest.qaEvidenceRetentionDays) || manifest.qaEvidenceRetentionDays < 1 || manifest.qaEvidenceRetentionDays > QA_EVIDENCE_MAX_RETENTION_DAYS) {
182
+ errors.push(`qaEvidenceRetentionDays must be an integer from 1 to ${QA_EVIDENCE_MAX_RETENTION_DAYS}`);
183
+ }
184
+ }
185
+
163
186
  const rustCache = manifest.rustCache;
164
187
  if (rustCache !== undefined) {
165
188
  if (profile !== "rust-hybrid") {
@@ -990,15 +1013,42 @@ export function deriveProjectName(repoPath) {
990
1013
  * validated manifest plus the target repo's derived project name.
991
1014
  */
992
1015
  /** Every managed public workflow runs ownership scanning; repository metadata cannot opt it out. */
993
- export function ownershipStep(manifest) {
1016
+ export function ownershipStep(manifest, lane = "ci") {
994
1017
  const { name } = parsePackageManager(manifest.packageManager);
995
1018
  const command = name === "pnpm" ? "pnpm exec right-release ownership --app ."
996
1019
  : name === "npm" ? "npm exec -- right-release ownership --app ."
997
1020
  : "yarn exec right-release ownership --app .";
998
- return `\n - name: RightKit ownership (protected registry roots)\n shell: bash\n run: test "$(git rev-parse HEAD)" = "\${{ github.event_name == 'workflow_dispatch' && inputs.source_revision || github.sha }}" && ${command}`;
1021
+ // Only release-candidate dispatches declare source_revision. Referencing
1022
+ // inputs.source_revision from other lanes makes actionlint reject their
1023
+ // generated workflows, even when guarded by an event-name expression.
1024
+ const sourceRevision = lane === "release-candidate"
1025
+ ? "\${{ inputs.source_revision }}"
1026
+ : "\${{ github.sha }}";
1027
+ return `\n - name: RightKit ownership (protected registry roots)\n shell: bash\n run: test "$(git rev-parse HEAD)" = "${sourceRevision}" && ${command}`;
1028
+ }
1029
+
1030
+ /**
1031
+ * Optional QA evidence upload, rendered after the rust-hybrid gate step.
1032
+ * Returns "" when qaEvidencePath is unset, so the rendered workflow stays
1033
+ * byte-identical to a manifest without the key.
1034
+ */
1035
+ export function qaEvidenceStep(manifest) {
1036
+ if (manifest.qaEvidencePath === undefined) return "";
1037
+ const retentionDays = manifest.qaEvidenceRetentionDays ?? QA_EVIDENCE_DEFAULT_RETENTION_DAYS;
1038
+ return [
1039
+ " - name: Upload QA evidence",
1040
+ " if: always()",
1041
+ ` uses: ${UPLOAD_ARTIFACT_PIN}`,
1042
+ " with:",
1043
+ " name: qa-evidence-${{ matrix.os || runner.os }}-${{ github.run_attempt }}",
1044
+ ` path: \${{ runner.temp }}/${manifest.qaEvidencePath}`,
1045
+ ` retention-days: ${retentionDays}`,
1046
+ " if-no-files-found: ignore",
1047
+ "",
1048
+ ].join("\n");
999
1049
  }
1000
1050
 
1001
- export function buildRenderContext(manifest, repoPath) {
1051
+ export function buildRenderContext(manifest, repoPath, lane = "ci") {
1002
1052
  const pm = parsePackageManager(manifest.packageManager);
1003
1053
  const projectName = deriveProjectName(repoPath);
1004
1054
  const artifactPrefix = projectName.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
@@ -1024,7 +1074,8 @@ export function buildRenderContext(manifest, repoPath) {
1024
1074
  WINDOWS_SIGNING_JOB: windowsSigningJob(manifest, pm, artifactPrefix),
1025
1075
  MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
1026
1076
  RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
1027
- OWNERSHIP_STEP: ownershipStep(manifest),
1077
+ OWNERSHIP_STEP: ownershipStep(manifest, lane),
1078
+ QA_EVIDENCE_STEP: qaEvidenceStep(manifest),
1028
1079
  };
1029
1080
 
1030
1081
  // Every key below is populated ONLY when its source manifest section is
@@ -116,6 +116,18 @@
116
116
  "type": "object", "required": ["path"], "additionalProperties": false,
117
117
  "properties": { "path": { "type": "string", "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$" } }
118
118
  },
119
+ "qaEvidencePath": {
120
+ "type": "string",
121
+ "description": "rust-hybrid only. Safe relative path under $RUNNER_TEMP where the gate writes QA evidence; rendered as ${{ runner.temp }}/<path> and uploaded with if: always().",
122
+ "pattern": "^(?!.*\\.\\.)(?!.*\\s)[A-Za-z0-9][A-Za-z0-9._/-]*$"
123
+ },
124
+ "qaEvidenceRetentionDays": {
125
+ "type": "integer",
126
+ "minimum": 1,
127
+ "maximum": 90,
128
+ "default": 7,
129
+ "description": "rust-hybrid only, requires qaEvidencePath. Artifact retention in days."
130
+ },
119
131
  "candidate": {
120
132
  "type": "object",
121
133
  "required": ["buildScript", "checkScript", "admissionScript", "stageSummaryScript", "artifactRoot", "os"],
package/manifest.test.mjs CHANGED
@@ -389,3 +389,17 @@ test("F15 non-regression: realistic homebrew/winget/matrix values still validate
389
389
  });
390
390
  assert.equal(result.valid, true, JSON.stringify(result.errors));
391
391
  });
392
+
393
+ test("qaEvidencePath and qaEvidenceRetentionDays are optional, rust-hybrid only, and charset-checked", () => {
394
+ const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
395
+ assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa" }).valid, true);
396
+ assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays: 30 }).valid, true);
397
+ for (const qaEvidencePath of ["/abs", "../up", "pulse hub", "${{ runner.temp }}/x", "a;b", "", null]) {
398
+ assert.equal(validateManifest({ ...valid, qaEvidencePath }).valid, false, String(qaEvidencePath));
399
+ }
400
+ for (const qaEvidenceRetentionDays of [0, 91, 1.5, "7"]) {
401
+ assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays }).valid, false, String(qaEvidenceRetentionDays));
402
+ }
403
+ assert.equal(validateManifest({ ...valid, qaEvidenceRetentionDays: 7 }).valid, false, "retention requires a path");
404
+ assert.equal(validateManifest({ ...VALID_MINIMAL, qaEvidencePath: "pulse-hub-qa" }).valid, false, "node profile rejects qaEvidencePath");
405
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/git",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
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
@@ -24,12 +24,11 @@ const FULL_MANIFEST = {
24
24
  };
25
25
 
26
26
  function renderAllLanes(manifest, repoPath) {
27
- const context = buildRenderContext(manifest, repoPath);
28
27
  const rendered = {};
29
28
  for (const lane of manifest.lanes) {
30
29
  const templatePath = path.join(templatesDir, LANE_FILENAMES[lane]);
31
30
  const templateText = fs.readFileSync(templatePath, "utf8");
32
- rendered[lane] = renderTemplate(templateText, context);
31
+ rendered[lane] = renderTemplate(templateText, buildRenderContext(manifest, repoPath, lane));
33
32
  }
34
33
  return rendered;
35
34
  }
@@ -60,6 +59,7 @@ test("rust-hybrid ci renders its pinned package manager and gate", () => {
60
59
  assert.match(content, /changed=true[\s\S]*\^0\{40\}\$[\s\S]*git cat-file -e[\s\S]*git diff --name-only --diff-filter=ACDMRTUXB/);
61
60
  assert.match(content, /--diff-filter=ACDMRTUXB/, "deleted Rust inputs must trigger Rust setup");
62
61
  assert.match(content, /\*\.rs\|Cargo\.toml[\s\S]*scripts\/gate\.sh/);
62
+ assert.doesNotMatch(content, /\|build\.rs\||\|\*\/build\.rs\|/);
63
63
  assert.match(content, /\.rightgit\.json\|\.github\/workflows\/\*\.yml\|\.github\/workflows\/\*\.yaml/);
64
64
  assert.equal((content.match(/if: steps\.rust-changes\.outputs\.required == 'true'/g) ?? []).length, 3);
65
65
  assert.match(content, /RIGHT_GIT_RUST_CHANGED: \$\{\{ steps\.rust-changes\.outputs\.changed \}\}/);
@@ -94,6 +94,15 @@ test("ordinary CI templates cancel superseded runs on the same ref", () => {
94
94
  assert.match(generic, /cancel-in-progress: true/);
95
95
  });
96
96
 
97
+ test("ownership revision expression matches each lane trigger contract", () => {
98
+ const rendered = renderAllLanes(FULL_MANIFEST, "/repos/widgets");
99
+ assert.match(rendered.ci, /git rev-parse HEAD\)" = "\$\{\{ github\.sha \}\}/);
100
+ assert.doesNotMatch(rendered.ci, /inputs\.source_revision/);
101
+ assert.match(rendered["publish-package-managers"], /git rev-parse HEAD\)" = "\$\{\{ github\.sha \}\}/);
102
+ assert.doesNotMatch(rendered["publish-package-managers"], /inputs\.source_revision/);
103
+ assert.match(rendered["release-candidate"], /git rev-parse HEAD\)" = "\$\{\{ inputs\.source_revision \}\}/);
104
+ });
105
+
97
106
  test("rust-hybrid ci preserves a declared macOS-only lane", () => {
98
107
  const manifest = {
99
108
  ...FULL_MANIFEST,
@@ -195,7 +204,7 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
195
204
  };
196
205
  const content = renderTemplate(
197
206
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
198
- buildRenderContext(manifest, "/repos/widgets"),
207
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
199
208
  );
200
209
  assert.match(content, /environment: release/);
201
210
  // Tag-last contract: signing is gated on the admitted, same-run
@@ -212,7 +221,7 @@ test("rust-hybrid release candidate can opt into OIDC Windows signing without lo
212
221
  // out the exact admitted revision by SHA, never by resolving HEAD after a
213
222
  // ref-triggered checkout.
214
223
  assert.match(content, /windows-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/);
215
- assert.match(content, /git rev-parse HEAD.*inputs\.source_revision \|\| github\.sha/);
224
+ assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
216
225
  assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
217
226
  assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
218
227
  assert.doesNotMatch(content, /secrets\.|client-secret|private.key/i);
@@ -240,7 +249,7 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
240
249
  };
241
250
  const content = renderTemplate(
242
251
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
243
- buildRenderContext(manifest, "/repos/widgets"),
252
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
244
253
  );
245
254
  assert.match(content, /macos-sign:/);
246
255
  assert.match(content, /macos-sign:\n needs: \[admission, candidate\]\n if: \$\{\{ needs\.admission\.outputs\.signed_qualification == 'true' \}\}/);
@@ -251,7 +260,7 @@ test("rust-hybrid release candidate can opt into protected macOS signing & notar
251
260
  assert.match(content, /right-release build --platform mac --skip-checks/);
252
261
  assert.match(content, /secrets\.APPLE_API_KEY_BASE64/);
253
262
  assert.match(content, /macos-sign:[\s\S]*?ref: \$\{\{ needs\.admission\.outputs\.source_revision \}\}\n\s+fetch-depth: 0\n\s+persist-credentials: false/);
254
- assert.match(content, /git rev-parse HEAD.*inputs\.source_revision \|\| github\.sha/);
263
+ assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
255
264
  assert.ok(content.includes("LEGION_SOURCE_REVISION: ${{ env.RIGHT_GIT_SOURCE_REVISION }}"));
256
265
  assert.doesNotMatch(content, /LEGION_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
257
266
  assert.match(content, /security delete-keychain/);
@@ -287,7 +296,7 @@ test("rust-hybrid multi-architecture candidates bind every native target to exac
287
296
  };
288
297
  const content = renderTemplate(
289
298
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
290
- buildRenderContext(manifest, "/repos/widgets"),
299
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
291
300
  );
292
301
  for (const [runner, triple] of [["windows-2025", "x86_64-pc-windows-msvc"], ["windows-11-arm", "aarch64-pc-windows-msvc"], ["macos-15", "aarch64-apple-darwin"], ["macos-15-intel", "x86_64-apple-darwin"]]) {
293
302
  assert.match(content, new RegExp(`os: ${JSON.stringify(runner).replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`));
@@ -338,7 +347,7 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
338
347
  };
339
348
  const content = renderTemplate(
340
349
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
341
- buildRenderContext(manifest, "/repos/widgets"),
350
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
342
351
  );
343
352
  assert.match(content, /windows-sign:[\s\S]*pnpm run release:finalize:win/);
344
353
  assert.match(content, /macos-sign:[\s\S]*pnpm run release:finalize:mac/);
@@ -372,7 +381,7 @@ test("rust-hybrid release chain preserves signed candidate, installed qualificat
372
381
  assert.match(content, /CLOUDFLARE_API_TOKEN: \$\{\{ secrets\.CLOUDFLARE_API_TOKEN \}\}/);
373
382
  // Ownership scan binds to the source revision input before admission emits
374
383
  // that same revision for every downstream job.
375
- assert.match(content, /git rev-parse HEAD.*inputs\.source_revision \|\| github\.sha/);
384
+ assert.match(content, /git rev-parse HEAD.*inputs\.source_revision/);
376
385
  assert.doesNotMatch(content, /RIGHT_GIT_SOURCE_REVISION: \$\{\{ github\.sha \}\}/);
377
386
  // candidate, windows-sign, macos-sign, installed-qualification and publish all
378
387
  // check out the admitted revision with full history; admission checks out
@@ -430,7 +439,7 @@ test("every 'run <script>' in a full release-chain workflow names a script the m
430
439
  };
431
440
  const content = renderTemplate(
432
441
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
433
- buildRenderContext(manifest, "/repos/widgets"),
442
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
434
443
  );
435
444
  const declaredScripts = new Set([
436
445
  manifest.candidate.buildScript,
@@ -491,7 +500,7 @@ test("release chain: installed-qualification has a stage summary and publish dow
491
500
  };
492
501
  const content = renderTemplate(
493
502
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
494
- buildRenderContext(manifest, "/repos/widgets"),
503
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
495
504
  );
496
505
  const installedQualificationJob = content.slice(
497
506
  content.search(/^ {2}installed-qualification:\n {4}needs:/m),
@@ -689,7 +698,7 @@ test("every job checking out an admitted revision fetches full history", () => {
689
698
  };
690
699
  const content = renderTemplate(
691
700
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
692
- buildRenderContext(manifest, "/repos/widgets"),
701
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
693
702
  );
694
703
  const checkouts = content
695
704
  .split(/^ {6}- uses: actions\/checkout@/m)
@@ -732,7 +741,7 @@ test("release-candidate template exposes a dry_run input threaded through admiss
732
741
  };
733
742
  const content = renderTemplate(
734
743
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
735
- buildRenderContext(manifest, "/repos/widgets"),
744
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
736
745
  );
737
746
  assert.match(content, /^ dry_run:\n description: .*\n required: false\n type: boolean\n default: false$/m);
738
747
  assert.match(content, /dry_run: \$\{\{ steps\.admit\.outputs\.dry_run \}\}/);
@@ -785,7 +794,7 @@ function environmentManifest(environments) {
785
794
  function renderRelease(manifest) {
786
795
  return renderTemplate(
787
796
  fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/release-candidate.yml"), "utf8"),
788
- buildRenderContext(manifest, "/repos/widgets"),
797
+ buildRenderContext(manifest, "/repos/widgets", "release-candidate"),
789
798
  );
790
799
  }
791
800
 
@@ -820,3 +829,24 @@ test("ci.yml ownership check cannot be removed by deleting optional repo metadat
820
829
  const without = renderTemplate(template, buildRenderContext(structuredClone(FULL_MANIFEST), "/repos/widgets"));
821
830
  assert.match(without, /RightKit ownership \(protected registry roots\)/);
822
831
  });
832
+
833
+ test("rust-hybrid ci uploads QA evidence after the gate only when qaEvidencePath is set", () => {
834
+ const base = { ...FULL_MANIFEST, lanes: ["ci"], profile: "rust-hybrid", gate: { path: "scripts/gate.sh" }, templateVersion: "1.1.0" };
835
+ const template = fs.readFileSync(path.join(templatesDir, "profiles/rust-hybrid/ci.yml"), "utf8");
836
+
837
+ const unset = renderTemplate(template, buildRenderContext(structuredClone(base), "/repos/widgets"));
838
+ assert.doesNotMatch(unset, /upload-artifact|QA evidence|runner\.temp/);
839
+ assert.match(unset, /CARGO_PROFILE_TEST_DEBUG: "0"\n$/);
840
+
841
+ const set = renderTemplate(template, buildRenderContext({ ...structuredClone(base), qaEvidencePath: "pulse-hub-qa" }, "/repos/widgets"));
842
+ assert.ok(set.indexOf("bash ./scripts/gate.sh") < set.indexOf("- name: Upload QA evidence"), "upload must follow the gate step");
843
+ assert.match(
844
+ set,
845
+ / - name: Upload QA evidence\n if: always\(\)\n uses: actions\/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4\n with:\n name: qa-evidence-\$\{\{ matrix\.os \|\| runner\.os \}\}-\$\{\{ github\.run_attempt \}\}\n path: \$\{\{ runner\.temp \}\}\/pulse-hub-qa\n retention-days: 7\n if-no-files-found: ignore\n$/,
846
+ );
847
+ // The upload step is the only difference between the two renders.
848
+ assert.equal(set.replace(/ - name: Upload QA evidence[\s\S]*$/, ""), unset);
849
+
850
+ const custom = renderTemplate(template, buildRenderContext({ ...structuredClone(base), qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays: 30 }, "/repos/widgets"));
851
+ assert.match(custom, /retention-days: 30\n/);
852
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "package": "@rightkit/git",
3
+ "capabilities": [
4
+ {
5
+ "capability": "github-workflows",
6
+ "summary": "Public-repo-only GitHub Actions workflow layer: templates, visibility gate, sync/drift/adopt/uninstall for right-git.; declaration-only detection (no precise implementation fingerprint)",
7
+ "fingerprints": [],
8
+ "detection": "declaration-only",
9
+ "policyNotes": [
10
+ "Package contract retained; dependency declarations do not verify integration.",
11
+ "No precise implementation fingerprint; declaration-only inventory, never enforced ownership or verified integration."
12
+ ]
13
+ }
14
+ ]
15
+ }
package/sync.mjs CHANGED
@@ -31,8 +31,8 @@ function releaseChainDependencyError(repoPath, manifest) {
31
31
 
32
32
  /** Render every lane declared in the manifest. Returns { lane, filename, content }[]. */
33
33
  export function renderLanes(manifest, repoPath) {
34
- const context = buildRenderContext(manifest, repoPath);
35
34
  return manifest.lanes.map((lane) => {
35
+ const context = buildRenderContext(manifest, repoPath, lane);
36
36
  const filename = LANE_FILENAMES[lane];
37
37
  const templatePath = manifest.profile === "rust-hybrid"
38
38
  ? path.join(TEMPLATES_DIR, "profiles", "rust-hybrid", filename)
@@ -45,7 +45,7 @@ jobs:
45
45
  changed=false
46
46
  while IFS= read -r changed_path; do
47
47
  case "$changed_path" in
48
- *.rs|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|build.rs|*/build.rs|rust-toolchain|*/rust-toolchain|rust-toolchain.toml|*/rust-toolchain.toml|.cargo/*|*/.cargo/*|.rightgit.json|.github/workflows/*.yml|.github/workflows/*.yaml|${GATE_PATH})
48
+ *.rs|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|rust-toolchain|*/rust-toolchain|rust-toolchain.toml|*/rust-toolchain.toml|.cargo/*|*/.cargo/*|.rightgit.json|.github/workflows/*.yml|.github/workflows/*.yaml|${GATE_PATH})
49
49
  changed=true
50
50
  break
51
51
  ;;
@@ -93,3 +93,4 @@ ${RUST_CACHE_WORKSPACES}
93
93
  CARGO_INCREMENTAL: "0"
94
94
  CARGO_PROFILE_DEV_DEBUG: "0"
95
95
  CARGO_PROFILE_TEST_DEBUG: "0"
96
+ ${QA_EVIDENCE_STEP}
@@ -43,10 +43,30 @@ ${PACKAGE_MANAGER_SETUP}
43
43
  ${OWNERSHIP_STEP}
44
44
  - run: node scripts/release/build-candidate.mjs --version "${{ github.event.release.tag_name }}" --platform current --out "release/candidates/publish"
45
45
  - run: node scripts/release/check-release.mjs release/candidates/publish
46
+ # check-release must supply publication-evidence.json with artifactSha256.
47
+ # Capture gated bytes before publication; never hash the source path during readback.
46
48
  - id: tarball
47
49
  shell: bash
48
50
  run: |
49
- TARBALL="release/candidates/publish/$(node -e 'const c=require("./release/candidates/publish/compatibility.json");process.stdout.write(c.packageName.replace(/^@/, "").replace("/", "-")+"-"+c.version+".tgz")')"
50
- test -f "$TARBALL"
51
- echo "path=$TARBALL" >> "$GITHUB_OUTPUT"
51
+ node --input-type=module <<'NODE'
52
+ import fs from 'node:fs';
53
+ import { prepareNpmPublication } from '@rightkit/release/publication-gate.mjs';
54
+ const root = 'release/candidates/publish';
55
+ const c = JSON.parse(fs.readFileSync(`${root}/compatibility.json`));
56
+ const tarball = `${root}/${c.packageName.replace(/^@/, '').replace('/', '-')}-${c.version}.tgz`;
57
+ const proof = prepareNpmPublication({ name: c.packageName, version: c.version, tarball, evidencePath: `${root}/publication-evidence.json` });
58
+ // Only safe single-line metadata may enter GitHub outputs.
59
+ for (const value of [tarball, proof.name, proof.version]) if (/[\r\n]/.test(value)) throw new Error('unsafe npm publication identity');
60
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, `path=${tarball}\nname=${proof.name}\nversion=${proof.version}\nsha1=${proof.sha1}\n`);
61
+ NODE
52
62
  - run: npm publish "${{ steps.tarball.outputs.path }}"${NPM_PROVENANCE_FLAG} --access public
63
+ - name: Require registry byte equality
64
+ env:
65
+ NPM_PUBLICATION_NAME: ${{ steps.tarball.outputs.name }}
66
+ NPM_PUBLICATION_VERSION: ${{ steps.tarball.outputs.version }}
67
+ NPM_PUBLICATION_SHA1: ${{ steps.tarball.outputs.sha1 }}
68
+ run: |
69
+ node --input-type=module <<'NODE'
70
+ import { verifyNpmRegistryBytes } from '@rightkit/release/publication-gate.mjs';
71
+ verifyNpmRegistryBytes({ name: process.env.NPM_PUBLICATION_NAME, version: process.env.NPM_PUBLICATION_VERSION, sha1: process.env.NPM_PUBLICATION_SHA1 });
72
+ NODE