@rightkit/release 0.4.0 → 0.5.0
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 +59 -7
- package/adhoc-entitlements.mjs +8 -0
- package/adoption-evidence.mjs +650 -0
- package/advisory-exceptions.mjs +371 -0
- package/apple-profile-create.mjs +113 -10
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1079 -0
- package/binary-license-notices.mjs +161 -0
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +39 -0
- package/deterministic-scans.mjs +966 -0
- package/development-signing.mjs +2 -1
- package/host-upload-refusal.mjs +90 -0
- package/local-publish.mjs +142 -19
- package/local-qualify.mjs +728 -0
- package/local-runtime.mjs +319 -0
- package/lock-graph.mjs +51 -0
- package/managed-cargo.mjs +4 -1
- package/ownership-approval.mjs +23 -6
- package/ownership-dependency-allowlist.json +2 -0
- package/ownership-mandatory-set.json +158 -68
- package/ownership-mandatory-set.mjs +82 -6
- package/package.json +6 -2
- package/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-receipts.mjs +302 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/production-mode.mjs +42 -0
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +24 -7
- package/publish-swift.mjs +12 -3
- package/qa-control-scan.mjs +4 -0
- package/release-config.mjs +330 -0
- package/release-switch-scan.mjs +44 -5
- package/release.mjs +14 -4
- package/rightkit-ownership.json +57 -0
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +191 -131
- package/runtime-budget-classes.json +8 -0
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +128 -31
- package/scan-digest.mjs +107 -0
- package/sign-qa-bundle.mjs +210 -0
- package/stage-sidecar.mjs +99 -15
- package/swap-helper-bundle.mjs +7 -3
- package/workflow-yaml.mjs +177 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// The release config contract (M24): `right-release.config.json`, a finite, versioned JSON schema read by dev, build, doctor, install
|
|
2
|
+
// and upload alike. It is data. RightKit never imports, evaluates or converts an executable (`.mjs`, `.js`, `.cjs`, `.ts`) config: the
|
|
3
|
+
// file name is judged BEFORE anything is opened or imported, including for `--dry-run`, so a legacy config cannot run a single line.
|
|
4
|
+
//
|
|
5
|
+
// Load order (build, dev, upload): resolve the primary checkout and the config path first, then `loadReleaseConfigSync` parses the JSON
|
|
6
|
+
// strictly (no comments, no trailing commas, no duplicate keys), checks `schema: 1` and the finite top-level key set, expands the
|
|
7
|
+
// typed templates below, and runs the M24 pipeline validation (`pipeline-config.mjs`) so a command that is not an admitted RightKit
|
|
8
|
+
// invocation stops the run before the #340 preflight and before any recipe executes. A hash alone is never trusted: each effect
|
|
9
|
+
// boundary (build, sign, upload) loads and validates again.
|
|
10
|
+
//
|
|
11
|
+
// Typed templates (the only computation a config may express):
|
|
12
|
+
// { "$fromJson": { "file": "src-tauri/tauri.conf.json", "pointer": "/version" } } a string read from an app-relative JSON file
|
|
13
|
+
// { "$fromToml": { "file": "src-tauri/Cargo.toml", "path": "package.version" } } a string read from an app-relative TOML file
|
|
14
|
+
// { "$json": <value> } JSON.stringify(<value>), for a JSON argument
|
|
15
|
+
// "{{app}}" "{{version}}" "{{home}}" "{{localAppData}}" replaced inside any string value
|
|
16
|
+
// Anything else with braces is left alone, and an unknown {{token}} is an error.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { parse as parseToml } from "smol-toml";
|
|
22
|
+
import { collectConfigCommands, formatFindings, validateConfigPipeline } from "./pipeline-config.mjs";
|
|
23
|
+
import { loadGeneratorRegistry } from "./pipeline-generators.mjs";
|
|
24
|
+
|
|
25
|
+
export const RELEASE_CONFIG_FILE = "right-release.config.json";
|
|
26
|
+
export const LEGACY_RELEASE_CONFIG_FILE = "right-release.config.mjs";
|
|
27
|
+
export const RELEASE_CONFIG_SCHEMA = 1;
|
|
28
|
+
export const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
29
|
+
export const LEGACY_EXECUTABLE_EXTENSIONS = Object.freeze([".mjs", ".js", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx"]);
|
|
30
|
+
|
|
31
|
+
/** The complete set of top-level keys. A key outside it is refused: a new capability is a RightKit change, not an app extension. */
|
|
32
|
+
export const TOP_LEVEL_KEYS = Object.freeze([
|
|
33
|
+
"$schema", "$comment", "schema", "app", "version", "channel", "distribution", "packageManager", "workdir", "targets", "development",
|
|
34
|
+
"deps", "checks", "buildInputs", "hostedWorkflows", "devtools", "releaseSwitches", "qa", "nativeAssembly", "legal", "web", "tests",
|
|
35
|
+
"sidecars", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts",
|
|
36
|
+
]);
|
|
37
|
+
export const TEMPLATE_TOKENS = Object.freeze(["app", "version", "home", "localAppData"]);
|
|
38
|
+
|
|
39
|
+
export class ReleaseConfigError extends Error {
|
|
40
|
+
constructor(code, message, details = {}) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "ReleaseConfigError";
|
|
43
|
+
this.code = code;
|
|
44
|
+
Object.assign(this, details);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const fail = (code, message, details) => { throw new ReleaseConfigError(code, message, details); };
|
|
48
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Judge a config file NAME. Throws for an executable config and for anything that is not `.json`. It touches no file: call it before
|
|
52
|
+
* opening, importing or even resolving a repository, so a legacy config is refused on every route (dry runs included).
|
|
53
|
+
*/
|
|
54
|
+
export function assertReleaseConfigFileName(file) {
|
|
55
|
+
if (typeof file !== "string" || !file.trim()) fail("RELEASE_CONFIG_NOT_JSON", "a release config path is required (right-release.config.json)");
|
|
56
|
+
const extension = path.extname(path.basename(file)).toLowerCase();
|
|
57
|
+
if (LEGACY_EXECUTABLE_EXTENSIONS.includes(extension)) {
|
|
58
|
+
fail("RELEASE_CONFIG_LEGACY_EXECUTABLE", `${file} is an executable release config (${extension}). RightKit never imports, evaluates or converts one; write ${RELEASE_CONFIG_FILE} (finite JSON schema, see the README section "Release config (JSON)") and delete the module.`, { file });
|
|
59
|
+
}
|
|
60
|
+
if (extension !== ".json") fail("RELEASE_CONFIG_NOT_JSON", `${file} is not a JSON release config; the only config file is ${RELEASE_CONFIG_FILE}`, { file });
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A hint for a missing config: a legacy sibling is named (never opened). */
|
|
65
|
+
function missingConfigMessage(file) {
|
|
66
|
+
const legacy = path.join(path.dirname(file), LEGACY_RELEASE_CONFIG_FILE);
|
|
67
|
+
return existsSync(legacy)
|
|
68
|
+
? `release config not found: ${file}. A legacy executable config exists at ${legacy}; it is never imported or converted. Write ${RELEASE_CONFIG_FILE}.`
|
|
69
|
+
: `release config not found: ${file}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- strict JSON ----------------------------------------------------------------------------------------------------------------
|
|
73
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
74
|
+
const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
75
|
+
const pointerKey = (key) => key.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
76
|
+
|
|
77
|
+
/** Parse JSON strictly and remember where every value starts: `{ value, offsets }` (offsets: JSON pointer -> character offset). */
|
|
78
|
+
export function parseStrictJson(text) {
|
|
79
|
+
const offsets = new Map();
|
|
80
|
+
let pos = 0;
|
|
81
|
+
const error = (message) => { throw Object.assign(new SyntaxError(message), { offset: pos }); };
|
|
82
|
+
const skip = () => { while (pos < text.length && (text[pos] === " " || text[pos] === "\t" || text[pos] === "\n" || text[pos] === "\r")) pos += 1; };
|
|
83
|
+
const string = () => {
|
|
84
|
+
const start = pos;
|
|
85
|
+
pos += 1;
|
|
86
|
+
while (pos < text.length && text[pos] !== '"') {
|
|
87
|
+
if (text.charCodeAt(pos) < 0x20) error("a control character inside a string");
|
|
88
|
+
pos += text[pos] === "\\" ? 2 : 1;
|
|
89
|
+
}
|
|
90
|
+
if (text[pos] !== '"') error("an unterminated string");
|
|
91
|
+
pos += 1;
|
|
92
|
+
try { return JSON.parse(text.slice(start, pos)); } catch { pos = start; return error("an invalid string escape"); }
|
|
93
|
+
};
|
|
94
|
+
const literal = (word, result) => { if (!text.startsWith(word, pos)) error(`unexpected token`); pos += word.length; return result; };
|
|
95
|
+
const value = (pointer, depth) => {
|
|
96
|
+
if (depth > 64) error("nesting deeper than 64 levels");
|
|
97
|
+
skip();
|
|
98
|
+
offsets.set(pointer, pos);
|
|
99
|
+
const ch = text[pos];
|
|
100
|
+
if (ch === "{") return object(pointer, depth);
|
|
101
|
+
if (ch === "[") return array(pointer, depth);
|
|
102
|
+
if (ch === '"') return string();
|
|
103
|
+
if (ch === "t") return literal("true", true);
|
|
104
|
+
if (ch === "f") return literal("false", false);
|
|
105
|
+
if (ch === "n") return literal("null", null);
|
|
106
|
+
NUMBER.lastIndex = pos;
|
|
107
|
+
const match = NUMBER.exec(text);
|
|
108
|
+
if (!match || match.index !== pos) error("unexpected token");
|
|
109
|
+
pos += match[0].length;
|
|
110
|
+
return Number(match[0]);
|
|
111
|
+
};
|
|
112
|
+
const object = (pointer, depth) => {
|
|
113
|
+
const out = {};
|
|
114
|
+
pos += 1;
|
|
115
|
+
skip();
|
|
116
|
+
if (text[pos] === "}") { pos += 1; return out; }
|
|
117
|
+
for (;;) {
|
|
118
|
+
skip();
|
|
119
|
+
if (text[pos] !== '"') error("expected a string key");
|
|
120
|
+
const keyStart = pos;
|
|
121
|
+
const key = string();
|
|
122
|
+
if (FORBIDDEN_KEYS.has(key)) { pos = keyStart; error(`the key "${key}" is not allowed`); }
|
|
123
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) { pos = keyStart; error(`duplicate key "${key}"`); }
|
|
124
|
+
skip();
|
|
125
|
+
if (text[pos] !== ":") error("expected ':'");
|
|
126
|
+
pos += 1;
|
|
127
|
+
out[key] = value(`${pointer}/${pointerKey(key)}`, depth + 1);
|
|
128
|
+
skip();
|
|
129
|
+
if (text[pos] === ",") { pos += 1; continue; }
|
|
130
|
+
if (text[pos] === "}") { pos += 1; return out; }
|
|
131
|
+
error("expected ',' or '}'");
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const array = (pointer, depth) => {
|
|
135
|
+
const out = [];
|
|
136
|
+
pos += 1;
|
|
137
|
+
skip();
|
|
138
|
+
if (text[pos] === "]") { pos += 1; return out; }
|
|
139
|
+
for (;;) {
|
|
140
|
+
out.push(value(`${pointer}/${out.length}`, depth + 1));
|
|
141
|
+
skip();
|
|
142
|
+
if (text[pos] === ",") { pos += 1; continue; }
|
|
143
|
+
if (text[pos] === "]") { pos += 1; return out; }
|
|
144
|
+
error("expected ',' or ']'");
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const result = value("", 0);
|
|
148
|
+
skip();
|
|
149
|
+
if (pos !== text.length) error("unexpected content after the JSON value");
|
|
150
|
+
return { value: result, offsets };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 1-based line of a JSON pointer in `text` (the nearest recorded ancestor when the exact pointer is not a source position). */
|
|
154
|
+
export function lineOfPointer(text, offsets, pointer) {
|
|
155
|
+
let current = pointer;
|
|
156
|
+
while (current !== "" && !offsets.has(current)) current = current.slice(0, current.lastIndexOf("/"));
|
|
157
|
+
const offset = offsets.get(current) ?? 0;
|
|
158
|
+
let line = 1;
|
|
159
|
+
for (let i = 0; i < offset && i < text.length; i += 1) if (text[i] === "\n") line += 1;
|
|
160
|
+
return line;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- templates ------------------------------------------------------------------------------------------------------------------
|
|
164
|
+
function readInside(appRoot, relative, label, { readBytes, lstat }) {
|
|
165
|
+
if (typeof relative !== "string" || !relative.trim() || path.isAbsolute(relative) || path.win32.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || relative.replaceAll("\\", "/").split("/").includes("..")) {
|
|
166
|
+
fail("RELEASE_CONFIG_TEMPLATE", `${label}: file must be a relative path inside the app: ${JSON.stringify(relative)}`);
|
|
167
|
+
}
|
|
168
|
+
const absolute = path.resolve(appRoot, relative);
|
|
169
|
+
let info;
|
|
170
|
+
try { info = lstat(absolute); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} does not exist`); }
|
|
171
|
+
if (info.isSymbolicLink() || !info.isFile()) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} must be a regular file (no symlink)`);
|
|
172
|
+
if (info.size > MAX_CONFIG_BYTES) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} is larger than ${MAX_CONFIG_BYTES} bytes`);
|
|
173
|
+
const bytes = readBytes(absolute);
|
|
174
|
+
return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), relative: relative.replaceAll("\\", "/") };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function jsonPointerGet(document, pointer, label) {
|
|
178
|
+
if (pointer === "") return document;
|
|
179
|
+
if (typeof pointer !== "string" || !pointer.startsWith("/")) fail("RELEASE_CONFIG_TEMPLATE", `${label}: pointer must be a JSON pointer such as /version`);
|
|
180
|
+
let current = document;
|
|
181
|
+
for (const raw of pointer.slice(1).split("/")) {
|
|
182
|
+
const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
183
|
+
if (!(isObject(current) || Array.isArray(current)) || !Object.prototype.hasOwnProperty.call(current, key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${pointer} is not in the file`);
|
|
184
|
+
current = current[key];
|
|
185
|
+
}
|
|
186
|
+
return current;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function scalarString(value, label) {
|
|
190
|
+
if (typeof value === "string") return value;
|
|
191
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
192
|
+
return fail("RELEASE_CONFIG_TEMPLATE", `${label}: the referenced value must be a string or a number`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Replace the typed templates in `node`. `context` carries the template values, the app root and the injectable file access. */
|
|
196
|
+
function expand(node, context, label) {
|
|
197
|
+
if (typeof node === "string") {
|
|
198
|
+
return node.replace(/\{\{([A-Za-z][A-Za-z0-9]*)\}\}/g, (_, token) => {
|
|
199
|
+
if (!TEMPLATE_TOKENS.includes(token)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: unknown template token {{${token}}} (allowed: ${TEMPLATE_TOKENS.map((t) => `{{${t}}}`).join(", ")})`);
|
|
200
|
+
const resolved = context.values[token];
|
|
201
|
+
if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "localAppData" ? " (LOCALAPPDATA is not set)" : token === "version" ? " here (version cannot refer to itself)" : ""}`);
|
|
202
|
+
return resolved;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(node)) return node.map((item, index) => expand(item, context, `${label}/${index}`));
|
|
206
|
+
if (!isObject(node)) return node;
|
|
207
|
+
const keys = Object.keys(node);
|
|
208
|
+
if (keys.length === 1 && keys[0] === "$fromJson") {
|
|
209
|
+
const spec = node.$fromJson;
|
|
210
|
+
if (!isObject(spec) || Object.keys(spec).some((key) => key !== "file" && key !== "pointer") || typeof spec.file !== "string") fail("RELEASE_CONFIG_TEMPLATE", `${label}: $fromJson must be { file, pointer }`);
|
|
211
|
+
const read = readInside(context.appRoot, spec.file, `${label} $fromJson`, context);
|
|
212
|
+
let document;
|
|
213
|
+
try { document = JSON.parse(Buffer.from(read.bytes).toString("utf8")); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.file} is not valid JSON`); }
|
|
214
|
+
context.inputs.set(read.relative, read.sha256);
|
|
215
|
+
return scalarString(jsonPointerGet(document, spec.pointer ?? "", `${label} $fromJson`), label);
|
|
216
|
+
}
|
|
217
|
+
if (keys.length === 1 && keys[0] === "$fromToml") {
|
|
218
|
+
const spec = node.$fromToml;
|
|
219
|
+
if (!isObject(spec) || Object.keys(spec).some((key) => key !== "file" && key !== "path") || typeof spec.file !== "string" || typeof spec.path !== "string") fail("RELEASE_CONFIG_TEMPLATE", `${label}: $fromToml must be { file, path }`);
|
|
220
|
+
const read = readInside(context.appRoot, spec.file, `${label} $fromToml`, context);
|
|
221
|
+
let document;
|
|
222
|
+
try { document = parseToml(Buffer.from(read.bytes).toString("utf8")); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.file} is not valid TOML`); }
|
|
223
|
+
context.inputs.set(read.relative, read.sha256);
|
|
224
|
+
let current = document;
|
|
225
|
+
for (const key of spec.path.split(".")) {
|
|
226
|
+
if (!isObject(current) || !Object.prototype.hasOwnProperty.call(current, key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.path} is not in ${spec.file}`);
|
|
227
|
+
current = current[key];
|
|
228
|
+
}
|
|
229
|
+
return scalarString(current, label);
|
|
230
|
+
}
|
|
231
|
+
if (keys.length === 1 && keys[0] === "$json") return JSON.stringify(expand(node.$json, context, `${label}/$json`));
|
|
232
|
+
const out = {};
|
|
233
|
+
for (const key of keys) {
|
|
234
|
+
if (key.startsWith("$") && !TOP_LEVEL_KEYS.includes(key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}/${key}: "$" keys other than $fromJson, $fromToml and $json are reserved`);
|
|
235
|
+
out[key] = expand(node[key], context, `${label}/${pointerKey(key)}`);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---- shape ----------------------------------------------------------------------------------------------------------------------
|
|
241
|
+
const SAFE_APP = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
242
|
+
const SAFE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
243
|
+
|
|
244
|
+
/** Schema, finite key set and the identity fields. Throws RELEASE_CONFIG_SCHEMA / RELEASE_CONFIG_KEY. */
|
|
245
|
+
export function validateReleaseConfigShape(config, { file = RELEASE_CONFIG_FILE, phase = "final" } = {}) {
|
|
246
|
+
if (!isObject(config)) fail("RELEASE_CONFIG_SCHEMA", `${file} must be a JSON object`, { file });
|
|
247
|
+
if (config.schema !== RELEASE_CONFIG_SCHEMA) fail("RELEASE_CONFIG_SCHEMA", `${file}: unsupported config schema ${JSON.stringify(config.schema ?? null)} (expected ${RELEASE_CONFIG_SCHEMA})`, { file });
|
|
248
|
+
const unknown = Object.keys(config).filter((key) => !TOP_LEVEL_KEYS.includes(key));
|
|
249
|
+
if (unknown.length) fail("RELEASE_CONFIG_KEY", `${file}: unknown top-level key(s): ${unknown.join(", ")}. The config schema is finite (allowed: ${TOP_LEVEL_KEYS.filter((key) => !key.startsWith("$")).join(", ")}); a new capability is a RightKit change.`, { file, unknown });
|
|
250
|
+
if (typeof config.app !== "string" || !SAFE_APP.test(config.app)) fail("RELEASE_CONFIG_SCHEMA", `${file}: app must be a safe name (letters, digits, . _ -)`, { file });
|
|
251
|
+
// Before template expansion a version may still be a { $fromJson | $fromToml } node or carry a {{token}}; the final check is strict.
|
|
252
|
+
const versionIsTemplate = phase === "raw" && (isObject(config.version) || (typeof config.version === "string" && config.version.includes("{{")));
|
|
253
|
+
if (config.version !== undefined && !versionIsTemplate && (typeof config.version !== "string" || !SAFE_VERSION.test(config.version))) fail("RELEASE_CONFIG_SCHEMA", `${file}: version must be a safe version string`, { file });
|
|
254
|
+
for (const key of ["targets", "development", "buildInputs", "distribution"]) {
|
|
255
|
+
if (config[key] !== undefined && !isObject(config[key])) fail("RELEASE_CONFIG_SCHEMA", `${file}: ${key} must be an object`, { file });
|
|
256
|
+
}
|
|
257
|
+
return config;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function packageScripts(appRoot, readText) {
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(readText(path.join(appRoot, "package.json")));
|
|
263
|
+
return isObject(parsed?.scripts) ? parsed.scripts : undefined;
|
|
264
|
+
} catch { return undefined; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Load, validate and expand a release config. Returns `{ config, configPath, appRoot, sha256, inputs, lineOf }`. Throws ReleaseConfigError
|
|
269
|
+
* (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _TEMPLATE | _M24). `validatePipeline: false`
|
|
270
|
+
* skips only the M24 command admission (the pipeline scanner reports those findings itself); nothing else is optional.
|
|
271
|
+
* File access, the generator registry, the environment and the home directory are injectable for tests.
|
|
272
|
+
*/
|
|
273
|
+
export function loadReleaseConfigSync(file, {
|
|
274
|
+
validatePipeline = true, registry, containment, env = process.env, homedir = () => os.homedir(),
|
|
275
|
+
readText = (target) => readFileSync(target, "utf8"), readBytes = (target) => readFileSync(target), lstat = lstatSync,
|
|
276
|
+
} = {}) {
|
|
277
|
+
assertReleaseConfigFileName(file);
|
|
278
|
+
const configPath = path.resolve(file);
|
|
279
|
+
const appRoot = path.dirname(configPath);
|
|
280
|
+
let text;
|
|
281
|
+
try { text = readText(configPath); } catch { fail("RELEASE_CONFIG_NOT_FOUND", missingConfigMessage(configPath), { file: configPath }); }
|
|
282
|
+
if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) fail("RELEASE_CONFIG_INVALID_JSON", `${configPath} is larger than ${MAX_CONFIG_BYTES} bytes`, { file: configPath });
|
|
283
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
284
|
+
let parsed;
|
|
285
|
+
try { parsed = parseStrictJson(text); } catch (error) {
|
|
286
|
+
let line = 1;
|
|
287
|
+
for (let i = 0; i < (error.offset ?? 0) && i < text.length; i += 1) if (text[i] === "\n") line += 1;
|
|
288
|
+
fail("RELEASE_CONFIG_INVALID_JSON", `${configPath}:${line} is not valid strict JSON (${error.message}; no comments, trailing commas or duplicate keys)`, { file: configPath, line });
|
|
289
|
+
}
|
|
290
|
+
const raw = parsed.value;
|
|
291
|
+
validateReleaseConfigShape(raw, { file: configPath, phase: "raw" });
|
|
292
|
+
const inputs = new Map();
|
|
293
|
+
const base = { appRoot, inputs, readBytes, lstat, values: { home: homedir(), localAppData: env.LOCALAPPDATA ?? "", app: "", version: "" } };
|
|
294
|
+
base.values.app = expand(raw.app, { ...base }, "/app");
|
|
295
|
+
if (raw.version !== undefined) base.values.version = expand(raw.version, { ...base, values: { ...base.values, version: "" } }, "/version");
|
|
296
|
+
const config = expand(raw, base, "");
|
|
297
|
+
validateReleaseConfigShape(config, { file: configPath });
|
|
298
|
+
const sha256 = createHash("sha256").update(Buffer.from(text)).digest("hex");
|
|
299
|
+
if (validatePipeline) {
|
|
300
|
+
const findings = validateConfigPipeline(config, {
|
|
301
|
+
scripts: packageScripts(appRoot, readText),
|
|
302
|
+
registry: registry ?? loadGeneratorRegistry(),
|
|
303
|
+
appRoot,
|
|
304
|
+
containment,
|
|
305
|
+
readBytes,
|
|
306
|
+
lineOf: (pointer) => lineOfPointer(text, parsed.offsets, pointer),
|
|
307
|
+
});
|
|
308
|
+
if (findings.length) {
|
|
309
|
+
fail("RELEASE_CONFIG_M24", `${configPath} has ${findings.length} M24 pipeline finding(s); every command must be an admitted RightKit invocation:\n${formatFindings(findings, configPath).map((line) => `- ${line}`).join("\n")}`, { file: configPath, findings });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
config, configPath, appRoot, sha256,
|
|
314
|
+
inputs: [...inputs].map(([file, digest]) => ({ file, sha256: digest })),
|
|
315
|
+
lineOf: (pointer) => lineOfPointer(text, parsed.offsets, pointer),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The config object only (what `(await import(configPath)).default` used to be). */
|
|
320
|
+
export async function loadReleaseConfig(file, options) {
|
|
321
|
+
return loadReleaseConfigSync(file, options).config;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** `{ config, appRoot, configPath }` for the commands that need the app root beside the config. */
|
|
325
|
+
export async function loadAppConfigFile(file = RELEASE_CONFIG_FILE, options) {
|
|
326
|
+
const loaded = loadReleaseConfigSync(file, options);
|
|
327
|
+
return { config: loaded.config, appRoot: loaded.appRoot, configPath: loaded.configPath };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export { collectConfigCommands };
|
package/release-switch-scan.mjs
CHANGED
|
@@ -84,22 +84,60 @@ export function featureClosureFindings(treeText, { forbidden = [...REGISTRY.forb
|
|
|
84
84
|
// M7 lane E: rightkit-daemon `remote` (the HTTPS host with tokio, axum and hyper) may enter a release graph only for an app
|
|
85
85
|
// that declares `releaseSwitches.remoteHost: true` (the Hetzner Pulse and PostRight server builds). `testing` implies `remote`,
|
|
86
86
|
// so it is caught by the same line. Fail-closed: a value other than a boolean throws, and `false` or an absent key refuses.
|
|
87
|
+
// PK17-2: a `remote` that appears ONLY beneath the daemon `remote-loopback` feature (the loopback feature enables the shared
|
|
88
|
+
// router and auth internals) is not a host declaration; that case is judged by `remoteLoopbackFindings`, not here. Any other
|
|
89
|
+
// path to `remote` (a direct request, `testing`, another crate's feature) is still refused exactly as before.
|
|
87
90
|
export const REMOTE_SERVICE_CRATE = "rightkit-daemon";
|
|
88
91
|
export const REMOTE_SERVICE_FEATURE = "remote";
|
|
92
|
+
export const REMOTE_LOOPBACK_FEATURE = "remote-loopback";
|
|
93
|
+
|
|
94
|
+
// Feature lines of a `cargo tree -e features` text with their parent. Default `--prefix indent` output indents each level by
|
|
95
|
+
// four columns (`├── `, `│ `, ` `; `--charset ascii` keeps the width), so depth is the prefix width / 4 and the parent is
|
|
96
|
+
// the nearest earlier line one level up. A flat tree (`--prefix none`) has depth 0 everywhere, so no feature has a parent and
|
|
97
|
+
// every parent-dependent rule treats it as the direct (stricter) case: this fails closed.
|
|
98
|
+
const TREE_PREFIX = /^[\s│├└─|`+\\-]*/;
|
|
99
|
+
function featureTreeEntries(treeText) {
|
|
100
|
+
const entries = []; const stack = [];
|
|
101
|
+
for (const line of String(treeText).split(/\r?\n/)) {
|
|
102
|
+
if (!line.trim()) continue;
|
|
103
|
+
const depth = Math.floor(TREE_PREFIX.exec(line)[0].length / 4);
|
|
104
|
+
const m = FEATURE_LINE.exec(line);
|
|
105
|
+
const entry = { crate: m ? m[1] : null, feature: m ? m[2] : null, parent: depth > 0 ? (stack[depth - 1] ?? null) : null };
|
|
106
|
+
stack[depth] = entry; stack.length = depth + 1;
|
|
107
|
+
if (m) entries.push(entry);
|
|
108
|
+
}
|
|
109
|
+
return entries;
|
|
110
|
+
}
|
|
111
|
+
const impliedByLoopback = (entry) => entry.parent?.crate === REMOTE_SERVICE_CRATE && entry.parent?.feature === REMOTE_LOOPBACK_FEATURE;
|
|
112
|
+
|
|
89
113
|
export function remoteServiceFindings(treeText, { remoteHost = false } = {}) {
|
|
90
114
|
if (typeof remoteHost !== "boolean") throw new Error(`releaseSwitches.remoteHost must be true or false, got ${JSON.stringify(remoteHost)}`);
|
|
91
115
|
if (remoteHost) return [];
|
|
92
116
|
const findings = []; const seen = new Set();
|
|
93
|
-
for (const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
seen.add(m[1]);
|
|
117
|
+
for (const entry of featureTreeEntries(treeText)) {
|
|
118
|
+
if (entry.crate !== REMOTE_SERVICE_CRATE || entry.feature !== REMOTE_SERVICE_FEATURE || seen.has(entry.crate) || impliedByLoopback(entry)) continue;
|
|
119
|
+
seen.add(entry.crate);
|
|
97
120
|
const id = "remote-feature-undeclared";
|
|
98
121
|
findings.push({ layer: "feature-closure", rule: id, file: "<cargo tree>", reason: `${id}: crate \`${REMOTE_SERVICE_CRATE}\` has feature \`${REMOTE_SERVICE_FEATURE}\` enabled in the resolved graph, but the app does not declare releaseSwitches.remoteHost: true (only Pulse and PostRight server builds may enable it)` });
|
|
99
122
|
}
|
|
100
123
|
return findings;
|
|
101
124
|
}
|
|
102
125
|
|
|
126
|
+
// PK17-2 / PK06-5: the daemon loopback feature (CodeRight's loopback HTTP + bearer host, loopback-only bind) is its own release
|
|
127
|
+
// declaration, `releaseSwitches.remoteLoopback: true`, distinct from `remoteHost`. Neither declaration implies the other: a server
|
|
128
|
+
// build with `remoteHost: true` still needs `remoteLoopback: true` to ship the loopback feature, and a loopback app that also
|
|
129
|
+
// enables `remote` on its own still needs `remoteHost: true`. Same fail-closed boolean rule as `remoteHost`.
|
|
130
|
+
export function remoteLoopbackFindings(treeText, { remoteLoopback = false } = {}) {
|
|
131
|
+
if (typeof remoteLoopback !== "boolean") throw new Error(`releaseSwitches.remoteLoopback must be true or false, got ${JSON.stringify(remoteLoopback)}`);
|
|
132
|
+
if (remoteLoopback) return [];
|
|
133
|
+
for (const entry of featureTreeEntries(treeText)) {
|
|
134
|
+
if (entry.crate !== REMOTE_SERVICE_CRATE || entry.feature !== REMOTE_LOOPBACK_FEATURE) continue;
|
|
135
|
+
const id = "remote-loopback-undeclared";
|
|
136
|
+
return [{ layer: "feature-closure", rule: id, file: "<cargo tree>", reason: `${id}: crate \`${REMOTE_SERVICE_CRATE}\` has feature \`${REMOTE_LOOPBACK_FEATURE}\` enabled in the resolved graph, but the app does not declare releaseSwitches.remoteLoopback: true (only a headless engine build that serves its own loopback API may enable it; releaseSwitches.remoteHost does not cover it)` }];
|
|
137
|
+
}
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
103
141
|
// A build command (shell string or { cmd, args }) as one flat token list, whole arguments plus their shell re-tokenisation.
|
|
104
142
|
export function commandTokens(command) {
|
|
105
143
|
const parts = typeof command === "string" ? [command] : [command?.cmd, ...(command?.args ?? [])].filter((v) => v !== undefined).map(String);
|
|
@@ -445,7 +483,7 @@ export function scanSwitchPaths(paths, { denyMap = denyNamesFor() } = {}) {
|
|
|
445
483
|
// Every layer is optional input; a layer given no input is skipped, except that `requireBinary` fails a scan that
|
|
446
484
|
// found no native binary. `bypass: ["feature-closure"]` exists only for the negative fixtures that prove the binary
|
|
447
485
|
// layer independently; release.mjs never passes it.
|
|
448
|
-
export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, devtools } = {}) {
|
|
486
|
+
export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, remoteLoopback, devtools } = {}) {
|
|
449
487
|
const findings = [];
|
|
450
488
|
const bypassed = new Set(bypass);
|
|
451
489
|
const denyMap = denyNamesFor({ appForbid: [...appForbid, ...(inventoryText != null ? inventoryDenyNames(inventoryText) : [])] });
|
|
@@ -453,6 +491,7 @@ export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sou
|
|
|
453
491
|
if (treeText != null) {
|
|
454
492
|
findings.push(...featureClosureFindings(treeText, { allowDevtools: devtools?.mode === "content-only" }));
|
|
455
493
|
findings.push(...remoteServiceFindings(treeText, { remoteHost }));
|
|
494
|
+
findings.push(...remoteLoopbackFindings(treeText, { remoteLoopback }));
|
|
456
495
|
}
|
|
457
496
|
for (const argv of commands) findings.push(...commandSwitchFindings(argv, { allowDevtools: devtools?.mode === "content-only" }));
|
|
458
497
|
}
|
package/release.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mj
|
|
|
54
54
|
import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
|
|
55
55
|
import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
|
|
56
56
|
import { releaseSwitchInputs } from "./ios-build-context.mjs";
|
|
57
|
+
import { productionBuildEnv, productionModeProblems } from "./production-mode.mjs";
|
|
57
58
|
|
|
58
59
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
59
60
|
const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
|
|
@@ -382,6 +383,13 @@ try {
|
|
|
382
383
|
{ label: "target.gradle", command: target.gradle },
|
|
383
384
|
] });
|
|
384
385
|
} catch (error) { fail(error.message); }
|
|
386
|
+
// M12: the frontend build resolves in production mode only (development-only conditional exports such as
|
|
387
|
+
// `@rightkit/license/testing` never resolve). `run` pins NODE_ENV=production for build children; this refuses a
|
|
388
|
+
// Vite config that lists the `development` resolve condition explicitly, which the pin cannot override.
|
|
389
|
+
{
|
|
390
|
+
const modeProblems = productionModeProblems({ appRoot: root });
|
|
391
|
+
if (modeProblems.length) fail(`${config.app ?? "app"} release frontend is not production-only:\n- ${modeProblems.join("\n- ")}`);
|
|
392
|
+
}
|
|
385
393
|
|
|
386
394
|
// D4 (M21/M23) source layer: every environment/argument read in app source is registered in the app's switches.json and
|
|
387
395
|
// every S1-S4 selector is compiled out of release. The inventory is `config.releaseSwitches.inventory` (relative to the
|
|
@@ -415,7 +423,8 @@ try {
|
|
|
415
423
|
console.log(`right-release: dry run: ${error.message}`);
|
|
416
424
|
}
|
|
417
425
|
// remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
|
|
418
|
-
|
|
426
|
+
// remoteLoopback: the daemon `remote-loopback` feature is a separate declaration (releaseSwitches.remoteLoopback: true); remoteHost does not cover it.
|
|
427
|
+
const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, remoteLoopback: config.releaseSwitches?.remoteLoopback, commands: closureCommands.map((c) => commandTokens(c)) };
|
|
419
428
|
if (releaseSwitchInventoryText != null) {
|
|
420
429
|
// iOS: only what the iPhone app compiles — the Xcode project and its local packages, plus the local crates in the
|
|
421
430
|
// resolved aarch64-apple-ios cargo graph (feature-aware, unlike the ownership closure). Other targets walk the app root.
|
|
@@ -979,8 +988,8 @@ function sweepStaleRustArtifacts() {
|
|
|
979
988
|
}
|
|
980
989
|
|
|
981
990
|
async function runInstall(pm, cwd) {
|
|
982
|
-
if (pm === "pnpm") await run("pnpm", ["install", "--frozen-lockfile"], cwd);
|
|
983
|
-
else if (pm === "npm") await run("npm", ["ci"], cwd);
|
|
991
|
+
if (pm === "pnpm") await run("pnpm", ["install", "--frozen-lockfile"], cwd, {}, { install: true });
|
|
992
|
+
else if (pm === "npm") await run("npm", ["ci"], cwd, {}, { install: true });
|
|
984
993
|
else fail(`unsupported package manager: ${pm}`);
|
|
985
994
|
}
|
|
986
995
|
|
|
@@ -1772,7 +1781,8 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
|
|
|
1772
1781
|
const useShell = process.platform === "win32" && !/\.(?:exe|com)$/i.test(cmd);
|
|
1773
1782
|
const child = spawn(cmd, runArgs, {
|
|
1774
1783
|
cwd,
|
|
1775
|
-
|
|
1784
|
+
// Build children resolve in production mode; package installs keep the caller's NODE_ENV (production drops devDependencies).
|
|
1785
|
+
env: macosCodesignEnvironment(options.install ? { ...process.env, ...releaseEnv, ...env } : productionBuildEnv({ ...process.env, ...releaseEnv, ...env })),
|
|
1776
1786
|
stdio: options.captureStdout ? ["inherit", "pipe", "inherit"] : "inherit",
|
|
1777
1787
|
shell: useShell,
|
|
1778
1788
|
windowsHide: true,
|
package/rightkit-ownership.json
CHANGED
|
@@ -2348,6 +2348,40 @@
|
|
|
2348
2348
|
"Fingerprints identify review candidates (competing dependencies and distinctive implementation code); published two-host replacement qualification is not recorded."
|
|
2349
2349
|
]
|
|
2350
2350
|
},
|
|
2351
|
+
{
|
|
2352
|
+
"package": "rightkit-overlay-win",
|
|
2353
|
+
"capability": "windows-native-overlay-renderer",
|
|
2354
|
+
"summary": "Native Windows overlay renderer (Win32 layered window, GDI software canvas, DWM pacing; no WebView2) for Right Suite pills and notches, consuming rightkit-overlay's state, hit testing and placement",
|
|
2355
|
+
"fingerprints": [
|
|
2356
|
+
{
|
|
2357
|
+
"id": "overlay-win.api.update-layered-window",
|
|
2358
|
+
"type": "source",
|
|
2359
|
+
"pattern": "\\bUpdateLayeredWindow\\s*\\(",
|
|
2360
|
+
"codeOnly": true,
|
|
2361
|
+
"extensions": [
|
|
2362
|
+
".rs",
|
|
2363
|
+
".c",
|
|
2364
|
+
".cc",
|
|
2365
|
+
".cpp",
|
|
2366
|
+
".h",
|
|
2367
|
+
".cs"
|
|
2368
|
+
],
|
|
2369
|
+
"tier": "advise",
|
|
2370
|
+
"usageContext": {
|
|
2371
|
+
"hosts": [
|
|
2372
|
+
"windows"
|
|
2373
|
+
],
|
|
2374
|
+
"scope": "Executable code only; authenticated owner provenance required for exclusions"
|
|
2375
|
+
},
|
|
2376
|
+
"tierReason": "No recorded published-version qualification: the crate is imported from HeardRight (bogusyogi/rightkit#896) and not yet published."
|
|
2377
|
+
}
|
|
2378
|
+
],
|
|
2379
|
+
"detection": "fingerprinted",
|
|
2380
|
+
"policyNotes": [
|
|
2381
|
+
"Package contract retained; dependency declarations do not verify integration.",
|
|
2382
|
+
"Visual design and the app's state machine stay per-app; the renderer, window ownership thread and layered-window presentation are shared (bogusyogi/rightkit#896)."
|
|
2383
|
+
]
|
|
2384
|
+
},
|
|
2351
2385
|
{
|
|
2352
2386
|
"package": "rightkit-ownership",
|
|
2353
2387
|
"capability": "ownership-scanner",
|
|
@@ -2359,6 +2393,18 @@
|
|
|
2359
2393
|
"Shared contract fixtures live in packages/release/fixtures/ownership and are run in place by this crate's tests."
|
|
2360
2394
|
]
|
|
2361
2395
|
},
|
|
2396
|
+
{
|
|
2397
|
+
"package": "rightkit-ownership",
|
|
2398
|
+
"capability": "appkit-autorelease-pool",
|
|
2399
|
+
"summary": "AppKit, Accessibility and CoreFoundation calls inside a long-lived std::thread loop must drain objc2::rc::autoreleasepool once per iteration; a pool-less polling thread leaks every autoreleased object it touches (heardright#292: 2.18M NSRunningApplication objects, 1.1 GB after 62 h).",
|
|
2400
|
+
"fingerprints": [],
|
|
2401
|
+
"detection": "declaration-only",
|
|
2402
|
+
"policyNotes": [
|
|
2403
|
+
"Declaration-only: the scanner evaluates this rule natively (rightkit-ownership src/autorelease.rs), not as a catalog regex, because it must see a thread closure, its loop and the absence of a pool inside the loop. The rule runs only when the scanned catalog declares this capability.",
|
|
2404
|
+
"Finding fingerprint appkit.autorelease.thread-loop-no-pool, block tier, reported at the spawn line of a macOS-scoped .rs file (path contains /macos/ or macos_, or a non-negated target_os = \"macos\" cfg). Fix: drain objc2::rc::autoreleasepool once per loop iteration; a pool around the whole loop never drains.",
|
|
2405
|
+
"An app records a reviewed exception in .rightkit/ownership-exceptions.json with capability appkit-autorelease-pool, package rightkit-ownership, optional fingerprint and path, reason, owner and date (Orthic-Labs/rightkit#893)."
|
|
2406
|
+
]
|
|
2407
|
+
},
|
|
2362
2408
|
{
|
|
2363
2409
|
"package": "rightkit-pdf",
|
|
2364
2410
|
"capability": "pdf-engine",
|
|
@@ -3504,6 +3550,17 @@
|
|
|
3504
3550
|
"Fingerprints identify review candidates; published two-host replacement qualification is not recorded."
|
|
3505
3551
|
]
|
|
3506
3552
|
},
|
|
3553
|
+
{
|
|
3554
|
+
"package": "@rightkit/issues-tracker",
|
|
3555
|
+
"capability": "issues-tracker",
|
|
3556
|
+
"summary": "Per-repository issues.html tracker generated from GitHub issues by right-issues: groups, label-driven status chips, offline, light and dark; declaration-only detection (no precise implementation fingerprint)",
|
|
3557
|
+
"fingerprints": [],
|
|
3558
|
+
"detection": "declaration-only",
|
|
3559
|
+
"policyNotes": [
|
|
3560
|
+
"Package contract retained; dependency declarations do not verify integration.",
|
|
3561
|
+
"No precise implementation fingerprint; declaration-only inventory, never enforced ownership or verified integration."
|
|
3562
|
+
]
|
|
3563
|
+
},
|
|
3507
3564
|
{
|
|
3508
3565
|
"package": "@rightkit/legal",
|
|
3509
3566
|
"capability": "legal-assent",
|
package/rightkit-sync.mjs
CHANGED
|
@@ -192,6 +192,14 @@ export function stageSourceVersions(entries, { file = VERSIONS_FILE, root = righ
|
|
|
192
192
|
return { changes, copies, written };
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** Feed file and consumer-copy root of one requested workspace. recordPublished/stageSourceVersions default to the
|
|
196
|
+
* location of THIS module, which is inside the package store when right-release runs from an installed
|
|
197
|
+
* @rightkit/release; every caller that knows its workspace passes these explicitly. */
|
|
198
|
+
export function workspaceFeedOptions(workspaceRoot) {
|
|
199
|
+
const root = path.resolve(workspaceRoot);
|
|
200
|
+
return { root, file: path.join(root, "packages", "release", "rightkit-versions.json") };
|
|
201
|
+
}
|
|
202
|
+
|
|
195
203
|
/** Record one just-published version (the registry index can lag the upload) and refresh the copies.
|
|
196
204
|
* Called by right-release after a verified cargo or npm publish; never fails the publish itself unless `strict` is set. */
|
|
197
205
|
export function recordPublished({ registry, name, version }, { file = VERSIONS_FILE, root = rightkitRoot, log = (line) => process.stdout.write(`${line}\n`), strict = false } = {}) {
|