@rightkit/git 0.2.26 → 0.2.28

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.
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:_-]*$/;
@@ -75,8 +83,11 @@ const NATIVE_RUNNER_TARGETS = new Map([
75
83
  ["macos-15-intel", ["macos", "x86_64"]],
76
84
  ]);
77
85
 
86
+ // Hosted `macos-*` runners plus the self-hosted Xcode runner labels (`xcode-27`).
87
+ const MACOS_RUNNER_RE = /^(macos|xcode)-/i;
88
+
78
89
  function inferredTarget(os) {
79
- const platform = /^windows-/i.test(os) ? "windows" : /^macos-/i.test(os) ? "macos" : "linux";
90
+ const platform = /^windows-/i.test(os) ? "windows" : MACOS_RUNNER_RE.test(os) ? "macos" : "linux";
80
91
  return { os, platform, architecture: platform === "macos" ? "arm64" : "x86_64" };
81
92
  }
82
93
 
@@ -160,6 +171,21 @@ export function validateManifest(manifest) {
160
171
  errors.push("gate is only valid for profile 'rust-hybrid'");
161
172
  }
162
173
 
174
+ if (manifest.qaEvidencePath !== undefined) {
175
+ if (profile !== "rust-hybrid") {
176
+ errors.push("qaEvidencePath is only valid for profile 'rust-hybrid'");
177
+ } else if (typeof manifest.qaEvidencePath !== "string" || !GATE_PATH_RE.test(manifest.qaEvidencePath)) {
178
+ errors.push("qaEvidencePath must be a safe relative path under $RUNNER_TEMP (no '..', whitespace, leading '/', or expression syntax)");
179
+ }
180
+ }
181
+ if (manifest.qaEvidenceRetentionDays !== undefined) {
182
+ if (manifest.qaEvidencePath === undefined) {
183
+ errors.push("qaEvidenceRetentionDays requires qaEvidencePath");
184
+ } else if (!Number.isInteger(manifest.qaEvidenceRetentionDays) || manifest.qaEvidenceRetentionDays < 1 || manifest.qaEvidenceRetentionDays > QA_EVIDENCE_MAX_RETENTION_DAYS) {
185
+ errors.push(`qaEvidenceRetentionDays must be an integer from 1 to ${QA_EVIDENCE_MAX_RETENTION_DAYS}`);
186
+ }
187
+ }
188
+
163
189
  const rustCache = manifest.rustCache;
164
190
  if (rustCache !== undefined) {
165
191
  if (profile !== "rust-hybrid") {
@@ -230,7 +256,7 @@ export function validateManifest(manifest) {
230
256
  if (nativeTarget && (target.platform !== nativeTarget[0] || target.architecture !== nativeTarget[1])) {
231
257
  errors.push(`candidate.targets[] must declare ${target.os} as ${nativeTarget[0]}/${nativeTarget[1]}`);
232
258
  }
233
- if ((target.platform === "windows" && !/^windows-/i.test(target.os)) || (target.platform === "macos" && !/^macos-/i.test(target.os))) {
259
+ if ((target.platform === "windows" && !/^windows-/i.test(target.os)) || (target.platform === "macos" && !MACOS_RUNNER_RE.test(target.os))) {
234
260
  errors.push("candidate.targets[].os must match its Windows or macOS platform");
235
261
  }
236
262
  const targetKey = `${target.os}|${target.platform}|${target.architecture}`;
@@ -1004,6 +1030,27 @@ export function ownershipStep(manifest, lane = "ci") {
1004
1030
  return `\n - name: RightKit ownership (protected registry roots)\n shell: bash\n run: test "$(git rev-parse HEAD)" = "${sourceRevision}" && ${command}`;
1005
1031
  }
1006
1032
 
1033
+ /**
1034
+ * Optional QA evidence upload, rendered after the rust-hybrid gate step.
1035
+ * Returns "" when qaEvidencePath is unset, so the rendered workflow stays
1036
+ * byte-identical to a manifest without the key.
1037
+ */
1038
+ export function qaEvidenceStep(manifest) {
1039
+ if (manifest.qaEvidencePath === undefined) return "";
1040
+ const retentionDays = manifest.qaEvidenceRetentionDays ?? QA_EVIDENCE_DEFAULT_RETENTION_DAYS;
1041
+ return [
1042
+ " - name: Upload QA evidence",
1043
+ " if: always()",
1044
+ ` uses: ${UPLOAD_ARTIFACT_PIN}`,
1045
+ " with:",
1046
+ " name: qa-evidence-${{ matrix.os || runner.os }}-${{ github.run_attempt }}",
1047
+ ` path: \${{ runner.temp }}/${manifest.qaEvidencePath}`,
1048
+ ` retention-days: ${retentionDays}`,
1049
+ " if-no-files-found: ignore",
1050
+ "",
1051
+ ].join("\n");
1052
+ }
1053
+
1007
1054
  export function buildRenderContext(manifest, repoPath, lane = "ci") {
1008
1055
  const pm = parsePackageManager(manifest.packageManager);
1009
1056
  const projectName = deriveProjectName(repoPath);
@@ -1031,6 +1078,7 @@ export function buildRenderContext(manifest, repoPath, lane = "ci") {
1031
1078
  MACOS_SIGNING_JOB: macosSigningJob(manifest, pm, artifactPrefix),
1032
1079
  RELEASE_CHAIN_JOBS: releaseChainJobs(manifest, pm, artifactPrefix),
1033
1080
  OWNERSHIP_STEP: ownershipStep(manifest, lane),
1081
+ QA_EVIDENCE_STEP: qaEvidenceStep(manifest),
1034
1082
  };
1035
1083
 
1036
1084
  // 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
@@ -207,6 +207,9 @@ test("candidate targets permit native multi-architecture matrices independent of
207
207
  assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, targets: [{ os: "windows-2025", platform: "windows", architecture: "riscv64" }] } }).valid, false);
208
208
  assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, targets: [{ os: "windows-11-arm", platform: "windows", architecture: "x86_64" }] } }).valid, false);
