@rightkit/release 0.2.153 → 0.2.155
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/cli/right-release.mjs +6 -1
- package/execution-policy.mjs +1 -1
- package/local-publish.mjs +246 -0
- package/msix-build.mjs +3 -6
- package/msix-signer.mjs +211 -0
- package/package.json +1 -1
- package/rightkit-versions.json +1 -1
package/cli/right-release.mjs
CHANGED
|
@@ -86,7 +86,9 @@ if (first === "--version" || first === "-v") {
|
|
|
86
86
|
run("apple-profile-renew.mjs", args.slice(1));
|
|
87
87
|
} else if (first === "publish") {
|
|
88
88
|
const rest = args.slice(1);
|
|
89
|
-
if (rest[0] === "cargo") {
|
|
89
|
+
if ((rest[0] === "cargo" || rest[0] === "npm") && rest.includes("--local")) {
|
|
90
|
+
run("local-publish.mjs", rest);
|
|
91
|
+
} else if (rest[0] === "cargo") {
|
|
90
92
|
run("publish-cargo.mjs", rest.slice(1));
|
|
91
93
|
} else if (rest[0] === "npm") {
|
|
92
94
|
run("publication-gate.mjs", rest.slice(1));
|
|
@@ -202,6 +204,9 @@ Commands:
|
|
|
202
204
|
Publish the gated tarball with the Keychain token
|
|
203
205
|
(npm-rightkit-publish) and verify registry bytes
|
|
204
206
|
publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
|
|
207
|
+
publish npm --package @rightkit/<name> --local Mac-only local publish under the committed local-build grant
|
|
208
|
+
publish cargo --crate <name> --local (.rightkit-local-build.json): runs the unit's CI tests, packs, writes darwin-only
|
|
209
|
+
evidence, then the same publication gate and publisher; refuses without the grant
|
|
205
210
|
publish swift [--dry-run] [--evidence <file>] [--workspace <dir>]
|
|
206
211
|
Check main, a clean tree, and the publication evidence, then tag RightKitSwift at its VERSION on origin
|
|
207
212
|
inventory check [--json] [--warn-only] [--md <file>] [--no-cache]
|
package/execution-policy.mjs
CHANGED
|
@@ -41,7 +41,7 @@ export const LOCAL_BUILD_FILE = ".rightkit-local-build.json";
|
|
|
41
41
|
|
|
42
42
|
// Adrian's local-build opt-in (2026-10-14): a committed .rightkit-local-build.json naming this exact
|
|
43
43
|
// repository allows local build, test and publish. Mirrors the build-control hook's check.
|
|
44
|
-
function localBuildGranted(root) {
|
|
44
|
+
export function localBuildGranted(root) {
|
|
45
45
|
if (!root) return false;
|
|
46
46
|
try {
|
|
47
47
|
const grant = JSON.parse(readFileSync(path.join(root, LOCAL_BUILD_FILE), "utf8"));
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `right-release publish npm|cargo ... --local`: publish ONE @rightkit npm package or crate from the Mac under
|
|
3
|
+
// Adrian's local-build grant (2026-10-14, committed .rightkit-local-build.json). It does not weaken the
|
|
4
|
+
// publication gate: it runs the unit's own CI tests (ci/gate.config.json, ci/asset-gated.json,
|
|
5
|
+
// scripts/ci/gate.mjs semantics), packs the artifact, writes a schema-1 evidence file with one darwin receipt
|
|
6
|
+
// (hostPolicy "darwin-only"), then calls the EXISTING enforcePublicationGate and the EXISTING publisher
|
|
7
|
+
// (publishGatedTarball for npm, runCargoPublish for cargo). There is no way to skip a test or the evidence.
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
+
import { spawnSync } from "./background-process.mjs";
|
|
14
|
+
import { runCargoSync } from "./managed-cargo.mjs";
|
|
15
|
+
import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
|
|
16
|
+
import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
|
|
17
|
+
import { runCargoPublish } from "./publish-cargo.mjs";
|
|
18
|
+
import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
|
|
19
|
+
import { readCargoPackage } from "./crate-graph.mjs";
|
|
20
|
+
import { recordPublished } from "./rightkit-sync.mjs";
|
|
21
|
+
|
|
22
|
+
export const LOCAL_OPERATOR = "agent (local-build grant)";
|
|
23
|
+
export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
|
|
24
|
+
const STEP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
export class LocalPublishError extends Error {
|
|
27
|
+
constructor(message) { super(message); this.name = "LocalPublishError"; }
|
|
28
|
+
}
|
|
29
|
+
const refuse = (message) => { throw new LocalPublishError(message); };
|
|
30
|
+
|
|
31
|
+
export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
|
|
32
|
+
const flag = kind === "npm" ? "--package" : "--crate";
|
|
33
|
+
let unit; let workspace; let local = false;
|
|
34
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
if (arg === "--local") local = true;
|
|
37
|
+
else if (arg === flag) unit = argv[++i];
|
|
38
|
+
else if (arg === "--workspace") workspace = argv[++i];
|
|
39
|
+
else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>, --workspace <dir>, --local)`);
|
|
40
|
+
}
|
|
41
|
+
if (!local) refuse("--local is required");
|
|
42
|
+
if (kind === "npm" && !/^@rightkit\/[a-z0-9-]+$/.test(unit ?? "")) refuse("publish npm --local requires --package @rightkit/<name>");
|
|
43
|
+
if (kind === "cargo" && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(unit ?? "")) refuse("publish cargo --local requires --crate <name>");
|
|
44
|
+
return { kind, unit, workspaceRoot: path.resolve(workspace ?? cwd) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function defaultGit(args, cwd) {
|
|
48
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
|
|
49
|
+
if (result.error || result.status !== 0) return null;
|
|
50
|
+
return (result.stdout ?? "").trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const defaultReadJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
|
|
54
|
+
|
|
55
|
+
async function defaultRegistryHas({ kind, name, version }) {
|
|
56
|
+
if (kind === "npm") {
|
|
57
|
+
const view = spawnSync("npm", ["view", `${name}@${version}`, "version", "--prefer-online", "--registry", "https://registry.npmjs.org/"], { encoding: "utf8", windowsHide: true });
|
|
58
|
+
const out = `${view.stdout ?? ""}${view.stderr ?? ""}`;
|
|
59
|
+
if (!view.error && view.status === 0) return (view.stdout ?? "").trim() === version;
|
|
60
|
+
if (/E404|404 Not Found/i.test(out)) return false;
|
|
61
|
+
throw new Error(`cannot read the npm registry for ${name}@${version}: ${out.trim().slice(0, 200)}`);
|
|
62
|
+
}
|
|
63
|
+
const response = await fetch(cargoIndexUrl(name), { headers: { Accept: "text/plain", "Cache-Control": "no-cache", "User-Agent": "rightkit-release" }, redirect: "error", signal: AbortSignal.timeout(30_000) });
|
|
64
|
+
if (response.status === 404) return false;
|
|
65
|
+
if (!response.ok) throw new Error(`crates.io index answered ${response.status} for ${name}`);
|
|
66
|
+
return (await response.text()).split(/\r?\n/).filter((line) => line.trim()).some((line) => { try { return JSON.parse(line).vers === version; } catch { return false; } });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function defaultRun(command, args, options) {
|
|
70
|
+
const settings = { encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: STEP_TIMEOUT_MS, windowsHide: true, ...options };
|
|
71
|
+
return command === "cargo" ? runCargoSync(args, settings) : spawnSync(command, args, settings);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function defaultLoadWorkspaceModules(workspaceRoot) {
|
|
75
|
+
const load = (file) => import(pathToFileURL(path.join(workspaceRoot, "scripts", "ci", file)).href);
|
|
76
|
+
const [results, upload] = await Promise.all([load("test-results.mjs"), load("upload.mjs")]);
|
|
77
|
+
return { results, checkNpmTarball: upload.checkNpmTarball };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
|
|
81
|
+
export function localTestPlan({ kind, unit, dir, config, manifest }) {
|
|
82
|
+
if (kind === "npm") {
|
|
83
|
+
if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
|
|
84
|
+
return {
|
|
85
|
+
id: `npm:${dir}`, featureLabels: ["default"],
|
|
86
|
+
steps: [
|
|
87
|
+
{ command: "pnpm", args: ["-r", "--workspace-concurrency=1", "run", "build"], test: false },
|
|
88
|
+
{ command: "pnpm", args: ["--dir", dir, "run", "test"], test: true },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const entry = config?.rust?.[unit] ?? {};
|
|
93
|
+
if (entry.os && !entry.os.includes("macos")) refuse(`${unit} is restricted to ${entry.os.join(", ")} by ci/gate.config.json; it cannot be qualified on this Mac`);
|
|
94
|
+
const featureSets = entry.featureSets ?? [[]];
|
|
95
|
+
const steps = [];
|
|
96
|
+
for (const features of featureSets) {
|
|
97
|
+
steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
|
|
98
|
+
if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
|
|
99
|
+
}
|
|
100
|
+
steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
|
|
101
|
+
return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
105
|
+
const tarballName = (name, version) => `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`;
|
|
106
|
+
const tail = (text, lines = 40) => String(text ?? "").split(/\r?\n/).slice(-lines).join("\n");
|
|
107
|
+
|
|
108
|
+
export async function runLocalPublish(kind, argv, deps = {}) {
|
|
109
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
110
|
+
const { unit, workspaceRoot } = parseLocalPublishArgs(kind, argv, deps.cwd);
|
|
111
|
+
const platform = deps.platform ?? process.platform;
|
|
112
|
+
const git = deps.git ?? defaultGit;
|
|
113
|
+
const readJson = deps.readJson ?? defaultReadJson;
|
|
114
|
+
const run = deps.run ?? defaultRun;
|
|
115
|
+
|
|
116
|
+
// 1. Preconditions, fail closed.
|
|
117
|
+
if (!(deps.localBuildGranted ?? localBuildGranted)(workspaceRoot)) refuse(`--local needs the local-build grant: a committed ${LOCAL_BUILD_FILE} (allow "build-test-publish") naming this repository at ${workspaceRoot}`);
|
|
118
|
+
if (platform !== "darwin") refuse(`--local publishes from the Mac only (host policy: darwin-only); this host is ${platform}`);
|
|
119
|
+
const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
|
|
120
|
+
const unitPath = path.join(workspaceRoot, dir);
|
|
121
|
+
if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
|
|
122
|
+
let manifest = null; let version;
|
|
123
|
+
if (kind === "npm") {
|
|
124
|
+
manifest = readJson(path.join(unitPath, "package.json")) ?? refuse(`cannot read ${dir}/package.json`);
|
|
125
|
+
if (manifest.name !== unit) refuse(`${dir}/package.json is ${manifest.name}, not ${unit}`);
|
|
126
|
+
if (manifest.private === true) refuse(`${unit} is private and is never published`);
|
|
127
|
+
version = manifest.version;
|
|
128
|
+
} else {
|
|
129
|
+
const pkg = (deps.readCargoPackage ?? readCargoPackage)(unitPath);
|
|
130
|
+
if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crate ${unit}`);
|
|
131
|
+
version = pkg.version;
|
|
132
|
+
}
|
|
133
|
+
if (typeof version !== "string" || !version.trim()) refuse(`${unit} has no literal version`);
|
|
134
|
+
const head = git(["rev-parse", "HEAD"], workspaceRoot);
|
|
135
|
+
if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
|
|
136
|
+
const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
|
|
137
|
+
if (dirty === null) refuse(`cannot read git status for ${dir}`);
|
|
138
|
+
if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
|
|
139
|
+
if (await (deps.registryHas ?? defaultRegistryHas)({ kind, name: unit, version })) refuse(`${unit}@${version} is already on the ${kind === "npm" ? "npm" : "crates.io"} registry; bump the version`);
|
|
140
|
+
log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, darwin-only policy)`);
|
|
141
|
+
|
|
142
|
+
// 2. The unit's own tests, exactly as the CI gate defines them.
|
|
143
|
+
const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
|
|
144
|
+
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
145
|
+
const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
|
|
146
|
+
const isAssetGated = Object.hasOwn(gatedSection, unit);
|
|
147
|
+
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest });
|
|
148
|
+
const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
|
|
149
|
+
const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
|
|
150
|
+
// Run the unit's tests under hosted-CI conditions: no inherited credentials (a managed host refuses a
|
|
151
|
+
// token from the environment) and no sibling product checkouts (CI holds rightkit alone).
|
|
152
|
+
const inherited = Object.fromEntries(Object.entries(deps.env ?? process.env).filter(([key]) => !/(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i.test(key)));
|
|
153
|
+
const env = { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
|
|
154
|
+
const steps = [];
|
|
155
|
+
for (const step of plan.steps) {
|
|
156
|
+
const commandText = `${step.command} ${step.args.join(" ")}`;
|
|
157
|
+
log(`[right-release] ${plan.id}: ${commandText}`);
|
|
158
|
+
const result = run(step.command, step.args, { cwd: workspaceRoot, env });
|
|
159
|
+
const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
|
|
160
|
+
if (result.error || result.status !== 0) {
|
|
161
|
+
steps.push(row);
|
|
162
|
+
refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\n${tail(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)}`);
|
|
163
|
+
}
|
|
164
|
+
row.ok = true;
|
|
165
|
+
if (row.isTest) {
|
|
166
|
+
const out = new ResultScanner(); const err = new ResultScanner();
|
|
167
|
+
out.push(result.stdout ?? ""); err.push(result.stderr ?? "");
|
|
168
|
+
const a = out.finish(); const b = err.finish();
|
|
169
|
+
Object.assign(row, { executed: a.executed + b.executed, failed: a.failed + b.failed, ignored: a.ignored + b.ignored, skipped: a.skipped + b.skipped, binaries: a.binaries + b.binaries });
|
|
170
|
+
}
|
|
171
|
+
steps.push(row);
|
|
172
|
+
}
|
|
173
|
+
const verdict = judgeUnit({ name: unit, steps: steps.map((s) => ({ ...s, unit: plan.id })), allowIgnored: [], allowMissingAssets: isAssetGated ? [unit] : [] });
|
|
174
|
+
if (verdict.status === "fail" || steps.some((s) => s.failed > 0)) refuse(`${plan.id} not qualified: ${verdict.problems.join("; ") || "failed tests reported"}`);
|
|
175
|
+
if (!(verdict.executed > 0)) refuse(`${plan.id} executed no test`);
|
|
176
|
+
log(`[right-release] ${plan.id}: ${verdict.executed} passed, 0 failed, ${verdict.skipped} skipped, ${verdict.ignored} ignored${isAssetGated ? " (asset-gated: RIGHTKIT_REQUIRE_ASSETS=0, skips recorded)" : ""}`);
|
|
177
|
+
|
|
178
|
+
// 3. Pack the artifact and hash it.
|
|
179
|
+
const scratch = fs.mkdtempSync(path.join(deps.tmpdir ?? os.tmpdir(), "rk-local-publish-"));
|
|
180
|
+
const cleanup = () => fs.rmSync(scratch, { recursive: true, force: true });
|
|
181
|
+
try {
|
|
182
|
+
let artifactFile; const packCommands = [];
|
|
183
|
+
if (kind === "npm") {
|
|
184
|
+
const args = ["--dir", dir, "pack", "--pack-destination", scratch];
|
|
185
|
+
packCommands.push(`pnpm ${args.join(" ")}`);
|
|
186
|
+
const packed = run("pnpm", args, { cwd: workspaceRoot, env });
|
|
187
|
+
if (packed.error || packed.status !== 0) refuse(`pnpm pack failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
|
|
188
|
+
artifactFile = path.join(scratch, tarballName(unit, version));
|
|
189
|
+
if (!fs.existsSync(artifactFile)) refuse(`pnpm pack did not produce ${path.basename(artifactFile)}`);
|
|
190
|
+
const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
|
|
191
|
+
if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
|
|
192
|
+
} else {
|
|
193
|
+
packCommands.push(`cargo package --locked -p ${unit}`);
|
|
194
|
+
const packed = run("cargo", ["package", "--locked", "-p", unit], { cwd: workspaceRoot, env });
|
|
195
|
+
if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
|
|
196
|
+
packCommands.push("cargo metadata --format-version 1 --no-deps");
|
|
197
|
+
const meta = run("cargo", ["metadata", "--format-version", "1", "--no-deps"], { cwd: workspaceRoot, env });
|
|
198
|
+
let targetDir;
|
|
199
|
+
try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
|
|
200
|
+
if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) refuse("cannot read the Cargo target directory");
|
|
201
|
+
artifactFile = path.join(targetDir, "package", `${unit}-${version}.crate`);
|
|
202
|
+
if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
|
|
203
|
+
}
|
|
204
|
+
const artifactSha256 = sha256(fs.readFileSync(artifactFile));
|
|
205
|
+
|
|
206
|
+
// 4. Evidence in the normal schema, then the EXISTING gate.
|
|
207
|
+
const commands = [...plan.steps.map((s) => `${s.command} ${s.args.join(" ")}`), ...packCommands];
|
|
208
|
+
const timestamp = (deps.now ?? (() => new Date().toISOString()))();
|
|
209
|
+
const evidence = {
|
|
210
|
+
schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
|
|
211
|
+
hostPolicy: "darwin-only", hostPolicyReason: LOCAL_HOST_POLICY_REASON,
|
|
212
|
+
runs: [{
|
|
213
|
+
host: { os: "darwin", arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
|
|
214
|
+
command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
|
|
215
|
+
passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored,
|
|
216
|
+
requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
|
|
217
|
+
steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
|
|
218
|
+
}],
|
|
219
|
+
};
|
|
220
|
+
const evidencePath = path.resolve(defaultEvidencePath(workspaceRoot, kind, unit));
|
|
221
|
+
fs.mkdirSync(path.dirname(evidencePath), { recursive: true });
|
|
222
|
+
fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
|
|
223
|
+
log(`[right-release] wrote ${evidencePath} (artifact sha256 ${artifactSha256})`);
|
|
224
|
+
const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
|
|
225
|
+
(deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
|
|
226
|
+
|
|
227
|
+
// 5. The EXISTING publish path.
|
|
228
|
+
if (kind === "npm") {
|
|
229
|
+
(deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
|
|
230
|
+
} else {
|
|
231
|
+
const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath }, undefined, deps.cargoDeps ?? {});
|
|
232
|
+
(deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version });
|
|
233
|
+
}
|
|
234
|
+
log(`[right-release] published ${unit}@${version}`);
|
|
235
|
+
return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
|
|
236
|
+
} finally {
|
|
237
|
+
cleanup();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
|
|
242
|
+
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
243
|
+
const [kind, ...rest] = process.argv.slice(2);
|
|
244
|
+
if (kind !== "npm" && kind !== "cargo") { process.stderr.write("right-release publish --local: expected npm or cargo\n"); process.exitCode = 2; }
|
|
245
|
+
else runLocalPublish(kind, rest).catch((error) => { process.stderr.write(`right-release publish ${kind} --local: ${error.message}\n`); process.exitCode = 1; });
|
|
246
|
+
}
|
package/msix-build.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { spawnSync } from "./background-process.mjs";
|
|
11
11
|
import { findWindowsSdkTool } from "./preflight.mjs";
|
|
12
12
|
import { generateAppInstaller } from "./msix-appinstaller.mjs";
|
|
13
|
+
import { readMsixSignerSubject } from "./msix-signer.mjs";
|
|
13
14
|
import { assertPublisherMatchesSigner, planMsixSigning, verifyPayloadInventory } from "./msix-plan.mjs";
|
|
14
15
|
import { createHash } from "node:crypto";
|
|
15
16
|
|
|
@@ -102,20 +103,16 @@ export function peFilesIn(root) {
|
|
|
102
103
|
|
|
103
104
|
const sha256 = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
104
105
|
|
|
105
|
-
function readSignerSubject(file, run) {
|
|
106
|
-
const r = run("powershell", ["-NoProfile", "-Command", `(Get-AuthenticodeSignature -LiteralPath '${file.replace(/'/g, "''")}').SignerCertificate.Subject`], { encoding: "utf8", windowsHide: true });
|
|
107
|
-
return r.status === 0 ? String(r.stdout ?? "").trim() : "";
|
|
108
|
-
}
|
|
109
|
-
|
|
110
106
|
/**
|
|
111
107
|
* Pack + sign. `run` is injectable for tests. Ordered per planMsixSigning():
|
|
112
108
|
* assemble, verify-inventory, sign-pe (DLLs then EXEs, raw bytes), pack, sign-msix, verify-msix,
|
|
113
109
|
* verify-publisher, appinstaller, seal. Windows-only unless dryRun.
|
|
110
|
+
* readSubject(file) defaults to reading AppxSignature.p7x (msix-signer.mjs) and may be overridden.
|
|
114
111
|
* options.runtime = { dlls, systemDlls, dependencies } (declared inventory, verified fail-closed);
|
|
115
112
|
* options.appInstaller = { uri, msixUri, out, ... } generates the update route after the package is signed.
|
|
116
113
|
*/
|
|
117
114
|
export function buildMsix({ stageDir, out, workDir, manifest, assets, runtime, appInstaller, sign = true, dryRun = false, receiptDir, peReceipt, msixReceipt },
|
|
118
|
-
{ platform = process.platform, run = spawnSync, findTool = findWindowsSdkTool, env = process.env, log = (e) => console.error(JSON.stringify(e)), readSubject =
|
|
115
|
+
{ platform = process.platform, run = spawnSync, findTool = findWindowsSdkTool, env = process.env, log = (e) => console.error(JSON.stringify(e)), readSubject = readMsixSignerSubject } = {}) {
|
|
119
116
|
out = path.resolve(out);
|
|
120
117
|
if (!out.endsWith(".msix")) throw fail("MSIX_INVALID_OPTIONS", "out must end with .msix");
|
|
121
118
|
validateMsixIdentity(manifest);
|
package/msix-signer.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Default signer-subject reader for .msix / .msixbundle.
|
|
2
|
+
// PowerShell Get-AuthenticodeSignature returns nothing for MSIX on hosted Windows runners, so the subject
|
|
3
|
+
// is read from the package's own AppxSignature.p7x: a "PKCX" magic followed by a PKCS#7 SignedData.
|
|
4
|
+
// Pure Node (node:zlib + node:crypto X509Certificate + a small DER walker); no dependency, any host.
|
|
5
|
+
// The certificate chosen is the one named by SignerInfo issuerAndSerialNumber (or subjectKeyIdentifier),
|
|
6
|
+
// never merely the first certificate. Every failure throws MSIX_PUBLISHER_MISMATCH (fail closed).
|
|
7
|
+
|
|
8
|
+
import { X509Certificate } from "node:crypto";
|
|
9
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
10
|
+
import { inflateRawSync } from "node:zlib";
|
|
11
|
+
|
|
12
|
+
const fail = (message) => Object.assign(new Error(message), { code: "MSIX_PUBLISHER_MISMATCH" });
|
|
13
|
+
const MAX_P7X = 4 * 1024 * 1024;
|
|
14
|
+
const SIGNED_DATA_OID = "1.2.840.113549.1.7.2";
|
|
15
|
+
|
|
16
|
+
// --- zip container -----------------------------------------------------------------------------
|
|
17
|
+
const u64 = (b, o) => Number(b.readBigUInt64LE(o));
|
|
18
|
+
|
|
19
|
+
function readAt(fd, position, length) {
|
|
20
|
+
const buffer = Buffer.alloc(length);
|
|
21
|
+
let done = 0;
|
|
22
|
+
while (done < length) {
|
|
23
|
+
const n = readSync(fd, buffer, done, length - done, position + done);
|
|
24
|
+
if (n === 0) break;
|
|
25
|
+
done += n;
|
|
26
|
+
}
|
|
27
|
+
return buffer.subarray(0, done);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Return the bytes of the named entry of a zip file, or null when absent. */
|
|
31
|
+
export function readZipEntry(file, wanted, { maxBytes = MAX_P7X } = {}) {
|
|
32
|
+
const fd = openSync(file, "r");
|
|
33
|
+
try {
|
|
34
|
+
const size = fstatSync(fd).size;
|
|
35
|
+
const tailLength = Math.min(size, 22 + 65535);
|
|
36
|
+
const tail = readAt(fd, size - tailLength, tailLength);
|
|
37
|
+
let entries; let dirSize; let dirOffset; let found = false;
|
|
38
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
39
|
+
if (tail.readUInt32LE(i) !== 0x06054b50) continue;
|
|
40
|
+
if (i + 22 + tail.readUInt16LE(i + 20) > tail.length) continue;
|
|
41
|
+
entries = tail.readUInt16LE(i + 10); dirSize = tail.readUInt32LE(i + 12); dirOffset = tail.readUInt32LE(i + 16);
|
|
42
|
+
if (entries === 0xffff || dirSize === 0xffffffff || dirOffset === 0xffffffff) {
|
|
43
|
+
const at = size - tailLength + i;
|
|
44
|
+
const loc = at >= 20 ? readAt(fd, at - 20, 20) : Buffer.alloc(0);
|
|
45
|
+
if (loc.length < 20 || loc.readUInt32LE(0) !== 0x07064b50) throw new Error("zip64 locator missing");
|
|
46
|
+
const rec = readAt(fd, u64(loc, 8), 56);
|
|
47
|
+
if (rec.length < 56 || rec.readUInt32LE(0) !== 0x06064b50) throw new Error("zip64 end record missing");
|
|
48
|
+
entries = u64(rec, 32); dirSize = u64(rec, 40); dirOffset = u64(rec, 48);
|
|
49
|
+
}
|
|
50
|
+
found = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (!found) throw new Error("not a zip container (end of central directory not found)");
|
|
54
|
+
if (dirSize > 256 * 1024 * 1024 || dirOffset + dirSize > size) throw new Error("central directory out of bounds");
|
|
55
|
+
const dir = readAt(fd, dirOffset, dirSize);
|
|
56
|
+
if (dir.length !== dirSize) throw new Error("central directory truncated");
|
|
57
|
+
let at = 0;
|
|
58
|
+
for (let n = 0; n < entries; n += 1) {
|
|
59
|
+
if (at + 46 > dir.length || dir.readUInt32LE(at) !== 0x02014b50) throw new Error("bad central directory entry");
|
|
60
|
+
const flags = dir.readUInt16LE(at + 8); const method = dir.readUInt16LE(at + 10);
|
|
61
|
+
let compressed = dir.readUInt32LE(at + 20); let uncompressed = dir.readUInt32LE(at + 24);
|
|
62
|
+
const nameLen = dir.readUInt16LE(at + 28); const extraLen = dir.readUInt16LE(at + 30); const commentLen = dir.readUInt16LE(at + 32);
|
|
63
|
+
let local = dir.readUInt32LE(at + 42);
|
|
64
|
+
const end = at + 46 + nameLen + extraLen + commentLen;
|
|
65
|
+
if (end > dir.length) throw new Error("central directory entry out of bounds");
|
|
66
|
+
const name = dir.subarray(at + 46, at + 46 + nameLen).toString("utf8");
|
|
67
|
+
if (name === wanted) {
|
|
68
|
+
const extra = dir.subarray(at + 46 + nameLen, at + 46 + nameLen + extraLen);
|
|
69
|
+
for (let e = 0; e + 4 <= extra.length; e += 4 + extra.readUInt16LE(e + 2)) {
|
|
70
|
+
if (extra.readUInt16LE(e) !== 0x0001) continue;
|
|
71
|
+
let p = e + 4;
|
|
72
|
+
if (uncompressed === 0xffffffff) { uncompressed = u64(extra, p); p += 8; }
|
|
73
|
+
if (compressed === 0xffffffff) { compressed = u64(extra, p); p += 8; }
|
|
74
|
+
if (local === 0xffffffff) local = u64(extra, p);
|
|
75
|
+
}
|
|
76
|
+
if (flags & 1) throw new Error(`${wanted} is encrypted`);
|
|
77
|
+
if (uncompressed > maxBytes || compressed > maxBytes) throw new Error(`${wanted} larger than ${maxBytes} bytes`);
|
|
78
|
+
const lh = readAt(fd, local, 30);
|
|
79
|
+
if (lh.length < 30 || lh.readUInt32LE(0) !== 0x04034b50) throw new Error("bad local header");
|
|
80
|
+
const start = local + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28);
|
|
81
|
+
if (start + compressed > size) throw new Error("entry data out of bounds");
|
|
82
|
+
const raw = readAt(fd, start, compressed);
|
|
83
|
+
let data;
|
|
84
|
+
if (method === 0) data = raw;
|
|
85
|
+
else if (method === 8) data = inflateRawSync(raw, { maxOutputLength: maxBytes });
|
|
86
|
+
else throw new Error(`unsupported zip method ${method}`);
|
|
87
|
+
if (data.length !== uncompressed) throw new Error(`${wanted} length differs from its directory entry`);
|
|
88
|
+
return data;
|
|
89
|
+
}
|
|
90
|
+
at = end;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
} finally { closeSync(fd); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- DER ---------------------------------------------------------------------------------------
|
|
97
|
+
/** Read one TLV at `at`; returns { tag, start (of header), body (start), end }. */
|
|
98
|
+
function tlv(buf, at, limit = buf.length) {
|
|
99
|
+
if (at + 2 > limit) throw new Error("DER truncated");
|
|
100
|
+
const tag = buf[at]; let len = buf[at + 1]; let body = at + 2;
|
|
101
|
+
if ((tag & 0x1f) === 0x1f) throw new Error("DER high tag numbers unsupported");
|
|
102
|
+
if (len & 0x80) {
|
|
103
|
+
const n = len & 0x7f;
|
|
104
|
+
if (n === 0) return indefinite(buf, tag, at, limit); // BER indefinite length
|
|
105
|
+
if (n > 4 || body + n > limit) throw new Error("DER bad length");
|
|
106
|
+
len = 0;
|
|
107
|
+
for (let i = 0; i < n; i += 1) len = len * 256 + buf[body + i];
|
|
108
|
+
body += n;
|
|
109
|
+
}
|
|
110
|
+
if (body + len > limit) throw new Error("DER length exceeds container");
|
|
111
|
+
return { tag, start: at, body, end: body + len };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// BER indefinite-length constructed value: children until 00 00.
|
|
115
|
+
function indefinite(buf, tag, at, limit) {
|
|
116
|
+
if (!(tag & 0x20)) throw new Error("indefinite length on primitive");
|
|
117
|
+
const body = at + 2; let p = body;
|
|
118
|
+
while (true) {
|
|
119
|
+
if (p + 2 > limit) throw new Error("BER unterminated");
|
|
120
|
+
if (buf[p] === 0 && buf[p + 1] === 0) return { tag, start: at, body, end: p, tail: p + 2 };
|
|
121
|
+
p = next(tlv(buf, p, limit));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const next = (t) => t.tail ?? t.end;
|
|
125
|
+
const children = (buf, t) => {
|
|
126
|
+
const out = []; let p = t.body;
|
|
127
|
+
while (p < t.end) { const c = tlv(buf, p, t.end); out.push(c); p = next(c); }
|
|
128
|
+
return out;
|
|
129
|
+
};
|
|
130
|
+
const whole = (buf, t) => buf.subarray(t.start, next(t));
|
|
131
|
+
const oid = (buf, t) => {
|
|
132
|
+
const b = buf.subarray(t.body, t.end); const parts = [Math.floor(b[0] / 40), b[0] % 40]; let v = 0;
|
|
133
|
+
for (let i = 1; i < b.length; i += 1) { v = v * 128 + (b[i] & 0x7f); if (!(b[i] & 0x80)) { parts.push(v); v = 0; } }
|
|
134
|
+
return parts.join(".");
|
|
135
|
+
};
|
|
136
|
+
const intHex = (buf, t) => { let h = buf.subarray(t.body, t.end).toString("hex").replace(/^(00)+(?=.)/, ""); return h.toLowerCase(); };
|
|
137
|
+
|
|
138
|
+
/** Parse PKCS#7 SignedData; return { certificates: [Buffer], signer: { issuer: Buffer, serial: string } | { ski: string } }. */
|
|
139
|
+
export function parseSignedData(der) {
|
|
140
|
+
const ci = tlv(der, 0);
|
|
141
|
+
if (ci.tag !== 0x30) throw new Error("ContentInfo is not a SEQUENCE");
|
|
142
|
+
const [type, content] = children(der, ci);
|
|
143
|
+
if (!type || type.tag !== 0x06 || oid(der, type) !== SIGNED_DATA_OID) throw new Error("not a PKCS#7 SignedData");
|
|
144
|
+
if (!content || content.tag !== 0xa0) throw new Error("SignedData content missing");
|
|
145
|
+
const sd = children(der, content)[0];
|
|
146
|
+
if (!sd || sd.tag !== 0x30) throw new Error("SignedData is not a SEQUENCE");
|
|
147
|
+
const parts = children(der, sd);
|
|
148
|
+
const certsSet = parts.find((p) => p.tag === 0xa0);
|
|
149
|
+
const signerSet = parts.filter((p) => p.tag === 0x31).at(-1);
|
|
150
|
+
if (!certsSet) throw new Error("SignedData carries no certificates");
|
|
151
|
+
if (!signerSet) throw new Error("SignedData carries no SignerInfos");
|
|
152
|
+
const certificates = children(der, certsSet).filter((c) => c.tag === 0x30).map((c) => Buffer.from(whole(der, c)));
|
|
153
|
+
const infos = children(der, signerSet);
|
|
154
|
+
if (infos.length !== 1) throw new Error(`expected exactly one SignerInfo, found ${infos.length}`);
|
|
155
|
+
const [, sid] = children(der, infos[0]);
|
|
156
|
+
if (!sid) throw new Error("SignerInfo has no signer identifier");
|
|
157
|
+
let signer;
|
|
158
|
+
if (sid.tag === 0x30) {
|
|
159
|
+
const [issuer, serial] = children(der, sid);
|
|
160
|
+
signer = { issuer: Buffer.from(whole(der, issuer)), serial: intHex(der, serial) };
|
|
161
|
+
} else if (sid.tag === 0x80) signer = { ski: Buffer.from(der.subarray(sid.body, sid.end)).toString("hex") };
|
|
162
|
+
else throw new Error("unsupported SignerInfo signer identifier");
|
|
163
|
+
return { certificates, signer };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** TBSCertificate issuer (DER) and serial (hex) of a certificate. */
|
|
167
|
+
function certIdentity(cert) {
|
|
168
|
+
const [tbs] = children(cert, tlv(cert, 0));
|
|
169
|
+
const f = children(cert, tbs);
|
|
170
|
+
const i = f[0].tag === 0xa0 ? 1 : 0; // optional [0] version
|
|
171
|
+
return { serial: intHex(cert, f[i]), issuer: Buffer.from(whole(cert, f[i + 2])) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function signerCertificate(parsed) {
|
|
175
|
+
const match = parsed.certificates.filter((c) => {
|
|
176
|
+
try {
|
|
177
|
+
if (parsed.signer.ski) {
|
|
178
|
+
const x = new X509Certificate(c);
|
|
179
|
+
return x.raw && (x.subjectKeyIdentifier ?? "").replace(/:/g, "").toLowerCase() === parsed.signer.ski;
|
|
180
|
+
}
|
|
181
|
+
const id = certIdentity(c);
|
|
182
|
+
return id.serial === parsed.signer.serial && id.issuer.equals(parsed.signer.issuer);
|
|
183
|
+
} catch { return false; }
|
|
184
|
+
});
|
|
185
|
+
if (match.length !== 1) throw new Error(`signer certificate not found in the signature (${match.length} matches)`);
|
|
186
|
+
return new X509Certificate(match[0]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- subject rendering ---------------------------------------------------------------------------
|
|
190
|
+
const needsQuote = (v) => /[,+"\\<>;=\r\n]/.test(v) || /^[ #]|\s$/.test(v);
|
|
191
|
+
/** Node renders subject as "K=V" lines; join as an RFC 2253-style display string like .NET Subject ("CN=a, O=b"). */
|
|
192
|
+
export function renderSubject(x509) {
|
|
193
|
+
return String(x509.subject).split("\n").filter(Boolean).map((line) => {
|
|
194
|
+
const at = line.indexOf("=");
|
|
195
|
+
const key = line.slice(0, at); const value = line.slice(at + 1);
|
|
196
|
+
return `${key}=${needsQuote(value) ? `"${value.replace(/"/g, '""')}"` : value}`;
|
|
197
|
+
}).join(", ");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Read the signer certificate subject of a signed .msix / .msixbundle from AppxSignature.p7x. Throws MSIX_PUBLISHER_MISMATCH. */
|
|
201
|
+
export function readMsixSignerSubject(file) {
|
|
202
|
+
let p7x;
|
|
203
|
+
try { p7x = readZipEntry(file, "AppxSignature.p7x"); } catch (error) { throw fail(`could not read the signer certificate subject: ${error.message}`); }
|
|
204
|
+
if (!p7x) throw fail("could not read the signer certificate subject: AppxSignature.p7x is missing (package is unsigned)");
|
|
205
|
+
try {
|
|
206
|
+
if (p7x.length < 5 || p7x.subarray(0, 4).toString("latin1") !== "PKCX") throw new Error("AppxSignature.p7x lacks the PKCX header");
|
|
207
|
+
return renderSubject(signerCertificate(parseSignedData(p7x.subarray(4))));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw fail(`could not read the signer certificate subject: AppxSignature.p7x unparsable (${error.message})`);
|
|
210
|
+
}
|
|
211
|
+
}
|
package/package.json
CHANGED
package/rightkit-versions.json
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@rightkit/platform-ui": "0.2.7",
|
|
28
28
|
"@rightkit/qa": "0.4.7",
|
|
29
29
|
"@rightkit/qa-chrome": "0.1.5",
|
|
30
|
-
"@rightkit/release": "0.2.
|
|
30
|
+
"@rightkit/release": "0.2.155",
|
|
31
31
|
"@rightkit/settings-ui": "0.4.3",
|
|
32
32
|
"@rightkit/tauri": "0.2.0",
|
|
33
33
|
"@rightkit/theme": "0.2.3"
|