209
209
  assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, targets: [{ os: "ubuntu-24.04", platform: "windows", architecture: "x86_64" }] } }).valid, false);
210
+ const xcodeRunner = { ...multiArchitecture, candidate: { ...multiArchitecture.candidate, os: ["xcode-27"], targets: [{ os: "xcode-27", platform: "macos", architecture: "arm64" }], signWindows: false } };
211
+ assert.equal(validateManifest(xcodeRunner).valid, true, JSON.stringify(validateManifest(xcodeRunner).errors));
212
+ assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, os: ["ubuntu-24.04"], targets: [{ os: "ubuntu-24.04", platform: "macos", architecture: "arm64" }] } }).valid, false);
210
213
  assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, targets: [{ os: "${{ secrets.X }}", platform: "windows", architecture: "x86_64" }] } }).valid, false);
211
214
  assert.equal(validateManifest({ ...multiArchitecture, candidate: { ...multiArchitecture.candidate, releaseChain: { windowsFinalizeScript: "release:finalize:win", macosFinalizeScript: "release:finalize:mac", installedQualificationScript: "release:qualify", publishScript: "release:publish", qualificationRunnerVariable: "QUALIFICATION_RUNNER_LABEL" } } }).valid, false);
212
215
  });
@@ -389,3 +392,17 @@ test("F15 non-regression: realistic homebrew/winget/matrix values still validate
389
392
  });
390
393
  assert.equal(result.valid, true, JSON.stringify(result.errors));
391
394
  });
395
+
396
+ test("qaEvidencePath and qaEvidenceRetentionDays are optional, rust-hybrid only, and charset-checked", () => {
397
+ const valid = { ...VALID_MINIMAL, profile: "rust-hybrid", gate: { path: "scripts/gate.sh" } };
398
+ assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa" }).valid, true);
399
+ assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays: 30 }).valid, true);
400
+ for (const qaEvidencePath of ["/abs", "../up", "pulse hub", "${{ runner.temp }}/x", "a;b", "", null]) {
401
+ assert.equal(validateManifest({ ...valid, qaEvidencePath }).valid, false, String(qaEvidencePath));
402
+ }
403
+ for (const qaEvidenceRetentionDays of [0, 91, 1.5, "7"]) {
404
+ assert.equal(validateManifest({ ...valid, qaEvidencePath: "pulse-hub-qa", qaEvidenceRetentionDays }).valid, false, String(qaEvidenceRetentionDays));
405
+ }
406
+ assert.equal(validateManifest({ ...valid, qaEvidenceRetentionDays: 7 }).valid, false, "retention requires a path");
407
+ assert.equal(validateManifest({ ...VALID_MINIMAL, qaEvidencePath: "pulse-hub-qa" }).valid, false, "node profile rejects qaEvidencePath");
408
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/git",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
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
@@ -829,3 +829,24 @@ test("ci.yml ownership check cannot be removed by deleting optional repo metadat
829
829
  const without = renderTemplate(template, buildRenderContext(structuredClone(FULL_MANIFEST), "/repos/widgets"));
830
830
  assert.match(without, /RightKit ownership \(protected registry roots\)/);
831
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
+ }
@@ -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